Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d018ae2f | |||
| 1b58130e68 | |||
| 12d4b025f7 | |||
| 871ebb31ce | |||
| 600693914f | |||
| 4d89dcd3d7 | |||
| 97c5778a36 | |||
| 22b352c1da | |||
| 15233a68cf | |||
| 2a637eaf3f | |||
| 6c976e9dc5 | |||
| c8ee4b9902 | |||
| 1197415216 | |||
| 18a5d93ae3 | |||
| 09849906e9 | |||
| 691c064a37 | |||
| 34519566ba | |||
| 86d9f49473 | |||
| 2064c0780c | |||
| f439b69451 | |||
| 1eef55016d | |||
| 61118cbdd4 | |||
| 22a61e0b48 | |||
| 13b1f36d4a | |||
| 9e6fc6e071 | |||
| 570ff504ad | |||
| e8b64ffe43 | |||
| ffa63a74f2 | |||
| 716875dd09 | |||
| ef736cb9d7 | |||
| 93c8dc4712 | |||
| f012ebbcba | |||
| 27a5d8daac | |||
| f6c6e4e594 | |||
| 0992548de7 | |||
| 94ca4041ca | |||
| 845a97601d | |||
| 85dd2bb077 | |||
| 3d9b329084 | |||
| 9a36ea2132 | |||
| 1de83ba51d | |||
| ccc4b08d45 | |||
| b168790e0a | |||
| 6824c1cc0c | |||
| 85bc5b9a3f | |||
| 6ea036766a | |||
| 3495d189e1 | |||
| c42ce88921 | |||
| 2e3208f75e | |||
| 6a0a97b702 | |||
| 45c29a99d5 | |||
| a738de6cd8 | |||
| 55e59458a2 | |||
| f910d23fb2 | |||
| c95e9125b9 | |||
| c2b9b32904 | |||
| 0899e53903 | |||
| 32ffe7d634 | |||
| 8374dfedf3 | |||
| e62cd5448e | |||
| 4ed5b88407 | |||
| 44b71e7460 |
@@ -316,6 +316,10 @@ jobs:
|
|||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
PROFILE="Punktfunk iOS App Store Distribution"
|
||||||
|
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||||
|
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||||
|
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||||
|
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
@@ -335,7 +339,10 @@ jobs:
|
|||||||
<key>signingStyle</key><string>manual</string>
|
<key>signingStyle</key><string>manual</string>
|
||||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||||
<key>provisioningProfiles</key>
|
<key>provisioningProfiles</key>
|
||||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
<dict>
|
||||||
|
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||||
|
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Publish the TypeScript SDK (@punktfunk/host) to the Gitea npm registry
|
||||||
|
# (https://git.unom.io/api/packages/unom/npm/).
|
||||||
|
#
|
||||||
|
# Trigger: push a tag `sdk-vX.Y.Z` (must equal sdk/package.json "version"), or run manually.
|
||||||
|
# The SDK versions independently of the app's `v*` tags, so bumping the host doesn't republish it.
|
||||||
|
#
|
||||||
|
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
|
||||||
|
# write:package scope). No new secret needed.
|
||||||
|
name: sdk-publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['sdk-v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: oven/bun:1
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: sdk
|
||||||
|
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
|
||||||
|
working-directory: /
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build (dist/ JS + .d.ts)
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Tag matches package version
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#sdk-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
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||||
|
# .npmrc already maps the @punktfunk scope to the registry; append the auth line.
|
||||||
|
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
||||||
|
bun publish
|
||||||
@@ -153,9 +153,9 @@ jobs:
|
|||||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||||
# toolchain-only probe crate and is excluded.)
|
# toolchain-only probe crate and is excluded.)
|
||||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
||||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
run: |
|
run: |
|
||||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
|||||||
Generated
+188
-19
@@ -2145,7 +2145,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2754,9 +2754,29 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-capture"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ashpd",
|
||||||
|
"libc",
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-frame",
|
||||||
|
"pf-gpu",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-win-display",
|
||||||
|
"pf-zerocopy",
|
||||||
|
"pipewire",
|
||||||
|
"punktfunk-core",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2797,7 +2817,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2816,9 +2836,31 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-encode"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ash",
|
||||||
|
"ffmpeg-next",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"nvidia-video-codec-sdk",
|
||||||
|
"openh264",
|
||||||
|
"pf-frame",
|
||||||
|
"pf-gpu",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-zerocopy",
|
||||||
|
"punktfunk-core",
|
||||||
|
"pyrowave-sys",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2826,8 +2868,73 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-frame"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"libc",
|
||||||
|
"pf-zerocopy",
|
||||||
|
"punktfunk-core",
|
||||||
|
"tracing",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-gpu"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-paths",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-host-config"
|
||||||
|
version = "0.13.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-inject"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ashpd",
|
||||||
|
"futures-util",
|
||||||
|
"libc",
|
||||||
|
"parking_lot",
|
||||||
|
"pf-capture",
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-paths",
|
||||||
|
"punktfunk-core",
|
||||||
|
"reis",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"usbip-sim",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"wayland-protocols-misc",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
"wayland-scanner",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
"xkbcommon",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-paths"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-presenter"
|
||||||
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2840,6 +2947,62 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-vdisplay"
|
||||||
|
version = "0.12.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ashpd",
|
||||||
|
"bytemuck",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-encode",
|
||||||
|
"pf-frame",
|
||||||
|
"pf-gpu",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-paths",
|
||||||
|
"pf-win-display",
|
||||||
|
"punktfunk-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-scanner",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-win-display"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"pf-paths",
|
||||||
|
"punktfunk-core",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-zerocopy"
|
||||||
|
version = "0.13.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ash",
|
||||||
|
"khronos-egl",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -3011,7 +3174,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3027,7 +3190,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3043,7 +3206,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3058,7 +3221,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3077,7 +3240,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3108,7 +3271,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3120,7 +3283,6 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cbc",
|
"cbc",
|
||||||
"ffmpeg-next",
|
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -3134,15 +3296,22 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"nvidia-video-codec-sdk",
|
|
||||||
"openh264",
|
|
||||||
"opus",
|
"opus",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"pf-capture",
|
||||||
"pf-clipboard",
|
"pf-clipboard",
|
||||||
"pf-driver-proto",
|
"pf-driver-proto",
|
||||||
|
"pf-encode",
|
||||||
|
"pf-frame",
|
||||||
|
"pf-gpu",
|
||||||
|
"pf-host-config",
|
||||||
|
"pf-inject",
|
||||||
|
"pf-paths",
|
||||||
|
"pf-vdisplay",
|
||||||
|
"pf-win-display",
|
||||||
|
"pf-zerocopy",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"pyrowave-sys",
|
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -3184,7 +3353,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3198,7 +3367,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3215,7 +3384,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
+11
-1
@@ -11,6 +11,16 @@ members = [
|
|||||||
"crates/pf-console-ui",
|
"crates/pf-console-ui",
|
||||||
"crates/pf-ffvk",
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
|
"crates/pf-paths",
|
||||||
|
"crates/pf-host-config",
|
||||||
|
"crates/pf-gpu",
|
||||||
|
"crates/pf-zerocopy",
|
||||||
|
"crates/pf-frame",
|
||||||
|
"crates/pf-win-display",
|
||||||
|
"crates/pf-encode",
|
||||||
|
"crates/pf-capture",
|
||||||
|
"crates/pf-inject",
|
||||||
|
"crates/pf-vdisplay",
|
||||||
"crates/pyrowave-sys",
|
"crates/pyrowave-sys",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
@@ -37,7 +47,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+20
-2
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.12.0"
|
"version": "0.13.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -2744,7 +2744,7 @@
|
|||||||
},
|
},
|
||||||
"CustomEntry": {
|
"CustomEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"title"
|
"title"
|
||||||
@@ -4617,6 +4617,15 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
|
"last_resize_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;\n`null` when no resize happened / GameStream).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"min_fec": {
|
"min_fec": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -4629,6 +4638,15 @@
|
|||||||
"description": "Video payload size per packet (bytes).",
|
"description": "Video payload size per packet (bytes).",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
|
"time_to_first_frame_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the\nGameStream plane or while the session is still bringing up).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ suspend fun connectToHost(
|
|||||||
): Long {
|
): Long {
|
||||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (baseW, baseH, hz) = settings.effectiveMode(context)
|
||||||
|
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
|
||||||
|
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
|
||||||
|
// native. 1.0 leaves the resolved mode untouched.
|
||||||
|
val (w, h) = RenderScale.apply(
|
||||||
|
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
|
||||||
|
)
|
||||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ data class Settings(
|
|||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val hz: Int = 0,
|
val hz: Int = 0,
|
||||||
val bitrateKbps: Int = 0,
|
val bitrateKbps: Int = 0,
|
||||||
|
/**
|
||||||
|
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
|
||||||
|
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
|
||||||
|
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
|
||||||
|
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
|
||||||
|
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
|
||||||
|
*/
|
||||||
|
val renderScale: Double = 1.0,
|
||||||
/**
|
/**
|
||||||
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||||
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||||
@@ -137,6 +145,7 @@ class SettingsStore(context: Context) {
|
|||||||
height = prefs.getInt(K_H, 0),
|
height = prefs.getInt(K_H, 0),
|
||||||
hz = prefs.getInt(K_HZ, 0),
|
hz = prefs.getInt(K_HZ, 0),
|
||||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||||
|
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
|
||||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
@@ -171,6 +180,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_H, s.height)
|
.putInt(K_H, s.height)
|
||||||
.putInt(K_HZ, s.hz)
|
.putInt(K_HZ, s.hz)
|
||||||
.putInt(K_BITRATE, s.bitrateKbps)
|
.putInt(K_BITRATE, s.bitrateKbps)
|
||||||
|
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
|
||||||
.putBoolean(K_HDR, s.hdrEnabled)
|
.putBoolean(K_HDR, s.hdrEnabled)
|
||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
@@ -193,6 +203,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_H = "height"
|
const val K_H = "height"
|
||||||
const val K_HZ = "hz"
|
const val K_HZ = "hz"
|
||||||
const val K_BITRATE = "bitrate_kbps"
|
const val K_BITRATE = "bitrate_kbps"
|
||||||
|
const val K_RENDER_SCALE = "render_scale"
|
||||||
const val K_HDR = "hdr_enabled"
|
const val K_HDR = "hdr_enabled"
|
||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
@@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
return Triple(w, h, hz)
|
return Triple(w, h, hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module
|
||||||
|
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
|
||||||
|
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
|
||||||
|
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
|
||||||
|
*/
|
||||||
|
object RenderScale {
|
||||||
|
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
|
||||||
|
|
||||||
|
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
|
||||||
|
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
|
||||||
|
|
||||||
|
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
|
||||||
|
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
|
||||||
|
|
||||||
|
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
|
||||||
|
fun label(scale: Double): String = when {
|
||||||
|
scale == 1.0 -> "Native (1×)"
|
||||||
|
scale > 1.0 -> "${trim(scale)}× · supersample"
|
||||||
|
else -> "${trim(scale)}×"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trim(s: Double): String =
|
||||||
|
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
|
||||||
|
|
||||||
|
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
|
||||||
|
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
|
||||||
|
val s = sanitize(scale)
|
||||||
|
var w = maxOf(baseW, 1) * s
|
||||||
|
var h = maxOf(baseH, 1) * s
|
||||||
|
val cap = maxDim.toDouble()
|
||||||
|
val over = maxOf(w / cap, h / cap)
|
||||||
|
if (over > 1.0) {
|
||||||
|
w /= over
|
||||||
|
h /= over
|
||||||
|
}
|
||||||
|
return Pair(evenFloor(w, 320), evenFloor(h, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evenFloor(value: Double, minimum: Int): Int {
|
||||||
|
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
|
||||||
|
return v / 2 * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (scale, label) for the render-scale picker. `1.0` = Native. */
|
||||||
|
val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) }
|
||||||
|
|
||||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||||
|
|
||||||
/** (width, height, label). `(0,0)` = native display. */
|
/** (width, height, label). `(0,0)` = native display. */
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
update(s.copy(bitrateKbps = kbps))
|
update(s.copy(bitrateKbps = kbps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Render scale",
|
||||||
|
options = RENDER_SCALE_OPTIONS,
|
||||||
|
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
|
||||||
|
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode);
|
||||||
|
// < 1 renders under native for a lighter host — this device resamples to the display.
|
||||||
|
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
|
||||||
|
) { scale -> update(s.copy(renderScale = scale)) }
|
||||||
|
|
||||||
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||||
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||||
// device stays visible so the selection is always representable.
|
// device stays visible so the selection is always representable.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of
|
||||||
|
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
|
||||||
|
*/
|
||||||
|
class RenderScaleTest {
|
||||||
|
@Test
|
||||||
|
fun sanitizeClampsAndDefaults() {
|
||||||
|
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
|
||||||
|
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
|
||||||
|
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
|
||||||
|
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
|
||||||
|
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
|
||||||
|
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun maxDimensionIsCodecAware() {
|
||||||
|
assertEquals(4096, RenderScale.maxDimension("h264"))
|
||||||
|
assertEquals(8192, RenderScale.maxDimension("hevc"))
|
||||||
|
assertEquals(8192, RenderScale.maxDimension("av1"))
|
||||||
|
assertEquals(8192, RenderScale.maxDimension("auto"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nativeIsIdentity() {
|
||||||
|
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supersampleDoubles() {
|
||||||
|
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun underRenderHalves() {
|
||||||
|
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resultsAreEven() {
|
||||||
|
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
|
||||||
|
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
|
||||||
|
assertEquals(0, w % 2)
|
||||||
|
assertEquals(0, h % 2)
|
||||||
|
assertEquals(2048 to 1152, w to h)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun overCeilingClampsUniformly() {
|
||||||
|
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
|
||||||
|
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
|
||||||
|
assertTrue(w <= 8192 && h <= 8192)
|
||||||
|
assertEquals(8192 to 4608, w to h)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun h264CeilingIsTighter() {
|
||||||
|
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
|
||||||
|
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun minimumFloorHonoured() {
|
||||||
|
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
|
||||||
|
assertTrue(w >= 320 && h >= 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,9 +52,6 @@ class GamepadFeedback(
|
|||||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
const val TAG_TRIGGER: Byte = 0x03
|
const val TAG_TRIGGER: Byte = 0x03
|
||||||
const val TAG_HID_RAW: Byte = 0x05
|
const val TAG_HID_RAW: Byte = 0x05
|
||||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
|
||||||
// A new host renews far below this, so it never actually holds this long there.
|
|
||||||
const val LEGACY_RUMBLE_MS = 60_000L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||||
@@ -95,19 +92,19 @@ class GamepadFeedback(
|
|||||||
while (running) {
|
while (running) {
|
||||||
val ev = NativeBridge.nativeNextRumble(handle)
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
if (ev < 0L) continue // timeout / closed
|
if (ev < 0L) continue // timeout / closed
|
||||||
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||||
// long one-shot.
|
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||||
|
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||||
|
// the backstop (the hardware net under a stalled poll thread).
|
||||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
|
||||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
|
||||||
renderRumble(
|
renderRumble(
|
||||||
pad,
|
pad,
|
||||||
((ev ushr 16) and 0xFFFF).toInt(),
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
(ev and 0xFFFF).toInt(),
|
(ev and 0xFFFF).toInt(),
|
||||||
durationMs,
|
backstopMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
@@ -212,12 +209,13 @@ class GamepadFeedback(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||||
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's
|
||||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
|
||||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
|
||||||
|
* real stop mechanism.
|
||||||
*/
|
*/
|
||||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
|||||||
|
//! The event-driven async MediaCodec decode loop (default) + its feeder/dispatch/present helpers.
|
||||||
|
|
||||||
|
use ndk::data_space::DataSpace;
|
||||||
|
use ndk::media::media_codec::{AsyncNotifyCallback, MediaCodec, MediaCodecDirection};
|
||||||
|
use ndk::media::media_format::MediaFormat;
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||||
|
use punktfunk_core::session::Frame;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::display::{
|
||||||
|
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||||
|
};
|
||||||
|
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||||
|
use super::setup::{
|
||||||
|
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||||
|
configure_low_latency, create_codec, try_set_frame_rate,
|
||||||
|
};
|
||||||
|
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||||
|
|
||||||
|
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||||
|
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||||
|
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||||
|
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
||||||
|
/// loop never inflates the decode stage.
|
||||||
|
struct OutputReady {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||||
|
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||||
|
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||||
|
enum DecodeEvent {
|
||||||
|
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||||
|
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||||
|
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||||
|
Au(Frame, bool),
|
||||||
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
|
InputAvailable(usize),
|
||||||
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
|
OutputAvailable {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
},
|
||||||
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
|
FormatChanged,
|
||||||
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
|
Error { fatal: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
|
||||||
|
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
|
||||||
|
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
|
||||||
|
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
|
||||||
|
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
|
||||||
|
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
|
||||||
|
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
|
||||||
|
pub(super) fn run_async(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
window: NativeWindow,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
|
) {
|
||||||
|
let DecodeOptions {
|
||||||
|
decoder_name,
|
||||||
|
ll_feature,
|
||||||
|
low_latency_mode,
|
||||||
|
is_tv,
|
||||||
|
} = opts;
|
||||||
|
boost_thread_priority();
|
||||||
|
let mode = client.mode();
|
||||||
|
let mime = codec_mime(client.codec);
|
||||||
|
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let codec_name = codec.name().unwrap_or_default();
|
||||||
|
stats.set_decoder(&codec_name, ll_feature);
|
||||||
|
log::info!(
|
||||||
|
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
|
||||||
|
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
|
||||||
|
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
|
||||||
|
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
|
||||||
|
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
|
||||||
|
{
|
||||||
|
let out_tx = ev_tx.clone();
|
||||||
|
let in_tx = ev_tx.clone();
|
||||||
|
let fmt_tx = ev_tx.clone();
|
||||||
|
let err_tx = ev_tx.clone();
|
||||||
|
let cb = AsyncNotifyCallback {
|
||||||
|
on_input_available: Some(Box::new(move |idx| {
|
||||||
|
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
||||||
|
})),
|
||||||
|
on_output_available: Some(Box::new(move |idx, info| {
|
||||||
|
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||||
|
index: idx,
|
||||||
|
pts_us: info.presentation_time_us().max(0) as u64,
|
||||||
|
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||||
|
// decode stage ends when the frame actually became available — not after the
|
||||||
|
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||||
|
decoded_ns: now_realtime_ns(),
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
on_format_changed: Some(Box::new(move |_fmt| {
|
||||||
|
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
||||||
|
})),
|
||||||
|
on_error: Some(Box::new(move |e, code, _detail| {
|
||||||
|
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||||
|
if fatal {
|
||||||
|
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||||
|
} else {
|
||||||
|
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||||
|
}
|
||||||
|
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
||||||
|
log::error!("decode: set_async_notify_callback failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the low-latency format (identical keys to the sync path).
|
||||||
|
let mut format = MediaFormat::new();
|
||||||
|
format.set_str("mime", mime);
|
||||||
|
format.set_i32("width", mode.width as i32);
|
||||||
|
format.set_i32("height", mode.height as i32);
|
||||||
|
format.set_i32(
|
||||||
|
"max-input-size",
|
||||||
|
(mode.width * mode.height).max(2_000_000) as i32,
|
||||||
|
);
|
||||||
|
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||||
|
if client.color.is_hdr() {
|
||||||
|
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||||
|
Ok(meta) => {
|
||||||
|
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||||
|
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||||
|
log::error!("decode: configure failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.start() {
|
||||||
|
log::error!("decode: start failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"decode: decoder started (async) at {}x{}",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||||
|
// off, every form factor gets the original soft seamless hint.
|
||||||
|
if mode.refresh_hz > 0
|
||||||
|
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
|
mode.refresh_hz
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
|
||||||
|
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||||
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
|
// HUD is visible.
|
||||||
|
let clock_offset = client.clock_offset_shared();
|
||||||
|
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
||||||
|
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
||||||
|
let measure_decode = client.wants_decode_latency();
|
||||||
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
|
// reclaimed after the codec is dropped below.
|
||||||
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
|
|
||||||
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
|
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||||
|
let feeder = {
|
||||||
|
let client = client.clone();
|
||||||
|
let stats = stats.clone();
|
||||||
|
let in_flight = in_flight.clone();
|
||||||
|
let clock_offset = clock_offset.clone();
|
||||||
|
let shutdown = shutdown.clone();
|
||||||
|
let ev_tx = ev_tx.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-decode-feed".into())
|
||||||
|
.spawn(move || {
|
||||||
|
feeder_loop(
|
||||||
|
client,
|
||||||
|
stats,
|
||||||
|
measure_decode,
|
||||||
|
in_flight,
|
||||||
|
clock_offset,
|
||||||
|
shutdown,
|
||||||
|
ev_tx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
};
|
||||||
|
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
|
||||||
|
|
||||||
|
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
|
||||||
|
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
|
||||||
|
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||||
|
1_000_000_000i64 / mode.refresh_hz as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
client.register_hot_thread();
|
||||||
|
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||||
|
let mut hint_tried = false;
|
||||||
|
|
||||||
|
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||||
|
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||||
|
let mut ready: Vec<OutputReady> = Vec::new();
|
||||||
|
let mut applied_ds: Option<DataSpace> = None;
|
||||||
|
let mut fed: u64 = 0;
|
||||||
|
let mut rendered: u64 = 0;
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
|
let mut oversized_dropped: u64 = 0;
|
||||||
|
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||||
|
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||||
|
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||||
|
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
|
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||||
|
let mut work_accum_ns: i64 = 0;
|
||||||
|
let mut fatal = false;
|
||||||
|
|
||||||
|
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
||||||
|
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
||||||
|
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
|
||||||
|
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
|
||||||
|
Ok(ev) => Some(ev),
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
};
|
||||||
|
let work_t0 = Instant::now();
|
||||||
|
let mut fmt_dirty = false;
|
||||||
|
let mut aus_dropped: u64 = 0;
|
||||||
|
if let Some(ev) = ev0 {
|
||||||
|
aus_dropped += u64::from(dispatch_event(
|
||||||
|
ev,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut ready,
|
||||||
|
&mut fmt_dirty,
|
||||||
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
|
// presentation across a decode burst, and batched feeding.
|
||||||
|
while let Ok(ev) = ev_rx.try_recv() {
|
||||||
|
aus_dropped += u64::from(dispatch_event(
|
||||||
|
ev,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut ready,
|
||||||
|
&mut fmt_dirty,
|
||||||
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
|
if fmt_dirty {
|
||||||
|
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||||
|
}
|
||||||
|
feed_ready(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut fed,
|
||||||
|
&mut oversized_dropped,
|
||||||
|
);
|
||||||
|
let had_output = !ready.is_empty();
|
||||||
|
present_ready(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
|
&mut ready,
|
||||||
|
&stats,
|
||||||
|
&in_flight,
|
||||||
|
clock_offset.load(Ordering::Relaxed),
|
||||||
|
&tracker,
|
||||||
|
&mut rendered,
|
||||||
|
&mut discarded,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
|
if had_output {
|
||||||
|
if !hint_tried {
|
||||||
|
hint_tried = true;
|
||||||
|
let tids = client.hot_thread_ids();
|
||||||
|
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||||
|
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||||
|
if low_latency_mode {
|
||||||
|
boost_hot_threads(&tids);
|
||||||
|
}
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||||
|
log::info!(
|
||||||
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
|
if hint.is_some() {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
tids.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = &hint {
|
||||||
|
h.report_actual(work_accum_ns);
|
||||||
|
}
|
||||||
|
work_accum_ns = 0;
|
||||||
|
if rendered > 0 && rendered % 300 == 0 {
|
||||||
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||||
|
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||||
|
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||||
|
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||||
|
let now = Instant::now();
|
||||||
|
if aus_dropped > 0 {
|
||||||
|
gate.arm(now);
|
||||||
|
}
|
||||||
|
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||||
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = codec.stop();
|
||||||
|
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||||
|
if let Some(j) = feeder {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||||
|
if let Some(ud) = render_cb {
|
||||||
|
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||||
|
unsafe { release_render_callback(ud) };
|
||||||
|
}
|
||||||
|
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
|
||||||
|
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
|
||||||
|
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
|
||||||
|
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
|
||||||
|
fn feeder_loop(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
measure_decode: bool,
|
||||||
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||||
|
) {
|
||||||
|
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||||
|
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||||
|
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||||
|
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||||
|
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||||
|
let gap = client.note_frame_index(frame.frame_index);
|
||||||
|
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||||
|
// 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 {
|
||||||
|
let received_ns = now_realtime_ns();
|
||||||
|
{
|
||||||
|
let mut g = in_flight
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
|
if g.len() > IN_FLIGHT_CAP {
|
||||||
|
g.pop_front(); // stale — codec never echoed it back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stats.enabled() {
|
||||||
|
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||||
|
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
|
.then_some((lat_ns / 1000) as u64);
|
||||||
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
|
if let Some(hostnet_us) = lat_us {
|
||||||
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
|
pending_split.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
|
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
|
{
|
||||||
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
|
stats.note_host_split(
|
||||||
|
t.host_us as u64,
|
||||||
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||||
|
break; // the decode loop is gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
|
||||||
|
Err(_) => break, // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||||
|
/// dropped on overflow (the caller then requests a keyframe).
|
||||||
|
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||||
|
fn dispatch_event(
|
||||||
|
ev: DecodeEvent,
|
||||||
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
|
free_inputs: &mut VecDeque<usize>,
|
||||||
|
ready: &mut Vec<OutputReady>,
|
||||||
|
fmt_dirty: &mut bool,
|
||||||
|
fatal: &mut bool,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
|
) -> bool {
|
||||||
|
match ev {
|
||||||
|
DecodeEvent::Au(f, gap) => {
|
||||||
|
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||||
|
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||||
|
if gap {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
|
pending_aus.push_back(f);
|
||||||
|
if pending_aus.len() > FRAME_PARK_CAP {
|
||||||
|
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||||
|
DecodeEvent::OutputAvailable {
|
||||||
|
index,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
} => ready.push(OutputReady {
|
||||||
|
index,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
}),
|
||||||
|
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||||
|
DecodeEvent::Error { fatal: f } => {
|
||||||
|
if f {
|
||||||
|
*fatal = true;
|
||||||
|
} else {
|
||||||
|
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||||
|
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||||
|
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||||
|
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||||
|
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||||
|
fn feed_ready(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
|
free_inputs: &mut VecDeque<usize>,
|
||||||
|
fed: &mut u64,
|
||||||
|
oversized_dropped: &mut u64,
|
||||||
|
) {
|
||||||
|
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||||
|
let idx = free_inputs.pop_front().unwrap();
|
||||||
|
let frame = pending_aus.pop_front().unwrap();
|
||||||
|
let pts_us = frame.pts_ns / 1000;
|
||||||
|
let Some(dst) = codec.input_buffer(idx) else {
|
||||||
|
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let au = &frame.data;
|
||||||
|
if au.len() > dst.len() {
|
||||||
|
// The slot was never queued, so it stays ours — recycle it for the next AU.
|
||||||
|
free_inputs.push_front(idx);
|
||||||
|
*oversized_dropped += 1;
|
||||||
|
log::warn!(
|
||||||
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
|
au.len(),
|
||||||
|
dst.len(),
|
||||||
|
*oversized_dropped
|
||||||
|
);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = au.len();
|
||||||
|
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
||||||
|
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
||||||
|
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||||
|
} else {
|
||||||
|
*fed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||||
|
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||||
|
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||||
|
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||||
|
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||||
|
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||||
|
/// drained.
|
||||||
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||||
|
fn present_ready(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
|
ready: &mut Vec<OutputReady>,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
|
clock_offset: i64,
|
||||||
|
tracker: &DisplayTracker,
|
||||||
|
rendered: &mut u64,
|
||||||
|
discarded: &mut u64,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
|
) {
|
||||||
|
if ready.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||||
|
// while visible) — both consume the receipt map, so enter for either.
|
||||||
|
if stats.enabled() || measure_decode {
|
||||||
|
let mut g = in_flight
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
for o in ready.iter() {
|
||||||
|
note_decoded_pts(
|
||||||
|
client,
|
||||||
|
measure_decode,
|
||||||
|
stats,
|
||||||
|
&mut g,
|
||||||
|
clock_offset,
|
||||||
|
o.pts_us,
|
||||||
|
o.decoded_ns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||||
|
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||||
|
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||||
|
let now = Instant::now();
|
||||||
|
let last = ready.len() - 1;
|
||||||
|
let mut skipped: u64 = 0;
|
||||||
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
|
let flags = take_flags(recovery_flags, o.pts_us);
|
||||||
|
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||||
|
let render = i == last && present;
|
||||||
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
|
Ok(()) if render => {
|
||||||
|
*rendered += 1;
|
||||||
|
if stats.enabled() {
|
||||||
|
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(()) => {
|
||||||
|
*discarded += 1;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||||
|
o.index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Display/frame-rendered tracking, render-callback registration, HDR dataspace mapping.
|
||||||
|
|
||||||
|
use ndk::data_space::DataSpace;
|
||||||
|
use ndk::media::media_codec::MediaCodec;
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use super::latency::now_realtime_ns;
|
||||||
|
use super::RENDERED_CAP;
|
||||||
|
|
||||||
|
/// `CLOCK_MONOTONIC` now in nanoseconds — the base of the `systemNano` render timestamp the
|
||||||
|
/// `OnFrameRendered` callback reports (Android's `System.nanoTime`), read only to re-base that
|
||||||
|
/// stamp onto `CLOCK_REALTIME` (see [`on_frame_rendered`]).
|
||||||
|
fn now_monotonic_ns() -> i128 {
|
||||||
|
let mut ts = libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||||
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||||
|
ts.tv_sec as i128 * 1_000_000_000 + ts.tv_nsec as i128
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State shared between the decode loop and the `AMediaCodec` `OnFrameRendered` callback (which
|
||||||
|
/// fires on a codec-internal thread): rendered frames awaiting their render timestamp, so the HUD
|
||||||
|
/// gets the spec's `display` stage (decoded→displayed) and the `capture→displayed` end-to-end
|
||||||
|
/// headline (`design/stats-unification.md` — this replaces Android's v1 `capture→decoded`
|
||||||
|
/// endpoint whenever the platform delivers render callbacks).
|
||||||
|
pub(super) struct DisplayTracker {
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||||
|
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||||
|
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
|
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||||
|
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||||
|
/// callback early-outs) while the overlay is hidden.
|
||||||
|
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayTracker {
|
||||||
|
pub(super) fn new(
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
|
) -> Arc<DisplayTracker> {
|
||||||
|
Arc::new(DisplayTracker {
|
||||||
|
stats,
|
||||||
|
clock_offset,
|
||||||
|
rendered: Mutex::new(VecDeque::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||||
|
/// Caller gates on the HUD being visible.
|
||||||
|
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||||
|
let mut g = self
|
||||||
|
.rendered
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
g.push_back((pts_us, decoded_ns));
|
||||||
|
if g.len() > RENDERED_CAP {
|
||||||
|
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
|
||||||
|
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
|
||||||
|
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
|
||||||
|
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
|
||||||
|
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
|
||||||
|
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
|
||||||
|
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
|
||||||
|
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
|
||||||
|
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
|
||||||
|
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
|
||||||
|
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
|
||||||
|
pub(super) fn install_render_callback(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
tracker: &Arc<DisplayTracker>,
|
||||||
|
) -> Option<*const DisplayTracker> {
|
||||||
|
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
|
||||||
|
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
|
||||||
|
type SetOnFrameRenderedFn = unsafe extern "C" fn(
|
||||||
|
*mut ndk_sys::AMediaCodec,
|
||||||
|
ndk_sys::AMediaCodecOnFrameRendered,
|
||||||
|
*mut c_void,
|
||||||
|
) -> ndk_sys::media_status_t;
|
||||||
|
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
|
||||||
|
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
|
||||||
|
// returns null when the symbol is absent (device below API 33), checked before transmuting the
|
||||||
|
// non-null pointer to its fn-pointer type.
|
||||||
|
let set_on_frame_rendered = unsafe {
|
||||||
|
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||||
|
if sym.is_null() {
|
||||||
|
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||||
|
};
|
||||||
|
let ud = Arc::into_raw(tracker.clone());
|
||||||
|
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
||||||
|
// (reclaimed only after the codec is deleted, per this function's contract).
|
||||||
|
let status = unsafe {
|
||||||
|
set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
|
||||||
|
};
|
||||||
|
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
||||||
|
Some(ud)
|
||||||
|
} else {
|
||||||
|
log::warn!("decode: setOnFrameRenderedCallback failed ({status:?}) — no display stage");
|
||||||
|
// SAFETY: registration failed, so the codec never took the reference — reclaim it now.
|
||||||
|
unsafe { drop(Arc::from_raw(ud)) };
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclaim [`install_render_callback`]'s leaked `Arc` refcount.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Call exactly once, and only after the codec the callback was registered on has been dropped —
|
||||||
|
/// deleting the codec stops its internal threads, so no callback can still be running (or run
|
||||||
|
/// later) against this pointer.
|
||||||
|
pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||||
|
drop(Arc::from_raw(ud));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
||||||
|
/// thread once per output frame actually placed on the output surface, with SurfaceFlinger's
|
||||||
|
/// render timestamp. That timestamp (`system_nano`) is on `CLOCK_MONOTONIC`, so it is re-based
|
||||||
|
/// onto `CLOCK_REALTIME` here — against monotonic-now at callback time, which also cancels any lag
|
||||||
|
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||||
|
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||||
|
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||||
|
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||||
|
/// unwind out of an `extern "C"` fn would abort the process.
|
||||||
|
unsafe extern "C" fn on_frame_rendered(
|
||||||
|
_codec: *mut ndk_sys::AMediaCodec,
|
||||||
|
userdata: *mut c_void,
|
||||||
|
media_time_us: i64,
|
||||||
|
system_nano: i64,
|
||||||
|
) {
|
||||||
|
let t = &*(userdata as *const DisplayTracker);
|
||||||
|
if !t.stats.enabled() {
|
||||||
|
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||||
|
}
|
||||||
|
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||||
|
let pts_us = media_time_us.max(0) as u64;
|
||||||
|
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||||
|
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||||
|
// discipline as `note_decoded_pts`.
|
||||||
|
let mut decoded_ns = None;
|
||||||
|
{
|
||||||
|
let mut g = t
|
||||||
|
.rendered
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
while let Some(&(p, d)) = g.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own callback
|
||||||
|
}
|
||||||
|
g.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
decoded_ns = Some(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let e2e_ns =
|
||||||
|
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||||
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
|
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||||
|
t.stats.note_displayed(e2e_us, display_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||||
|
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
|
||||||
|
/// handling; safe to call repeatedly (`applied_ds` dedups).
|
||||||
|
pub(super) fn apply_hdr_dataspace(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
window: &NativeWindow,
|
||||||
|
applied_ds: &mut Option<DataSpace>,
|
||||||
|
) {
|
||||||
|
if let Some(ds) = hdr_dataspace(codec) {
|
||||||
|
if *applied_ds != Some(ds) {
|
||||||
|
match window.set_buffers_data_space(ds) {
|
||||||
|
Ok(()) => {
|
||||||
|
*applied_ds = Some(ds);
|
||||||
|
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||||
|
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||||
|
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||||
|
pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||||
|
let fmt = codec.output_format();
|
||||||
|
let full_range = fmt.i32("color-range") == Some(1);
|
||||||
|
match fmt.i32("color-transfer") {
|
||||||
|
Some(6) => Some(if full_range {
|
||||||
|
DataSpace::Bt2020Pq
|
||||||
|
} else {
|
||||||
|
DataSpace::Bt2020ItuPq
|
||||||
|
}),
|
||||||
|
Some(7) => Some(if full_range {
|
||||||
|
DataSpace::Bt2020Hlg
|
||||||
|
} else {
|
||||||
|
DataSpace::Bt2020ItuHlg
|
||||||
|
}),
|
||||||
|
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Decode-latency bookkeeping: realtime clock + decoded-pts / user-flags stat recording.
|
||||||
|
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
|
||||||
|
/// capture `pts_ns` after the skew offset is applied.
|
||||||
|
pub(super) fn now_realtime_ns() -> i128 {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as i128)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HUD `decoded` point for one dequeued output frame, keyed by the echoed `presentationTimeUs`:
|
||||||
|
/// build the end-to-end (capture→decoded, skew-corrected, clamped to (0, 10 s)) and `decode`
|
||||||
|
/// (received→decoded, single-clock local, ≥ 0) samples and hand them to
|
||||||
|
/// [`crate::stats::VideoStats::note_decoded`]. The pts keys the receipt stamp in `in_flight`;
|
||||||
|
/// entries older than it are evicted (decode order == input order here — low-latency, no
|
||||||
|
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||||
|
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||||
|
/// stamp (async loop).
|
||||||
|
pub(super) fn note_decoded_pts(
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
|
clock_offset: i64,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
) {
|
||||||
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
|
let mut received_ns = None;
|
||||||
|
while let Some(&(p, r)) = in_flight.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own output buffer
|
||||||
|
}
|
||||||
|
in_flight.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
received_ns = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||||
|
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
||||||
|
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
||||||
|
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
||||||
|
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
||||||
|
if measure_decode {
|
||||||
|
if let Some(us) = decode_us {
|
||||||
|
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
||||||
|
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
||||||
|
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time to
|
||||||
|
// < 1 µs — negligible against the ms-scale figures shown.
|
||||||
|
if stats.enabled() {
|
||||||
|
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||||
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
|
stats.note_decoded(e2e_us, decode_us);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||||
|
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||||
|
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||||
|
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||||
|
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||||
|
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||||
|
pub(super) fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||||
|
while let Some(&(p, f)) = map.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own output buffer
|
||||||
|
}
|
||||||
|
map.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
||||||
|
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||||
|
//!
|
||||||
|
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
||||||
|
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
||||||
|
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
||||||
|
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||||
|
|
||||||
|
mod async_loop;
|
||||||
|
mod display;
|
||||||
|
mod latency;
|
||||||
|
mod setup;
|
||||||
|
mod sync_loop;
|
||||||
|
|
||||||
|
use async_loop::run_async;
|
||||||
|
pub(crate) use setup::{codec_label, codec_mime};
|
||||||
|
use sync_loop::run_sync;
|
||||||
|
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
|
||||||
|
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
|
||||||
|
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
|
||||||
|
const FRAME_PARK_CAP: usize = 16;
|
||||||
|
|
||||||
|
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
||||||
|
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
||||||
|
const IN_FLIGHT_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
||||||
|
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
||||||
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||||
|
const PENDING_SPLIT_CAP: usize = 256;
|
||||||
|
|
||||||
|
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
|
||||||
|
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
|
||||||
|
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
|
||||||
|
/// gets evicted.
|
||||||
|
const RENDERED_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||||
|
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||||
|
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||||
|
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
||||||
|
/// original pipeline, kept as the per-device escape hatch).
|
||||||
|
const USE_ASYNC_DECODE: bool = true;
|
||||||
|
|
||||||
|
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
||||||
|
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
|
||||||
|
pub(crate) struct DecodeOptions {
|
||||||
|
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
|
||||||
|
/// let the platform resolve the default decoder for the MIME.
|
||||||
|
pub decoder_name: Option<String>,
|
||||||
|
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
||||||
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||||
|
pub ll_feature: bool,
|
||||||
|
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
||||||
|
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
||||||
|
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
||||||
|
/// escape hatch.
|
||||||
|
pub low_latency_mode: bool,
|
||||||
|
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||||
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||||
|
pub is_tv: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||||
|
/// Both run until `shutdown` is set or the session closes.
|
||||||
|
pub fn run(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
window: NativeWindow,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
|
) {
|
||||||
|
if opts.low_latency_mode && USE_ASYNC_DECODE {
|
||||||
|
run_async(client, window, shutdown, stats, opts);
|
||||||
|
} else {
|
||||||
|
run_sync(client, window, shutdown, stats, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Codec creation, low-latency config, thread/frame-rate tuning, HDR static-info encode.
|
||||||
|
|
||||||
|
use ndk::media::media_codec::MediaCodec;
|
||||||
|
use ndk::media::media_format::MediaFormat;
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
||||||
|
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
||||||
|
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
||||||
|
/// emitted HEVC).
|
||||||
|
pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||||
|
match codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||||
|
punktfunk_core::quic::CODEC_AV1 => "video/av01",
|
||||||
|
_ => "video/hevc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
||||||
|
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
||||||
|
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
||||||
|
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
||||||
|
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
||||||
|
match codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||||
|
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||||
|
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||||
|
_ => "HEVC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||||
|
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||||
|
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||||
|
pub(super) fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
||||||
|
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
|
||||||
|
if let Some(c) = MediaCodec::from_codec_name(name) {
|
||||||
|
return Some(c);
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
MediaCodec::from_decoder_type(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||||
|
///
|
||||||
|
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||||
|
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||||
|
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||||
|
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||||
|
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||||
|
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||||
|
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||||
|
///
|
||||||
|
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
||||||
|
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
||||||
|
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
|
||||||
|
/// standard key + clock hint + being ranked first in `VideoDecoders`.
|
||||||
|
pub(super) fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
|
||||||
|
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
||||||
|
format.set_i32("low-latency", 1);
|
||||||
|
if !aggressive {
|
||||||
|
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
|
||||||
|
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
|
||||||
|
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
|
||||||
|
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
|
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
||||||
|
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
|
||||||
|
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
|
||||||
|
// does the same, and also relies on it for Amazon's Amlogic fork.
|
||||||
|
format.set_i32("vdec-lowlatency", 1);
|
||||||
|
let name = codec_name.to_ascii_lowercase();
|
||||||
|
let is = |prefix: &str| name.starts_with(prefix);
|
||||||
|
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
|
||||||
|
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
|
||||||
|
// vendor twin.
|
||||||
|
if is("omx.qcom") || is("c2.qti") {
|
||||||
|
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
|
||||||
|
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
|
||||||
|
if is("omx.exynos") || is("c2.exynos") {
|
||||||
|
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
|
||||||
|
if is("omx.amlogic") || is("c2.amlogic") {
|
||||||
|
format.set_i32("vendor.low-latency.enable", 1);
|
||||||
|
}
|
||||||
|
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
|
||||||
|
if is("omx.hisi") || is("c2.hisi") {
|
||||||
|
format.set_i32(
|
||||||
|
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
format.set_i32(
|
||||||
|
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
|
||||||
|
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
|
||||||
|
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
|
||||||
|
// (see `VideoDecoders`) is their treatment.
|
||||||
|
//
|
||||||
|
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
|
||||||
|
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
|
||||||
|
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
|
||||||
|
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
|
||||||
|
// plain realtime `priority` hint instead.
|
||||||
|
if decoder_supports_max_operating_rate(&name) {
|
||||||
|
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||||
|
} else {
|
||||||
|
format.set_i32("priority", 0); // 0 = realtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
|
||||||
|
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
|
||||||
|
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
|
||||||
|
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
|
||||||
|
/// hint above.
|
||||||
|
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||||
|
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
|
||||||
|
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
|
||||||
|
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
|
||||||
|
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
|
||||||
|
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
|
||||||
|
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
|
||||||
|
pub(super) fn boost_hot_threads(tids: &[i32]) {
|
||||||
|
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
|
||||||
|
let self_tid = unsafe { libc::gettid() };
|
||||||
|
for &tid in tids {
|
||||||
|
if tid == self_tid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
|
||||||
|
// syscall; a refusal is reported via the return value, not UB.
|
||||||
|
unsafe {
|
||||||
|
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||||
|
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
||||||
|
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
||||||
|
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
||||||
|
pub(super) fn boost_thread_priority() {
|
||||||
|
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
|
||||||
|
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
|
||||||
|
unsafe {
|
||||||
|
let tid = libc::gettid();
|
||||||
|
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
|
||||||
|
log::warn!(
|
||||||
|
"decode: setpriority(-10) failed (non-fatal): {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
|
||||||
|
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
|
||||||
|
/// floor, so both are dlsym-resolved at runtime (a hard import of a >floor symbol makes
|
||||||
|
/// `dlopen`/`System.load` fail on every API-28/29 device, even where this path is never hit —
|
||||||
|
/// mirrors [`crate::adpf`]):
|
||||||
|
/// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
|
||||||
|
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
|
||||||
|
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||||
|
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||||
|
/// phone. Falls through to the 2-arg hint on API 30.
|
||||||
|
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||||
|
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||||
|
/// decline.
|
||||||
|
pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
|
||||||
|
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||||
|
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||||
|
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
|
||||||
|
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
|
||||||
|
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
|
||||||
|
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||||
|
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
|
||||||
|
// symbol's API level), checked before transmuting the non-null pointer to its fn-pointer type.
|
||||||
|
// `window.ptr()` is the live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||||
|
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||||
|
if is_tv {
|
||||||
|
let sym = libc::dlsym(
|
||||||
|
lib,
|
||||||
|
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
|
||||||
|
);
|
||||||
|
if !sym.is_null() {
|
||||||
|
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||||
|
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||||
|
if sym.is_null() {
|
||||||
|
return false; // device API < 30 — no per-surface frame-rate hint
|
||||||
|
}
|
||||||
|
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||||
|
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
||||||
|
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
||||||
|
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
||||||
|
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
||||||
|
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
||||||
|
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
||||||
|
/// MaxCLL/MaxFALL (nits) match 1:1.
|
||||||
|
pub(super) fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
||||||
|
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
||||||
|
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||||
|
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||||
|
let fields: [u16; 12] = [
|
||||||
|
r[0],
|
||||||
|
r[1],
|
||||||
|
g[0],
|
||||||
|
g[1],
|
||||||
|
b_[0],
|
||||||
|
b_[1], // R, G, B primaries
|
||||||
|
m.white_point[0],
|
||||||
|
m.white_point[1], // white point
|
||||||
|
max_nits,
|
||||||
|
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||||
|
m.max_cll,
|
||||||
|
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||||
|
];
|
||||||
|
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||||
|
for (i, v) in fields.iter().enumerate() {
|
||||||
|
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
//! The synchronous MediaCodec decode loop (the original poll path) + its feed/drain helpers.
|
||||||
|
|
||||||
|
use ndk::data_space::DataSpace;
|
||||||
|
use ndk::media::media_codec::{
|
||||||
|
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
|
||||||
|
OutputBuffer,
|
||||||
|
};
|
||||||
|
use ndk::media::media_format::MediaFormat;
|
||||||
|
use ndk::native_window::NativeWindow;
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||||
|
use punktfunk_core::session::Frame;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::display::{
|
||||||
|
hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||||
|
};
|
||||||
|
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||||
|
use super::setup::{
|
||||||
|
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||||
|
configure_low_latency, create_codec, try_set_frame_rate,
|
||||||
|
};
|
||||||
|
use super::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||||
|
|
||||||
|
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
||||||
|
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
||||||
|
/// only blocking wait is a short output dequeue while input is backed up.
|
||||||
|
pub(super) fn run_sync(
|
||||||
|
client: Arc<NativeClient>,
|
||||||
|
window: NativeWindow,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
opts: DecodeOptions,
|
||||||
|
) {
|
||||||
|
let DecodeOptions {
|
||||||
|
decoder_name,
|
||||||
|
ll_feature,
|
||||||
|
low_latency_mode,
|
||||||
|
is_tv,
|
||||||
|
} = opts;
|
||||||
|
boost_thread_priority();
|
||||||
|
let mode = client.mode();
|
||||||
|
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
|
||||||
|
// out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
||||||
|
let mime = codec_mime(client.codec);
|
||||||
|
let codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
|
||||||
|
// back) drives both the HUD label and which vendor low-latency keys apply below.
|
||||||
|
let codec_name = codec.name().unwrap_or_default();
|
||||||
|
stats.set_decoder(&codec_name, ll_feature);
|
||||||
|
log::info!(
|
||||||
|
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut format = MediaFormat::new();
|
||||||
|
format.set_str("mime", mime);
|
||||||
|
format.set_i32("width", mode.width as i32);
|
||||||
|
format.set_i32("height", mode.height as i32);
|
||||||
|
// Generous input buffer so a large keyframe AU is never truncated.
|
||||||
|
format.set_i32(
|
||||||
|
"max-input-size",
|
||||||
|
(mode.width * mode.height).max(2_000_000) as i32,
|
||||||
|
);
|
||||||
|
// Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
|
||||||
|
// name and the master toggle (see `configure_low_latency`).
|
||||||
|
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||||
|
|
||||||
|
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
|
||||||
|
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
|
||||||
|
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
|
||||||
|
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
|
||||||
|
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
|
||||||
|
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
|
||||||
|
if client.color.is_hdr() {
|
||||||
|
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||||
|
Ok(meta) => {
|
||||||
|
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||||
|
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||||
|
log::error!("decode: configure failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = codec.start() {
|
||||||
|
log::error!("decode: start failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"decode: {mime} decoder started at {}x{}",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
||||||
|
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||||
|
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||||
|
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||||
|
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||||
|
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||||
|
// off, every form factor gets the original soft seamless hint.
|
||||||
|
if mode.refresh_hz > 0
|
||||||
|
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
|
mode.refresh_hz
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
|
||||||
|
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
|
||||||
|
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
|
||||||
|
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
|
||||||
|
// ADPF backend responds well to this. We register this thread now but create the session lazily
|
||||||
|
// on the first presented frame: by then the pump + audio threads have registered their ids too,
|
||||||
|
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
|
||||||
|
let frame_period_ns = if mode.refresh_hz > 0 {
|
||||||
|
1_000_000_000i64 / mode.refresh_hz as i64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
|
||||||
|
let mut hint: Option<crate::adpf::HintSession> = None;
|
||||||
|
let mut hint_tried = false;
|
||||||
|
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
|
||||||
|
// once per rendered frame against the frame-period target.
|
||||||
|
let mut work_accum_ns: i64 = 0;
|
||||||
|
|
||||||
|
let mut fed: u64 = 0;
|
||||||
|
let mut rendered: u64 = 0;
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
|
let mut oversized_dropped: u64 = 0;
|
||||||
|
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||||
|
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||||
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
|
let mut pending: Option<Frame> = None;
|
||||||
|
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||||
|
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||||
|
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||||
|
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||||
|
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||||
|
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||||
|
// re-anchor signalling the platform decoder doesn't expose.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||||
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
|
// HUD flags it "(same-host clock)").
|
||||||
|
let clock_offset = client.clock_offset_shared();
|
||||||
|
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||||
|
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||||
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
|
// reclaimed after the codec is dropped below.
|
||||||
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
|
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||||
|
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||||
|
// for the `decode` stage. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
||||||
|
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
||||||
|
let measure_decode = client.wants_decode_latency();
|
||||||
|
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||||
|
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||||
|
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||||
|
// where receipts are recorded and matched by pts; `network = hostnet − host` (saturating).
|
||||||
|
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
|
||||||
|
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||||
|
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
|
||||||
|
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
|
||||||
|
let mut applied_ds: Option<DataSpace> = None;
|
||||||
|
// One thread feeds AND drains: the NDK AMediaCodec wrapper isn't documented thread-safe for
|
||||||
|
// cross-thread feed/drain, so instead of splitting threads the loop decouples the two — input
|
||||||
|
// dequeue is non-blocking (never stalls presentation of already-decoded frames) and the only
|
||||||
|
// blocking wait is a short output dequeue while input is backed up (decoder progress is exactly
|
||||||
|
// what frees the next input buffer).
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
if pending.is_none() {
|
||||||
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||||
|
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||||
|
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||||
|
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||||
|
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||||
|
if client.note_frame_index(frame.frame_index) {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||||
|
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||||
|
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
|
if fed == 0 {
|
||||||
|
let p = &frame.data;
|
||||||
|
log::info!(
|
||||||
|
"decode: first AU {} bytes, head {:02x?}",
|
||||||
|
p.len(),
|
||||||
|
&p[..p.len().min(6)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||||
|
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||||
|
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||||
|
// capture_pts.
|
||||||
|
if stats.enabled() {
|
||||||
|
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||||
|
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
|
.then_some((lat_ns / 1000) as u64);
|
||||||
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
|
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||||
|
// 0xCF host timings that have arrived — host = the host's own
|
||||||
|
// capture→sent, network = our capture→received minus it (per-frame
|
||||||
|
// tiling; saturating in case of clock jitter).
|
||||||
|
if let Some(hostnet_us) = lat_us {
|
||||||
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
|
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
|
if let Some(i) =
|
||||||
|
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
|
{
|
||||||
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
|
stats.note_host_split(
|
||||||
|
t.host_us as u64,
|
||||||
|
hostnet_us.saturating_sub(t.host_us as u64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = Some(frame);
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||||
|
Err(_) => break, // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
|
||||||
|
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||||
|
let work_t0 = Instant::now();
|
||||||
|
if let Some(frame) = pending.take() {
|
||||||
|
if feed(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
&frame.data,
|
||||||
|
frame.pts_ns / 1000,
|
||||||
|
&mut oversized_dropped,
|
||||||
|
) {
|
||||||
|
fed += 1;
|
||||||
|
if fed % 300 == 0 {
|
||||||
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No input buffer free — transient back-pressure. Keep the AU and let `drain` block
|
||||||
|
// briefly below; a released output buffer is what recycles an input slot.
|
||||||
|
pending = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drain every iteration. When input is blocked, wait ~2 ms on output so the loop rides
|
||||||
|
// decoder progress instead of busy-spinning against a full input queue.
|
||||||
|
let wait = if pending.is_some() {
|
||||||
|
Duration::from_millis(2)
|
||||||
|
} else {
|
||||||
|
Duration::ZERO
|
||||||
|
};
|
||||||
|
let (r, d) = drain(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
|
&window,
|
||||||
|
&mut applied_ds,
|
||||||
|
wait,
|
||||||
|
&stats,
|
||||||
|
&mut in_flight,
|
||||||
|
clock_offset.load(Ordering::Relaxed),
|
||||||
|
&tracker,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
|
);
|
||||||
|
rendered += r;
|
||||||
|
discarded += d;
|
||||||
|
|
||||||
|
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||||
|
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||||
|
// the short output-dequeue wait is included in the tally — for a latency-first client,
|
||||||
|
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
|
||||||
|
// (one `Instant` diff, no report).
|
||||||
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
|
if r > 0 {
|
||||||
|
if !hint_tried {
|
||||||
|
// First presented frame: the pump + audio threads have registered their ids by now.
|
||||||
|
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
|
||||||
|
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||||
|
hint_tried = true;
|
||||||
|
let tids = client.hot_thread_ids();
|
||||||
|
// The pump/audio priority boost is part of the experimental low-latency stack; the
|
||||||
|
// ADPF session itself predates it and always runs (max-performance bias gated inside).
|
||||||
|
if low_latency_mode {
|
||||||
|
boost_hot_threads(&tids);
|
||||||
|
}
|
||||||
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
||||||
|
log::info!(
|
||||||
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
|
if hint.is_some() {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
tids.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = &hint {
|
||||||
|
h.report_actual(work_accum_ns);
|
||||||
|
}
|
||||||
|
work_accum_ns = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||||
|
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||||
|
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||||
|
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||||
|
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||||
|
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||||
|
let now = Instant::now();
|
||||||
|
if gate.poll(client.frames_dropped(), now)
|
||||||
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = codec.stop();
|
||||||
|
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||||
|
if let Some(ud) = render_cb {
|
||||||
|
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||||
|
unsafe { release_render_callback(ud) };
|
||||||
|
}
|
||||||
|
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
|
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||||
|
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
||||||
|
/// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
|
||||||
|
/// corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||||
|
fn feed(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
au: &[u8],
|
||||||
|
pts_us: u64,
|
||||||
|
oversized_dropped: &mut u64,
|
||||||
|
) -> bool {
|
||||||
|
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||||
|
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||||
|
let n = {
|
||||||
|
let dst = buf.buffer_mut();
|
||||||
|
if au.len() > dst.len() {
|
||||||
|
*oversized_dropped += 1;
|
||||||
|
log::warn!(
|
||||||
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
|
au.len(),
|
||||||
|
dst.len(),
|
||||||
|
*oversized_dropped
|
||||||
|
);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
|
||||||
|
} else {
|
||||||
|
let n = au.len();
|
||||||
|
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
|
||||||
|
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
|
||||||
|
// the cast write initializes exactly `dst[..n]`.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
au.as_ptr(),
|
||||||
|
dst.as_mut_ptr().cast::<u8>(),
|
||||||
|
n,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||||
|
log::warn!("decode: queue_input_buffer: {e}");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(DequeuedInputBufferResult::TryAgainLater) => false, // caller keeps the AU pending
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("decode: dequeue_input_buffer: {e}");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dequeue every ready output buffer and present only the NEWEST (render = true), discarding the
|
||||||
|
/// rest (render = false) — when decode falls behind, a back-to-back burst of stale frames on glass
|
||||||
|
/// is worse than skipping straight to the freshest one (the Apple client's 1-slot newest-ready
|
||||||
|
/// ring, ported). `first_wait` is the timeout for the first dequeue only: zero normally, ~2 ms when
|
||||||
|
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
|
||||||
|
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
|
||||||
|
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
|
||||||
|
///
|
||||||
|
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
|
||||||
|
/// finished decoding either way): end-to-end = decoded + clock_offset − capture pts, and the
|
||||||
|
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
|
||||||
|
/// `in_flight` (single-clock local difference, no skew involved). The presented frame's
|
||||||
|
/// `(pts, decoded stamp)` is additionally parked in `tracker` for the OnFrameRendered callback —
|
||||||
|
/// the `display` stage's other endpoint.
|
||||||
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||||
|
fn drain(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
|
window: &NativeWindow,
|
||||||
|
applied_ds: &mut Option<DataSpace>,
|
||||||
|
first_wait: Duration,
|
||||||
|
stats: &crate::stats::VideoStats,
|
||||||
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
|
clock_offset: i64,
|
||||||
|
tracker: &DisplayTracker,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
|
) -> (u64, u64) {
|
||||||
|
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||||
|
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||||
|
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||||
|
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||||
|
let mut held_present = true;
|
||||||
|
let mut discarded: u64 = 0;
|
||||||
|
let mut wait = first_wait;
|
||||||
|
loop {
|
||||||
|
match codec.dequeue_output_buffer(wait) {
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
|
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||||
|
wait = Duration::ZERO;
|
||||||
|
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||||
|
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||||
|
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||||
|
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||||
|
let flags = take_flags(recovery_flags, pts_us);
|
||||||
|
held_present =
|
||||||
|
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||||
|
let meta = if stats.enabled() || measure_decode {
|
||||||
|
// The dequeue IS the sync loop's decoded-availability instant.
|
||||||
|
let decoded_ns = now_realtime_ns();
|
||||||
|
note_decoded_pts(
|
||||||
|
client,
|
||||||
|
measure_decode,
|
||||||
|
stats,
|
||||||
|
in_flight,
|
||||||
|
clock_offset,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
);
|
||||||
|
// The tracker's `display` stage is a HUD concern — park only when visible.
|
||||||
|
stats.enabled().then_some((pts_us, decoded_ns))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some((stale, _)) = held.replace((buf, meta)) {
|
||||||
|
// A newer frame is ready — drop the held one without rendering.
|
||||||
|
if let Err(e) = codec.release_output_buffer(stale, false) {
|
||||||
|
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||||
|
}
|
||||||
|
discarded += 1;
|
||||||
|
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||||
|
// The decoder has parsed the SPS and now reports the stream's real colour signalling
|
||||||
|
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
|
||||||
|
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
|
||||||
|
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
|
||||||
|
// Main10 path from the SPS — no profile override needed. Keep looping (buffers
|
||||||
|
// follow, and any held buffer stays held across this event).
|
||||||
|
wait = Duration::ZERO;
|
||||||
|
if let Some(ds) = hdr_dataspace(codec) {
|
||||||
|
if *applied_ds != Some(ds) {
|
||||||
|
match window.set_buffers_data_space(ds) {
|
||||||
|
Ok(()) => {
|
||||||
|
*applied_ds = Some(ds);
|
||||||
|
log::info!("decode: HDR stream → Surface dataspace {ds}");
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!(
|
||||||
|
"decode: set_buffers_data_space({ds}) failed (non-fatal): {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TryAgainLater / OutputBuffersChanged — nothing more to dequeue now.
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("decode: dequeue_output_buffer: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||||
|
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||||
|
// on glass) and count it as a discard rather than a display.
|
||||||
|
let mut rendered = 0;
|
||||||
|
if let Some((buf, meta)) = held {
|
||||||
|
match codec.release_output_buffer(buf, held_present) {
|
||||||
|
Ok(()) if held_present => {
|
||||||
|
rendered = 1;
|
||||||
|
if let Some((pts_us, decoded_ns)) = meta {
|
||||||
|
tracker.note_rendered(pts_us, decoded_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||||
|
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(rendered, discarded)
|
||||||
|
}
|
||||||
@@ -24,14 +24,19 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
|||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
const TAG_HID_RAW: u8 = 0x05;
|
const TAG_HID_RAW: u8 = 0x05;
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next EFFECTIVE
|
||||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The
|
||||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending
|
||||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands
|
||||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level.
|
||||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
///
|
||||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the
|
||||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
/// command's `backstop_ms` (≤ 5000 — the one-shot duration, i.e. the hardware net under a stalled
|
||||||
|
/// poll thread; the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||||
|
/// mechanism), bits 16..31 = `low`, bits 0..15 = `high` (0..=0xFFFF). `-1` on timeout / session
|
||||||
|
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||||
|
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||||
|
/// poll thread.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -43,24 +48,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is
|
||||||
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
// &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin
|
||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// stops these poll threads (and joins them — unbounded) before nativeClose frees the
|
||||||
|
// handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||||
Ok((pad, low, high, ttl)) => {
|
Ok(cmd) => {
|
||||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
(jlong::from(cmd.pad & 0xF) << 49)
|
||||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
| (jlong::from(cmd.low) << 16)
|
||||||
let (lease_flag, ttl_bits) = match ttl {
|
| jlong::from(cmd.high)
|
||||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
|
||||||
None => (0, 0),
|
|
||||||
};
|
|
||||||
(jlong::from(pad & 0xF) << 49)
|
|
||||||
| lease_flag
|
|
||||||
| ttl_bits
|
|
||||||
| (jlong::from(low) << 16)
|
|
||||||
| jlong::from(high)
|
|
||||||
}
|
}
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2700"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E2955696300948B9009F939C"
|
||||||
|
BuildableName = "PunktfunkWidgetsExtension.appex"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2"
|
||||||
|
queueDebuggingEnabled = "No">
|
||||||
|
<RemoteRunnable
|
||||||
|
runnableDebuggingMode = "2"
|
||||||
|
BundleIdentifier = "com.apple.springboard">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E2955696300948B9009F939C"
|
||||||
|
BuildableName = "PunktfunkWidgetsExtension.appex"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</RemoteRunnable>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetKind"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetDefaultView"
|
||||||
|
value = "timeline"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetFamily"
|
||||||
|
value = "systemMedium"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
|
|
||||||
|
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||||
|
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||||
|
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||||
|
struct ApprovalRequest {
|
||||||
|
let host: StoredHost
|
||||||
|
let advertisedFingerprint: Data?
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
|
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
|
||||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@@ -338,18 +339,20 @@ struct ContentView: View {
|
|||||||
+ "approve it — no need to reconnect.")
|
+ "approve it — no need to reconnect.")
|
||||||
}
|
}
|
||||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||||
.alert(
|
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
||||||
"Can't open",
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { deepLinkNotice != nil },
|
|
||||||
set: { if !$0 { deepLinkNotice = nil } })
|
|
||||||
) {
|
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(deepLinkNotice ?? "")
|
Text(deepLinkNotice ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so
|
||||||
|
/// the manual get/set Binding type-checks on its own instead of inflating the body chain's
|
||||||
|
/// budget (adding it inline tips SwiftUI's per-expression limit — see the split sections idiom).
|
||||||
|
private var deepLinkNoticePresented: Binding<Bool> {
|
||||||
|
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||||||
private func currentModeLine() -> String {
|
private func currentModeLine() -> String {
|
||||||
@@ -736,6 +739,17 @@ struct ContentView: View {
|
|||||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||||
|
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
|
||||||
|
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
|
||||||
|
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
|
||||||
|
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
|
||||||
|
private func scaledMode() -> (width: UInt32, height: UInt32) {
|
||||||
|
RenderScale.apply(
|
||||||
|
baseWidth: width, baseHeight: height,
|
||||||
|
scale: renderScale,
|
||||||
|
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||||
|
}
|
||||||
|
|
||||||
private func startSessionDirect(
|
private func startSessionDirect(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||||
@@ -747,7 +761,7 @@ struct ContentView: View {
|
|||||||
if let approvalReq { awaitingApproval = approvalReq }
|
if let approvalReq { awaitingApproval = approvalReq }
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: scaledMode().width, height: scaledMode().height,
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
compositor: PunktfunkConnection.Compositor(
|
compositor: PunktfunkConnection.Compositor(
|
||||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||||
@@ -930,7 +944,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: scaledMode().width, height: scaledMode().height,
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
compositor: pref,
|
compositor: pref,
|
||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
@@ -941,71 +955,3 @@ struct ContentView: View {
|
|||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
|
||||||
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
|
||||||
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
|
||||||
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
|
||||||
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
|
||||||
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
|
||||||
/// says otherwise).
|
|
||||||
private struct FullscreenController: NSViewRepresentable {
|
|
||||||
let active: Bool
|
|
||||||
@Binding var isFullscreen: Bool
|
|
||||||
|
|
||||||
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
|
||||||
/// and removed on dismantle.
|
|
||||||
final class Coordinator {
|
|
||||||
var observers: [NSObjectProtocol] = []
|
|
||||||
weak var observedWindow: NSWindow?
|
|
||||||
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView { NSView() }
|
|
||||||
|
|
||||||
func updateNSView(_ view: NSView, context: Context) {
|
|
||||||
let want = active
|
|
||||||
let isFullscreen = $isFullscreen
|
|
||||||
let coordinator = context.coordinator
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let window = view.window else { return }
|
|
||||||
observeTransitions(of: window, coordinator: coordinator)
|
|
||||||
let isFull = window.styleMask.contains(.fullScreen)
|
|
||||||
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
|
||||||
if want != isFull { window.toggleFullScreen(nil) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
|
||||||
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
|
||||||
/// no black gap in either direction.
|
|
||||||
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
|
||||||
guard coordinator.observedWindow !== window else { return }
|
|
||||||
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
|
||||||
coordinator.observers.removeAll()
|
|
||||||
coordinator.observedWindow = window
|
|
||||||
let isFullscreen = $isFullscreen
|
|
||||||
for (name, value) in [
|
|
||||||
(NSWindow.willEnterFullScreenNotification, true),
|
|
||||||
(NSWindow.didExitFullScreenNotification, false),
|
|
||||||
] {
|
|
||||||
coordinator.observers.append(NotificationCenter.default.addObserver(
|
|
||||||
forName: name, object: window, queue: .main
|
|
||||||
) { _ in
|
|
||||||
isFullscreen.wrappedValue = value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
|
||||||
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
|
||||||
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
|
||||||
private struct ApprovalRequest {
|
|
||||||
let host: StoredHost
|
|
||||||
let advertisedFingerprint: Data?
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
|
||||||
|
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
|
||||||
|
/// green button / ⌃⌘F — ContentView keys the session view's safe-area handling off the real state,
|
||||||
|
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
|
||||||
|
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
|
||||||
|
/// already in flight, and never touches a window the user fullscreened manually unless `active`
|
||||||
|
/// says otherwise).
|
||||||
|
struct FullscreenController: NSViewRepresentable {
|
||||||
|
let active: Bool
|
||||||
|
@Binding var isFullscreen: Bool
|
||||||
|
|
||||||
|
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
|
||||||
|
/// and removed on dismantle.
|
||||||
|
final class Coordinator {
|
||||||
|
var observers: [NSObjectProtocol] = []
|
||||||
|
weak var observedWindow: NSWindow?
|
||||||
|
/// The last `active` value we DROVE the window to. We toggle only when `active` itself
|
||||||
|
/// changes (stream start/end) — never to correct a mismatch — so a deliberate mid-session
|
||||||
|
/// toggle (⌃⌘F / the green button) isn't snapped back on the next SwiftUI update.
|
||||||
|
var lastActive: Bool?
|
||||||
|
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView { NSView() }
|
||||||
|
|
||||||
|
func updateNSView(_ view: NSView, context: Context) {
|
||||||
|
let want = active
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
let coordinator = context.coordinator
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = view.window else { return }
|
||||||
|
observeTransitions(of: window, coordinator: coordinator)
|
||||||
|
let isFull = window.styleMask.contains(.fullScreen)
|
||||||
|
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
|
||||||
|
// Drive the window only on an `active` EDGE (stream start/end), not to close a mismatch —
|
||||||
|
// so a user's ⌃⌘F / green-button toggle stays put. First pass (lastActive == nil) just
|
||||||
|
// records the state without toggling, so mounting never yanks a window into fullscreen.
|
||||||
|
if coordinator.lastActive != want {
|
||||||
|
coordinator.lastActive = want
|
||||||
|
if want != isFull { window.toggleFullScreen(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
|
||||||
|
/// animating away; `didExit` so the top inset returns only once the title bar is back —
|
||||||
|
/// no black gap in either direction.
|
||||||
|
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
|
||||||
|
guard coordinator.observedWindow !== window else { return }
|
||||||
|
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
coordinator.observers.removeAll()
|
||||||
|
coordinator.observedWindow = window
|
||||||
|
let isFullscreen = $isFullscreen
|
||||||
|
for (name, value) in [
|
||||||
|
(NSWindow.willEnterFullScreenNotification, true),
|
||||||
|
(NSWindow.didExitFullScreenNotification, false),
|
||||||
|
] {
|
||||||
|
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: name, object: window, queue: .main
|
||||||
|
) { _ in
|
||||||
|
isFullscreen.wrappedValue = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The Stream menu's "Toggle Fullscreen" (⌃⌘F) and InputCapture's captured-state interception
|
||||||
|
// both post this; flip the KEY window only (posted app-wide, object nil). The transition
|
||||||
|
// observers above then mirror the real state back into the binding.
|
||||||
|
coordinator.observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .punktfunkToggleFullscreen, object: nil, queue: .main
|
||||||
|
) { [weak window] _ in
|
||||||
|
guard let window, window.isKeyWindow else { return }
|
||||||
|
window.toggleFullScreen(nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -202,9 +202,17 @@ public final class ClipboardSync: NSObject {
|
|||||||
let types = pb.types ?? []
|
let types = pb.types ?? []
|
||||||
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
||||||
offerSeq &+= 1
|
offerSeq &+= 1
|
||||||
let kinds = Self.wireToPasteboard
|
var kinds = Self.wireToPasteboard
|
||||||
.filter { types.contains($0.type) }
|
.filter { types.contains($0.type) }
|
||||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||||
|
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots
|
||||||
|
// TIFF) and only sometimes PNG — announce the portable `image/png` whenever ANY
|
||||||
|
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
|
||||||
|
if !kinds.contains(where: { $0.mime == "image/png" }),
|
||||||
|
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||||
|
{
|
||||||
|
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
||||||
|
}
|
||||||
// Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side.
|
// Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side.
|
||||||
connection.clipOffer(seq: offerSeq, kinds: kinds)
|
connection.clipOffer(seq: offerSeq, kinds: kinds)
|
||||||
}
|
}
|
||||||
@@ -305,8 +313,7 @@ public final class ClipboardSync: NSObject {
|
|||||||
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
||||||
let pb = NSPasteboard.general
|
let pb = NSPasteboard.general
|
||||||
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
||||||
let type = Self.wireToPasteboard.first(where: { $0.wire == mime })?.type,
|
let data = Self.readWireData(pb, mime)
|
||||||
let data = pb.data(forType: type)
|
|
||||||
else {
|
else {
|
||||||
connection.clipCancel(id: reqId)
|
connection.clipCancel(id: reqId)
|
||||||
return
|
return
|
||||||
@@ -322,6 +329,30 @@ public final class ClipboardSync: NSObject {
|
|||||||
connection.clipServe(reqId: reqId, data: Data(), last: true)
|
connection.clipServe(reqId: reqId, data: Data(), last: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read one wire format from the pasteboard, converting where macOS stores a different
|
||||||
|
/// native type: `image/png` is served from a real `.png` entry when present, else converted
|
||||||
|
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
|
||||||
|
/// WebP/AVIF/GIF from browsers — `NSImage` decodes them all) into PNG at fetch time.
|
||||||
|
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
|
||||||
|
guard mime == "image/png" else {
|
||||||
|
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pb.data(forType: type)
|
||||||
|
}
|
||||||
|
if let png = pb.data(forType: .png) {
|
||||||
|
return png
|
||||||
|
}
|
||||||
|
// No native PNG: decode whatever image the pasteboard carries and re-encode.
|
||||||
|
guard let img = NSImage(pasteboard: pb),
|
||||||
|
let tiff = img.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rep.representation(using: .png, properties: [:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
|
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
|
||||||
|
|||||||
@@ -794,6 +794,34 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
||||||
|
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
||||||
|
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
||||||
|
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
|
||||||
|
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
||||||
|
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||||
|
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||||
|
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
||||||
|
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
||||||
|
{
|
||||||
|
feedbackLock.lock()
|
||||||
|
defer { feedbackLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
|
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
||||||
|
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
return (pad, low, high, backstop)
|
||||||
|
case statusNoFrame:
|
||||||
|
return nil
|
||||||
|
case statusClosed:
|
||||||
|
throw PunktfunkClientError.closed
|
||||||
|
default:
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||||
|
|||||||
@@ -155,21 +155,18 @@ public final class GamepadFeedback {
|
|||||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
//
|
//
|
||||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
// Rumble arrives as EFFECTIVE commands from the core's shared policy engine
|
||||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
// (design/rumble-root-fix.md §D): the engine owns leases, legacy staleness,
|
||||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
// and close-drain zeros, and its per-pad mailbox already coalesces — a
|
||||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
// stalled drain wakes to ONE current-level command per pad, and a stop can
|
||||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
// never be shed by a queue. Apply verbatim, in order.
|
||||||
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
|
||||||
var rumbleBurst = 0
|
var rumbleBurst = 0
|
||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
self?.routeRumble(
|
||||||
|
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
for (pad, n) in newestByPad {
|
|
||||||
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
|
||||||
}
|
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
var burst = 0
|
var burst = 0
|
||||||
@@ -218,15 +215,15 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
||||||
/// live renderer — one that just left the forwarded set — is dropped.
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
renderer?.apply(low: low, high: high)
|
||||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withRouting<R>(_ body: () -> R) -> R {
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
|||||||
@@ -23,23 +23,6 @@ enum RumbleTuning {
|
|||||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||||
/// zero is never throttled.
|
/// zero is never throttled.
|
||||||
static let minRebakeSeconds: TimeInterval = 0.025
|
static let minRebakeSeconds: TimeInterval = 0.025
|
||||||
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
|
|
||||||
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
|
||||||
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
|
|
||||||
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
|
|
||||||
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
|
|
||||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
|
||||||
|
|
||||||
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
|
|
||||||
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
|
|
||||||
static let noTTL: UInt32 = .max
|
|
||||||
|
|
||||||
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
|
|
||||||
/// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination
|
|
||||||
/// lease in seconds (render the level for at most this long unless the host renews it).
|
|
||||||
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
|
|
||||||
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
|
|
||||||
}
|
|
||||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||||
/// refresh must never rebuild a player.
|
/// refresh must never rebuild a player.
|
||||||
static let levelEpsilon: Float = 1.0 / 256.0
|
static let levelEpsilon: Float = 1.0 / 256.0
|
||||||
@@ -110,13 +93,15 @@ enum RumbleTuning {
|
|||||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||||
/// `queue` closures — the serial queue is the synchronization.
|
/// `queue` closures — the serial queue is the synchronization.
|
||||||
final class RumbleRenderer: @unchecked Sendable {
|
final class RumbleRenderer: @unchecked Sendable {
|
||||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||||
/// slider level indefinitely.
|
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||||
|
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||||
|
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||||
|
/// the call sites' intent.
|
||||||
struct Policy {
|
struct Policy {
|
||||||
let staleAfter: TimeInterval?
|
static let session = Policy()
|
||||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
static let manual = Policy()
|
||||||
static let manual = Policy(staleAfter: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||||
@@ -160,13 +145,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: Motor?
|
private var high: Motor?
|
||||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
||||||
|
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
||||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
|
||||||
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
|
|
||||||
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the
|
|
||||||
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
|
|
||||||
private var envelopeDeadline: DispatchTime?
|
|
||||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||||
@@ -247,17 +228,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
||||||
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
||||||
/// lapses mid-effect.
|
/// lapses mid-effect.
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
|
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||||
queue.async {
|
queue.async {
|
||||||
self.lastCommand = .now()
|
|
||||||
let active = lowAmp != 0 || highAmp != 0
|
let active = lowAmp != 0 || highAmp != 0
|
||||||
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
|
|
||||||
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
|
|
||||||
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
|
|
||||||
self.envelopeDeadline = .now() + lease
|
|
||||||
} else {
|
|
||||||
self.envelopeDeadline = nil
|
|
||||||
}
|
|
||||||
if active != self.wasActive {
|
if active != self.wasActive {
|
||||||
self.wasActive = active
|
self.wasActive = active
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -275,7 +248,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self.ticker?.cancel()
|
self.ticker?.cancel()
|
||||||
self.ticker = nil
|
self.ticker = nil
|
||||||
self.target = (0, 0)
|
self.target = (0, 0)
|
||||||
self.envelopeDeadline = nil
|
|
||||||
self.wasActive = false
|
self.wasActive = false
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
self.closeHID()
|
||||||
@@ -331,25 +303,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
healthSink?(problem)
|
healthSink?(problem)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watchdog + housekeeping heartbeat while audible.
|
/// Housekeeping heartbeat while audible: segment re-arm, HID keepalive, backoff retries.
|
||||||
|
/// Every liveness decision (lease expiry, legacy-host staleness, session close) lives in the
|
||||||
|
/// core policy engine now — it emits explicit zero commands, so the renderer never guesses
|
||||||
|
/// when a level should end.
|
||||||
private func tick() {
|
private func tick() {
|
||||||
if let deadline = envelopeDeadline {
|
|
||||||
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
|
||||||
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
|
|
||||||
// — the whole point of the envelope model: the motor can't outlive the host's intent.
|
|
||||||
if target != (0, 0), DispatchTime.now() >= deadline {
|
|
||||||
log.warning("rumble: envelope expired unrenewed — silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
envelopeDeadline = nil
|
|
||||||
}
|
|
||||||
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
|
||||||
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
|
|
||||||
// channel (or host) died while a motor was on. A direct-connected pad would have been
|
|
||||||
// stopped by its game long ago — force the same outcome.
|
|
||||||
log.warning(
|
|
||||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ public enum DefaultsKey {
|
|||||||
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||||
/// stream views' `MatchWindowFollower`.
|
/// stream views' `MatchWindowFollower`.
|
||||||
public static let matchWindow = "punktfunk.matchWindow"
|
public static let matchWindow = "punktfunk.matchWindow"
|
||||||
|
/// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host
|
||||||
|
/// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger
|
||||||
|
/// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost
|
||||||
|
/// of more bandwidth AND client decode — both grow ∝ scale²); < 1 renders below native for a
|
||||||
|
/// weak host GPU / constrained link (the presenter upscales). Purely client-side — the host just
|
||||||
|
/// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even +
|
||||||
|
/// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path.
|
||||||
|
public static let renderScale = "punktfunk.renderScale"
|
||||||
public static let compositor = "punktfunk.compositor"
|
public static let compositor = "punktfunk.compositor"
|
||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
@@ -69,6 +77,21 @@ public enum DefaultsKey {
|
|||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
public static let cursorMode = "punktfunk.cursorMode"
|
public static let cursorMode = "punktfunk.cursorMode"
|
||||||
|
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||||
|
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||||
|
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||||
|
/// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the
|
||||||
|
/// opposite convention from their local OS preference.
|
||||||
|
public static let invertScroll = "punktfunk.invertScroll"
|
||||||
|
/// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK
|
||||||
|
/// each PHYSICAL modifier position forwards to the host. `.mac` keeps ⌥ Option → Alt and
|
||||||
|
/// ⌘ Command → Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the
|
||||||
|
/// Option and Command keys — preserving side (L/R) — so the key nearest the space bar acts as
|
||||||
|
/// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / ⊞ / Alt` row.
|
||||||
|
/// Only what's FORWARDED changes; client-local shortcuts (⌘⎋ &co.) stay on the physical ⌘ key.
|
||||||
|
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||||
|
/// both keyboards).
|
||||||
|
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||||
@@ -132,6 +155,12 @@ extension Notification.Name {
|
|||||||
/// discoverable menu-bar surface.
|
/// discoverable menu-bar surface.
|
||||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||||
|
|
||||||
|
/// Posted by the app's Stream menu ("Toggle Fullscreen", ⌃⌘F) and by InputCapture's monitor
|
||||||
|
/// when the same combo fires while input is captured (the menu key-equivalent never reaches a
|
||||||
|
/// captured stream view). The key window's `FullscreenController` flips the window's fullscreen
|
||||||
|
/// state. macOS only.
|
||||||
|
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
|
||||||
|
|
||||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||||
/// which runs in the app's process): the app tears the active session down deliberately
|
/// which runs in the app's process): the app tears the active session down deliberately
|
||||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Render-resolution scaling — the pure geometry behind `DefaultsKey.renderScale`. The client asks
|
||||||
|
// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the
|
||||||
|
// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for
|
||||||
|
// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link).
|
||||||
|
//
|
||||||
|
// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the
|
||||||
|
// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the
|
||||||
|
// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept
|
||||||
|
// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the
|
||||||
|
// fixed-mode connect and the match-window follower.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum RenderScale {
|
||||||
|
/// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1
|
||||||
|
/// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value.
|
||||||
|
public static let range: ClosedRange<Double> = 0.5...4.0
|
||||||
|
|
||||||
|
/// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops
|
||||||
|
/// users reason about.
|
||||||
|
public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
|
||||||
|
|
||||||
|
/// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264
|
||||||
|
/// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in
|
||||||
|
/// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`.
|
||||||
|
public static func maxDimension(codec: String) -> Int {
|
||||||
|
codec == "h264" ? 4096 : 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample".
|
||||||
|
/// Shared by every platform's picker so the wording stays identical.
|
||||||
|
public static func label(_ scale: Double) -> String {
|
||||||
|
if scale == 1.0 { return "Native (1×)" }
|
||||||
|
let magnitude = String(format: "%g×", scale)
|
||||||
|
return scale > 1 ? "\(magnitude) · supersample" : magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native).
|
||||||
|
public static func sanitize(_ raw: Double) -> Double {
|
||||||
|
guard raw > 0 else { return 1.0 }
|
||||||
|
return min(max(raw, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping
|
||||||
|
/// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio
|
||||||
|
/// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200).
|
||||||
|
/// The result is a directly host-valid `Mode` width/height.
|
||||||
|
public static func apply(
|
||||||
|
baseWidth: Int,
|
||||||
|
baseHeight: Int,
|
||||||
|
scale rawScale: Double,
|
||||||
|
maxDimension: Int,
|
||||||
|
minWidth: Int = 320,
|
||||||
|
minHeight: Int = 200
|
||||||
|
) -> (width: UInt32, height: UInt32) {
|
||||||
|
let scale = sanitize(rawScale)
|
||||||
|
var w = Double(max(baseWidth, 1)) * scale
|
||||||
|
var h = Double(max(baseHeight, 1)) * scale
|
||||||
|
// Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact.
|
||||||
|
let cap = Double(maxDimension)
|
||||||
|
let over = max(w / cap, h / cap)
|
||||||
|
if over > 1 {
|
||||||
|
w /= over
|
||||||
|
h /= over
|
||||||
|
}
|
||||||
|
let evenFloor: (Double, Int) -> UInt32 = { value, minimum in
|
||||||
|
let clamped = max(Int(value.rounded(.down)), minimum)
|
||||||
|
return UInt32(clamped / 2 * 2)
|
||||||
|
}
|
||||||
|
return (evenFloor(w, minWidth), evenFloor(h, minHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,13 +52,6 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPolicies() {
|
|
||||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
|
||||||
// holds a level indefinitely.
|
|
||||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
|
||||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||||
@@ -75,45 +68,22 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLeaseSecondsInterpretsWireTTL() {
|
/// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at
|
||||||
// The legacy no-lease sentinel → nil (fall back to the staleness watchdog).
|
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||||
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
|
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||||
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
|
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||||
// A real lease → its duration in seconds (non-nil for any ttl != noTTL).
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
|
|
||||||
// The whole point of v2: a host-supplied lease silences the motor faster than the
|
|
||||||
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
|
|
||||||
// default 400 ms TTL is well under that, on every platform.
|
|
||||||
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
|
|
||||||
XCTAssertNotNil(defaultTTL)
|
|
||||||
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
|
|
||||||
// The ticker must be able to observe an expired lease promptly (well within one TTL).
|
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A v2 envelope with a short TTL, left unrenewed, must self-silence — the renderer's core
|
|
||||||
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
|
|
||||||
func testEnvelopeExpiresWhenUnrenewed() {
|
|
||||||
let renderer = RumbleRenderer(policy: .session)
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
renderer.retarget(nil)
|
renderer.retarget(nil)
|
||||||
// A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own.
|
renderer.apply(low: 0x8000, high: 0x8000)
|
||||||
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
renderer.apply(low: 0, high: 0)
|
||||||
// No assertion on private state; this exercises the expiry path + serial-queue teardown
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
// No assertion on private state; this exercises the stop path + serial-queue teardown
|
||||||
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTuningRelationsTheDesignDependsOn() {
|
func testTuningRelationsTheDesignDependsOn() {
|
||||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
|
||||||
// but trip well before a stuck rumble reads as "still going".
|
|
||||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
|
||||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
|
||||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||||
// segment boundary and gap.
|
// segment boundary and gap.
|
||||||
XCTAssertGreaterThanOrEqual(
|
XCTAssertGreaterThanOrEqual(
|
||||||
@@ -123,9 +93,8 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
// The ticker (which lands throttled levels) must outpace the HID keepalive, or its
|
||||||
// watchdog, or those deadlines could be overshot by a full period.
|
// deadline could be overshot by a full period.
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,8 +896,8 @@ class Plugin:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||||
return {
|
return {
|
||||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||||
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export interface StreamSettings {
|
|||||||
width: number; // 0 = native
|
width: number; // 0 = native
|
||||||
height: number; // 0 = native
|
height: number; // 0 = native
|
||||||
refresh_hz: number; // 0 = native
|
refresh_hz: number; // 0 = native
|
||||||
|
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||||
bitrate_kbps: number; // 0 = host default
|
bitrate_kbps: number; // 0 = host default
|
||||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [
|
|||||||
[2560, 1440, "2560 × 1440"],
|
[2560, 1440, "2560 × 1440"],
|
||||||
];
|
];
|
||||||
const REFRESH = [0, 30, 60, 90, 120];
|
const REFRESH = [0, 30, 60, 90, 120];
|
||||||
|
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||||
|
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||||
|
const renderScaleLabel = (x: number): string =>
|
||||||
|
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||||
const GAMEPAD_LABELS: Record<string, string> = {
|
const GAMEPAD_LABELS: Record<string, string> = {
|
||||||
auto: "Automatic",
|
auto: "Automatic",
|
||||||
@@ -106,6 +110,24 @@ export const SettingsSection: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</RowActions>
|
</RowActions>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Render scale"
|
||||||
|
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||||
|
childrenContainerWidth="max"
|
||||||
|
>
|
||||||
|
<RowActions>
|
||||||
|
<div style={selectShell}>
|
||||||
|
<Dropdown
|
||||||
|
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||||
|
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||||
|
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||||
|
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||||
|
)}
|
||||||
|
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RowActions>
|
||||||
|
</Field>
|
||||||
<SliderField
|
<SliderField
|
||||||
label="Bitrate"
|
label="Bitrate"
|
||||||
description="Mbit/s · 0 = host default"
|
description="Mbit/s · 0 = host default"
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
|||||||
];
|
];
|
||||||
/// `0` = the monitor's native refresh, resolved at connect.
|
/// `0` = the monitor's native refresh, resolved at connect.
|
||||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||||
|
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||||
|
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||||
|
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||||
|
|
||||||
|
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||||
|
fn render_scale_label(scale: f64) -> String {
|
||||||
|
if scale == 1.0 {
|
||||||
|
"Native".to_string()
|
||||||
|
} else if scale > 1.0 {
|
||||||
|
format!("{scale}× (supersample)")
|
||||||
|
} else {
|
||||||
|
format!("{scale}×")
|
||||||
|
}
|
||||||
|
}
|
||||||
const GAMEPADS: &[&str] = &[
|
const GAMEPADS: &[&str] = &[
|
||||||
"auto",
|
"auto",
|
||||||
"xbox360",
|
"xbox360",
|
||||||
@@ -304,6 +318,18 @@ pub fn show(
|
|||||||
"",
|
"",
|
||||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
let scale_names: Vec<String> = RENDER_SCALES
|
||||||
|
.iter()
|
||||||
|
.map(|&s| render_scale_label(s))
|
||||||
|
.collect();
|
||||||
|
let scale_row = ChoiceRow::new(
|
||||||
|
&dialog,
|
||||||
|
inline,
|
||||||
|
"Render scale",
|
||||||
|
"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);
|
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||||
bitrate_row.set_title("Bitrate");
|
bitrate_row.set_title("Bitrate");
|
||||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||||
@@ -346,6 +372,7 @@ pub fn show(
|
|||||||
.build();
|
.build();
|
||||||
stream.add(res_row.widget());
|
stream.add(res_row.widget());
|
||||||
stream.add(hz_row.widget());
|
stream.add(hz_row.widget());
|
||||||
|
stream.add(scale_row.widget());
|
||||||
stream.add(&bitrate_row);
|
stream.add(&bitrate_row);
|
||||||
stream.add(compositor_row.widget());
|
stream.add(compositor_row.widget());
|
||||||
stream.add(decoder_row.widget());
|
stream.add(decoder_row.widget());
|
||||||
@@ -500,6 +527,11 @@ pub fn show(
|
|||||||
res_row.set_selected(res_i as u32);
|
res_row.set_selected(res_i as u32);
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
hz_row.set_selected(hz_i as u32);
|
||||||
|
let scale_i = RENDER_SCALES
|
||||||
|
.iter()
|
||||||
|
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||||
|
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||||
|
scale_row.set_selected(scale_i as u32);
|
||||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||||
pad_row.set_selected(pad_i as u32);
|
pad_row.set_selected(pad_i as u32);
|
||||||
@@ -545,6 +577,8 @@ pub fn show(
|
|||||||
RESOLUTIONS[res_i - 1]
|
RESOLUTIONS[res_i - 1]
|
||||||
};
|
};
|
||||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||||
|
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;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
// Latched at console start (like the stats tier above): toggling Match window in
|
// Latched at console start (like the stats tier above): toggling Match window in
|
||||||
// the console's settings screen applies from the next console launch.
|
// the console's settings screen applies from the next console launch.
|
||||||
match_window: crate::session_main::match_window(&settings_at_start),
|
match_window: crate::session_main::match_window(&settings_at_start),
|
||||||
|
render_scale: settings_at_start.render_scale,
|
||||||
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ mod session_main {
|
|||||||
settings.refresh_hz
|
settings.refresh_hz
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||||
|
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||||
|
// Native/explicit resolution so it composes uniformly with both.
|
||||||
|
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||||
|
mode.width,
|
||||||
|
mode.height,
|
||||||
|
settings.render_scale,
|
||||||
|
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||||
|
);
|
||||||
|
let mode = Mode {
|
||||||
|
width: sw,
|
||||||
|
height: sh,
|
||||||
|
..mode
|
||||||
|
};
|
||||||
SessionParams {
|
SessionParams {
|
||||||
host: addr,
|
host: addr,
|
||||||
port,
|
port,
|
||||||
@@ -372,6 +386,8 @@ mod session_main {
|
|||||||
overlay: None,
|
overlay: None,
|
||||||
window_size: window_size(&settings),
|
window_size: window_size(&settings),
|
||||||
match_window: match_window(&settings),
|
match_window: match_window(&settings),
|
||||||
|
render_scale: settings.render_scale,
|
||||||
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome =
|
let outcome =
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
|||||||
];
|
];
|
||||||
/// `0` = the display's native refresh, resolved at connect.
|
/// `0` = the display's native refresh, resolved at connect.
|
||||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||||
|
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||||
|
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||||
|
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||||
|
|
||||||
|
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||||
|
fn render_scale_label(scale: f64) -> String {
|
||||||
|
if scale == 1.0 {
|
||||||
|
"Native".to_string()
|
||||||
|
} else if scale > 1.0 {
|
||||||
|
format!("{scale}\u{00D7} (supersample)")
|
||||||
|
} else {
|
||||||
|
format!("{scale}\u{00D7}")
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Decode backend presets: `(stored value, display label)`.
|
/// Decode backend presets: `(stored value, display label)`.
|
||||||
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
||||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||||
@@ -193,6 +207,24 @@ pub(crate) fn settings_page(
|
|||||||
s.refresh_hz = REFRESH[i];
|
s.refresh_hz = REFRESH[i];
|
||||||
})
|
})
|
||||||
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
.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()
|
||||||
|
.map(|&x| render_scale_label(x))
|
||||||
|
.collect();
|
||||||
|
let i = RENDER_SCALES
|
||||||
|
.iter()
|
||||||
|
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||||
|
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||||
|
(names, i)
|
||||||
|
};
|
||||||
|
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_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||||
s.compositor = COMPOSITORS[i].0.to_string();
|
s.compositor = COMPOSITORS[i].0.to_string();
|
||||||
@@ -441,6 +473,7 @@ pub(crate) fn settings_page(
|
|||||||
settings_card(vec![
|
settings_card(vec![
|
||||||
res_combo.into(),
|
res_combo.into(),
|
||||||
hz_combo.into(),
|
hz_combo.into(),
|
||||||
|
scale_combo.into(),
|
||||||
fullscreen_toggle.into(),
|
fullscreen_toggle.into(),
|
||||||
comp_combo.into(),
|
comp_combo.into(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Frame capture (plan §7 / §W6): the Linux xdg-ScreenCast/PipeWire portal capturer and the Windows
|
||||||
|
# IDD direct-push capturer, plus the synthetic sources + the Capturer trait, extracted into a
|
||||||
|
# subsystem crate. Depends on the shared frame vocabulary (pf-frame), the zero-copy plumbing
|
||||||
|
# (pf-zerocopy), and the display leaves (pf-win-display) — never on pf-encode: the encode-backend
|
||||||
|
# facts arrive pre-resolved (ZeroCopyPolicy) and the sealed-channel delivery as a closure
|
||||||
|
# (FrameChannelSender), so the capture→encode edge is one-way (plan §2.4).
|
||||||
|
[package]
|
||||||
|
name = "pf-capture"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
rust-version.workspace = true
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
pf-frame = { path = "../pf-frame" }
|
||||||
|
pf-zerocopy = { path = "../pf-zerocopy" }
|
||||||
|
pf-win-display = { path = "../pf-win-display" }
|
||||||
|
pf-gpu = { path = "../pf-gpu" }
|
||||||
|
pf-host-config = { path = "../pf-host-config" }
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# The xdg ScreenCast + RemoteDesktop portals, and the PipeWire consumer for the capture frames.
|
||||||
|
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||||
|
pipewire = "0.9"
|
||||||
|
libc = "0.2"
|
||||||
|
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
||||||
|
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Graphics_Direct3D11",
|
||||||
|
"Win32_Graphics_Direct3D_Fxc",
|
||||||
|
"Win32_Graphics_Dxgi",
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
//! Frame capture (plan §7 / §W6): the capturers themselves — the Linux xdg-ScreenCast/PipeWire
|
||||||
|
//! portal capturer and the Windows IDD direct-push capturer — plus the synthetic test sources and
|
||||||
|
//! the `Capturer` trait, extracted into a subsystem crate. Speaks the shared frame vocabulary
|
||||||
|
//! (`pf-frame`) + the zero-copy plumbing (`pf-zerocopy`) and the display leaves (`pf-win-display`),
|
||||||
|
//! and NEVER `pf-encode` — the capture→encode edge is one-way (the encode-backend facts arrive
|
||||||
|
//! pre-resolved in a [`ZeroCopyPolicy`], and the Windows sealed-channel delivery arrives as a
|
||||||
|
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||||
|
//! orchestrator).
|
||||||
|
|
||||||
|
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
|
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
|
||||||
|
// `pf_frame::CursorOverlay`, so only `DmabufFrame` needs to sit in this crate root's scope.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use pf_frame::DmabufFrame;
|
||||||
|
|
||||||
|
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
||||||
|
/// over a bounded drop-oldest channel (never block the compositor).
|
||||||
|
pub trait Capturer: Send {
|
||||||
|
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||||
|
|
||||||
|
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||||
|
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||||
|
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||||
|
/// overrides it to drain its channel without blocking.
|
||||||
|
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||||
|
self.next_frame().map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
||||||
|
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
||||||
|
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
||||||
|
/// duration of a stream, `false` when it ends.
|
||||||
|
fn set_active(&self, _active: bool) {}
|
||||||
|
|
||||||
|
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
||||||
|
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
||||||
|
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
||||||
|
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
||||||
|
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
||||||
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many frames the encode loop may keep in flight (submitted but not yet polled) before it
|
||||||
|
/// blocks. `1` (the default) is the synchronous loop: capture → submit → poll-blocks, so the
|
||||||
|
/// per-frame wall time is `capture+convert + encode`. A capturer that hands a fresh output texture
|
||||||
|
/// per frame (so the encode of N reads a different texture than the convert of N+1 writes) can return
|
||||||
|
/// `>1` to PIPELINE: the loop submits N+1 before polling N, overlapping the convert/copy on the 3D
|
||||||
|
/// engine with the NVENC-ASIC encode of the prior frame, dropping per-frame wall toward `max(...)`.
|
||||||
|
fn pipeline_depth(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||||
|
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||||
|
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||||
|
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HOST-INITIATED output resize (latency plan P2.3): the session's resize handler has ALREADY
|
||||||
|
/// committed the display's new mode (the manager's in-place mode set), so a capable capturer
|
||||||
|
/// re-sizes its capture surface NOW — no descriptor-poll debounce (that machinery stays, for
|
||||||
|
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||||
|
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||||
|
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||||
|
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||||
|
/// `punktfunk_core` path with no live capture session, and produces obviously non-static
|
||||||
|
/// content (a sweeping bar + animated gradient) so the encoded output is verifiable.
|
||||||
|
pub struct SyntheticCapturer {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
frame_idx: u64,
|
||||||
|
buf: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyntheticCapturer {
|
||||||
|
const BPP: usize = 4; // emits BGRx
|
||||||
|
|
||||||
|
pub fn new(width: u32, height: u32, fps: u32) -> Self {
|
||||||
|
assert!(width > 0 && height > 0 && fps > 0);
|
||||||
|
let buf = vec![0u8; width as usize * height as usize * Self::BPP];
|
||||||
|
SyntheticCapturer {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
frame_idx: 0,
|
||||||
|
buf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capturer for SyntheticCapturer {
|
||||||
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
|
let w = self.width as usize;
|
||||||
|
let h = self.height as usize;
|
||||||
|
let bpp = Self::BPP;
|
||||||
|
let t = self.frame_idx;
|
||||||
|
// A vertical bar sweeps left→right once every ~2s; the background is a gradient
|
||||||
|
// whose phase advances each frame, so every pixel changes frame-to-frame.
|
||||||
|
let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64;
|
||||||
|
let phase = (t % 256) as usize;
|
||||||
|
for y in 0..h {
|
||||||
|
let row = y * w * bpp;
|
||||||
|
for x in 0..w {
|
||||||
|
let i = row + x * bpp;
|
||||||
|
let on_bar = (x as u64).abs_diff(bar_x) < 8;
|
||||||
|
// BGRx byte order: [B, G, R, x]
|
||||||
|
self.buf[i] = if on_bar {
|
||||||
|
255
|
||||||
|
} else {
|
||||||
|
((x + phase) & 0xff) as u8
|
||||||
|
};
|
||||||
|
self.buf[i + 1] = if on_bar {
|
||||||
|
255
|
||||||
|
} else {
|
||||||
|
((y + phase) & 0xff) as u8
|
||||||
|
};
|
||||||
|
self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 };
|
||||||
|
self.buf[i + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64;
|
||||||
|
self.frame_idx += 1;
|
||||||
|
Ok(CapturedFrame {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
pts_ns,
|
||||||
|
format: PixelFormat::Bgrx,
|
||||||
|
payload: FramePayload::Cpu(self.buf.clone()),
|
||||||
|
cursor: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cheap moving test pattern (BGRx) for the streaming path: a pulsing field + a white band
|
||||||
|
/// sweeping down, generated with whole-buffer `fill`s so it stays real-time even at 5K.
|
||||||
|
pub struct FastSyntheticCapturer {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
frame_idx: u64,
|
||||||
|
buf: Vec<u8>,
|
||||||
|
/// PUNKTFUNK_SYNTH_NOISE: every frame is fresh high-entropy noise NVENC can't compress or
|
||||||
|
/// predict, so the encoder hits its (CBR) bitrate target — a throughput test of the real
|
||||||
|
/// encode→FEC→send→recv path. The default flat/band content compresses to ~nothing, so it
|
||||||
|
/// can't generate real Mbps (the encoder is content-driven). xorshift over u64 chunks.
|
||||||
|
noise: bool,
|
||||||
|
rng: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FastSyntheticCapturer {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
assert!(width > 0 && height > 0);
|
||||||
|
FastSyntheticCapturer {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frame_idx: 0,
|
||||||
|
buf: vec![0u8; width as usize * height as usize * 4],
|
||||||
|
noise: std::env::var_os("PUNKTFUNK_SYNTH_NOISE").is_some(),
|
||||||
|
rng: 0x9e3779b97f4a7c15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capturer for FastSyntheticCapturer {
|
||||||
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
|
if self.noise {
|
||||||
|
// Fresh, every-frame-decorrelated noise: reseed from the frame index so consecutive
|
||||||
|
// frames share no structure (forces large P-frames too, not just the keyframe).
|
||||||
|
let mut s = self
|
||||||
|
.rng
|
||||||
|
.wrapping_add(self.frame_idx.wrapping_mul(0x2545F491_4F6CDD1D))
|
||||||
|
| 1;
|
||||||
|
for c in self.buf.chunks_exact_mut(8) {
|
||||||
|
s ^= s << 13;
|
||||||
|
s ^= s >> 7;
|
||||||
|
s ^= s << 17;
|
||||||
|
c.copy_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
self.rng = s;
|
||||||
|
} else {
|
||||||
|
let (w, h) = (self.width as usize, self.height as usize);
|
||||||
|
let row = w * 4;
|
||||||
|
let shade = (self.frame_idx % 256) as u8;
|
||||||
|
self.buf.fill(shade);
|
||||||
|
let band_h = (h / 20).max(1);
|
||||||
|
let band_y = (self.frame_idx as usize * 6) % h;
|
||||||
|
for y in band_y..(band_y + band_h).min(h) {
|
||||||
|
self.buf[y * row..(y + 1) * row].fill(0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.frame_idx += 1;
|
||||||
|
Ok(CapturedFrame {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
pts_ns: 0,
|
||||||
|
format: PixelFormat::Bgrx,
|
||||||
|
payload: FramePayload::Cpu(self.buf.clone()),
|
||||||
|
cursor: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encode-backend facts the Linux zero-copy negotiation needs, resolved **once** here (the host
|
||||||
|
/// facade, which may reach the host `encode`) and passed **into** the capturer — so the capturer never
|
||||||
|
/// calls back into `encode`, keeping the capture→encode dependency one-way (plan §2.4 / §W6). The
|
||||||
|
/// three facts were formerly re-derived inside the PipeWire thread via
|
||||||
|
/// `encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu, pyrowave_capture_modifiers}`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ZeroCopyPolicy {
|
||||||
|
/// The GPU encode backend resolves to VAAPI (AMD/Intel) — the capturer hands raw dmabufs
|
||||||
|
/// straight through instead of the EGL→CUDA import (the host `encode::linux_zero_copy_is_vaapi`).
|
||||||
|
pub backend_is_vaapi: bool,
|
||||||
|
/// 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,
|
||||||
|
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||||
|
/// 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
||||||
|
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||||
|
// but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
|
||||||
|
// direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||||
|
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
||||||
|
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
||||||
|
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
||||||
|
encoder_ingests_rgb_444
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-registered HID compose-kick hook: `(target_rect, desktop_bounds) -> accepted`, both
|
||||||
|
/// `(x, y, w, h)` in desktop coordinates (from CCD). The host facade registers it once at startup
|
||||||
|
/// when the resident virtual HID mouse exists (`inject::mouse_windows::hid_kick`); the IDD-push
|
||||||
|
/// capturer's compose kick then prefers it over `SendInput`, because device-level input is
|
||||||
|
/// delivered regardless of this process's session or the active desktop and wakes a powered-off
|
||||||
|
/// display — the lid-closed first-frame fix. Same one-way-edge philosophy as
|
||||||
|
/// [`FrameChannelSender`]: this crate never reaches back into the host's inject module. `false`
|
||||||
|
/// from the hook = mouse not available right now → the caller falls back to `SendInput`.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub static HID_COMPOSE_KICK: std::sync::OnceLock<HidKickFn> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// The [`HID_COMPOSE_KICK`] hook's shape: `(target_rect, desktop_bounds) -> accepted`, both
|
||||||
|
/// `(x, y, w, h)` in desktop coordinates.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub type HidKickFn = fn((i32, i32, i32, i32), (i32, i32, i32, i32)) -> bool;
|
||||||
|
|
||||||
|
/// Delivers a monitor's sealed frame channel to the pf-vdisplay driver (`IOCTL_SET_FRAME_CHANNEL`) —
|
||||||
|
/// the ONE reach the IDD-push capturer would otherwise make into the host's `vdisplay` module. The
|
||||||
|
/// host facade builds this closure (capturing the pf-vdisplay control device handle + the
|
||||||
|
/// `send_frame_channel` IOCTL wrapper) and hands it in, so this crate delivers the channel without a
|
||||||
|
/// path back to the orchestrator. Called once per ring generation (at attach), never per-frame —
|
||||||
|
/// guardrail-compliant. The handle values in `req` were just duplicated into the driver's WUDFHost
|
||||||
|
/// by the capturer's [`windows::idd_push`] broker; on IOCTL success the DRIVER owns them.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub type FrameChannelSender = std::sync::Arc<
|
||||||
|
dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
// One-time PipeWire library init, shared by the video (portal) and audio capture threads.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod pwinit;
|
||||||
|
|
||||||
|
// The Windows backend lives under `windows/`, the Linux one under `linux/`. Windows capture is IDD
|
||||||
|
// direct-push only (DXGI Desktop Duplication + the WGC relay were removed).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/dxgi.rs"]
|
||||||
|
pub mod dxgi;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/idd_push.rs"]
|
||||||
|
mod idd_push;
|
||||||
|
// The WUDFHost-identity check the IDD-push broker uses is reused by the host's gamepad-channel
|
||||||
|
// bootstrap (`inject::windows::gamepad_raii`); re-export it so that reach stays a leaf dependency.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub use idd_push::verify_is_wudfhost;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "linux/mod.rs"]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/synthetic_nv12.rs"]
|
||||||
|
pub mod synthetic_nv12;
|
||||||
|
|
||||||
|
/// Open the Linux xdg-ScreenCast portal capturer for a client-sized monitor. `anchored` drives
|
||||||
|
/// ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits that grant headlessly. The
|
||||||
|
/// [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn open_portal_monitor(anchored: bool, policy: ZeroCopyPolicy) -> Result<Box<dyn Capturer>> {
|
||||||
|
linux::PortalCapturer::open(anchored, policy).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
||||||
|
/// caller (host facade) explodes its `VirtualOutput` into these primitives + owns nothing after —
|
||||||
|
/// the capturer takes `keepalive`, so dropping it releases the output. `allow_zerocopy` mirrors
|
||||||
|
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn open_virtual_output(
|
||||||
|
remote_fd: Option<std::os::fd::OwnedFd>,
|
||||||
|
node_id: u32,
|
||||||
|
preferred_mode: Option<(u32, u32, u32)>,
|
||||||
|
keepalive: Box<dyn Send>,
|
||||||
|
allow_zerocopy: bool,
|
||||||
|
want_444: bool,
|
||||||
|
policy: ZeroCopyPolicy,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
|
linux::PortalCapturer::from_virtual_output(
|
||||||
|
remote_fd,
|
||||||
|
node_id,
|
||||||
|
preferred_mode,
|
||||||
|
keepalive,
|
||||||
|
allow_zerocopy,
|
||||||
|
want_444,
|
||||||
|
policy,
|
||||||
|
)
|
||||||
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Windows IDD direct-push capturer on a pf-vdisplay target. `sender` delivers the sealed
|
||||||
|
/// frame channel to the driver (the host facade builds it from the vdisplay control device). On
|
||||||
|
/// failure the `keepalive` is handed back so the caller can retire the display.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn open_idd_push(
|
||||||
|
target: pf_frame::dxgi::WinCaptureTarget,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
|
keepalive: Box<dyn Send>,
|
||||||
|
sender: FrameChannelSender,
|
||||||
|
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||||
|
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
|
||||||
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
|
}
|
||||||
+61
-46
@@ -20,7 +20,7 @@
|
|||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
|
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -55,7 +55,7 @@ pub struct PortalCapturer {
|
|||||||
stall_since: Option<std::time::Instant>,
|
stall_since: Option<std::time::Instant>,
|
||||||
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
|
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
|
||||||
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
|
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
|
||||||
/// the process-wide downgrade ([`crate::zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
|
||||||
/// rebuild retries on the CPU offer instead of failing identically forever.
|
/// rebuild retries on the CPU offer instead of failing identically forever.
|
||||||
vaapi_dmabuf: bool,
|
vaapi_dmabuf: bool,
|
||||||
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
||||||
@@ -77,7 +77,7 @@ impl PortalCapturer {
|
|||||||
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
||||||
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
||||||
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
|
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
|
||||||
pub fn open(anchored: bool) -> Result<PortalCapturer> {
|
pub fn open(anchored: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
||||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
@@ -101,37 +101,45 @@ impl PortalCapturer {
|
|||||||
"ScreenCast portal session started; connecting PipeWire"
|
"ScreenCast portal session started; connecting PipeWire"
|
||||||
);
|
);
|
||||||
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
||||||
Ok(spawn_pipewire(Some(fd), node_id, None, true, false)?.into_capturer(node_id, None))
|
Ok(
|
||||||
|
spawn_pipewire(Some(fd), node_id, None, true, false, policy)?
|
||||||
|
.into_capturer(node_id, None),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
|
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
|
||||||
/// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and
|
/// explodes its `vdisplay::VirtualOutput` into these primitives so this crate never depends on
|
||||||
/// take ownership of its keepalive so the output lives exactly as long as this capturer. This
|
/// the vdisplay type: `remote_fd` selects portal-remote vs. default-daemon, `node_id` is the
|
||||||
/// is how the client's requested resolution becomes the captured resolution without scaling.
|
/// output's screencast node, `preferred_mode` seeds format negotiation, and `keepalive` owns the
|
||||||
/// `allow_zerocopy` mirrors [`OutputFormat::gpu`](crate::capture::OutputFormat): `false` forces
|
/// output (dropping the capturer releases it). `allow_zerocopy` mirrors
|
||||||
/// the CPU mmap path, `true` keeps the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`.
|
/// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps
|
||||||
/// `want_444` (a 4:4:4 session) makes the zero-copy worker convert tiled dmabufs to planar
|
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||||
/// YUV444 on the GPU instead of NV12/RGB.
|
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn from_virtual_output(
|
pub fn from_virtual_output(
|
||||||
vout: crate::vdisplay::VirtualOutput,
|
remote_fd: Option<OwnedFd>,
|
||||||
|
node_id: u32,
|
||||||
|
preferred_mode: Option<(u32, u32, u32)>,
|
||||||
|
keepalive: Box<dyn Send>,
|
||||||
allow_zerocopy: bool,
|
allow_zerocopy: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
|
policy: ZeroCopyPolicy,
|
||||||
) -> Result<PortalCapturer> {
|
) -> Result<PortalCapturer> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
node_id = vout.node_id,
|
node_id,
|
||||||
allow_zerocopy,
|
allow_zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
"connecting PipeWire to virtual output"
|
"connecting PipeWire to virtual output"
|
||||||
);
|
);
|
||||||
let node_id = vout.node_id;
|
|
||||||
Ok(spawn_pipewire(
|
Ok(spawn_pipewire(
|
||||||
vout.remote_fd,
|
remote_fd,
|
||||||
node_id,
|
node_id,
|
||||||
vout.preferred_mode,
|
preferred_mode,
|
||||||
allow_zerocopy,
|
allow_zerocopy,
|
||||||
want_444,
|
want_444,
|
||||||
|
policy,
|
||||||
)?
|
)?
|
||||||
.into_capturer(node_id, Some(vout.keepalive)))
|
.into_capturer(node_id, Some(keepalive)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +194,9 @@ fn spawn_pipewire(
|
|||||||
// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
|
// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
|
||||||
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
|
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
|
// capture→encode edge (plan §W6).
|
||||||
|
policy: ZeroCopyPolicy,
|
||||||
) -> Result<PwHandles> {
|
) -> Result<PwHandles> {
|
||||||
// Frames flow from the pipewire thread over a small bounded channel.
|
// Frames flow from the pipewire thread over a small bounded channel.
|
||||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
||||||
@@ -201,13 +212,13 @@ fn spawn_pipewire(
|
|||||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||||
// inner `mod pipewire` shadows the crate name at this scope.
|
// inner `mod pipewire` shadows the crate name at this scope.
|
||||||
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
||||||
let zerocopy = allow_zerocopy && crate::zerocopy::enabled();
|
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
|
||||||
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
|
||||||
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
|
// 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.
|
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
|
||||||
let vaapi_dmabuf = zerocopy
|
let vaapi_dmabuf = zerocopy
|
||||||
&& std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() != Ok("1")
|
&& std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() != Ok("1")
|
||||||
&& crate::encode::linux_zero_copy_is_vaapi();
|
&& policy.backend_is_vaapi;
|
||||||
let join = thread::Builder::new()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-pipewire".into())
|
.name("punktfunk-pipewire".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -223,6 +234,7 @@ fn spawn_pipewire(
|
|||||||
want_444,
|
want_444,
|
||||||
preferred,
|
preferred,
|
||||||
quit_rx,
|
quit_rx,
|
||||||
|
policy,
|
||||||
) {
|
) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
||||||
}
|
}
|
||||||
@@ -330,11 +342,11 @@ impl PortalCapturer {
|
|||||||
or capture never started)",
|
or capture never started)",
|
||||||
self.node_id
|
self.node_id
|
||||||
))
|
))
|
||||||
} else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() {
|
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||||
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
|
||||||
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
|
||||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||||
crate::zerocopy::note_vaapi_dmabuf_failed();
|
pf_zerocopy::note_vaapi_dmabuf_failed();
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||||
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||||
@@ -604,7 +616,7 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
|
|||||||
mod pipewire {
|
mod pipewire {
|
||||||
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
|
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
|
||||||
|
|
||||||
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
@@ -655,11 +667,11 @@ mod pipewire {
|
|||||||
impl CursorState {
|
impl CursorState {
|
||||||
/// A shareable overlay for the GPU encode paths (blended at encode time), or `None` when
|
/// A shareable overlay for the GPU encode paths (blended at encode time), or `None` when
|
||||||
/// there is nothing to draw. Cheap: clones an `Arc` + a few scalars.
|
/// there is nothing to draw. Cheap: clones an `Arc` + a few scalars.
|
||||||
fn overlay(&self) -> Option<crate::capture::CursorOverlay> {
|
fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||||
if !self.visible || self.rgba.is_empty() {
|
if !self.visible || self.rgba.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(crate::capture::CursorOverlay {
|
Some(pf_frame::CursorOverlay {
|
||||||
x: self.x,
|
x: self.x,
|
||||||
y: self.y,
|
y: self.y,
|
||||||
w: self.bw,
|
w: self.bw,
|
||||||
@@ -693,8 +705,8 @@ mod pipewire {
|
|||||||
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
|
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
|
||||||
import_fail_streak: u32,
|
import_fail_streak: u32,
|
||||||
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
|
||||||
/// normally via the isolated worker process (`crate::zerocopy::Importer::Remote`).
|
/// normally via the isolated worker process (`pf_zerocopy::Importer::Remote`).
|
||||||
importer: Option<crate::zerocopy::Importer>,
|
importer: Option<pf_zerocopy::Importer>,
|
||||||
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
|
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
|
||||||
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
|
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
|
||||||
/// encoder backend is VAAPI (AMD/Intel).
|
/// encoder backend is VAAPI (AMD/Intel).
|
||||||
@@ -1205,7 +1217,7 @@ mod pipewire {
|
|||||||
// closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that
|
// closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that
|
||||||
// attach no fence. Covers both the GPU import and the CPU mmap read below.
|
// attach no fence. Covers both the GPU import and the CPU mmap read below.
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
match crate::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
|
match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
|
||||||
Ok(waited) => {
|
Ok(waited) => {
|
||||||
static F1: std::sync::atomic::AtomicBool =
|
static F1: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(true);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
@@ -1237,7 +1249,7 @@ mod pipewire {
|
|||||||
if ud.vaapi_passthrough {
|
if ud.vaapi_passthrough {
|
||||||
if let Some(fmt) = ud.format {
|
if let Some(fmt) = ud.format {
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
|
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
|
||||||
let chunk = datas[0].chunk();
|
let chunk = datas[0].chunk();
|
||||||
let offset = chunk.offset();
|
let offset = chunk.offset();
|
||||||
let stride = chunk.stride().max(0) as u32;
|
let stride = chunk.stride().max(0) as u32;
|
||||||
@@ -1300,7 +1312,7 @@ mod pipewire {
|
|||||||
let mut gpu_import_broken = false;
|
let mut gpu_import_broken = false;
|
||||||
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
let plane = crate::zerocopy::DmabufPlane {
|
let plane = pf_zerocopy::DmabufPlane {
|
||||||
fd: datas[0].fd(),
|
fd: datas[0].fd(),
|
||||||
offset: datas[0].chunk().offset(),
|
offset: datas[0].chunk().offset(),
|
||||||
stride: datas[0].chunk().stride().max(0) as u32,
|
stride: datas[0].chunk().stride().max(0) as u32,
|
||||||
@@ -1309,7 +1321,7 @@ mod pipewire {
|
|||||||
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
|
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
|
||||||
// sample LINEAR).
|
// sample LINEAR).
|
||||||
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
||||||
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
|
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
|
||||||
// GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4
|
// GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4
|
||||||
// session gets the planar-YUV444 convert (full chroma, takes precedence over
|
// session gets the planar-YUV444 convert (full chroma, takes precedence over
|
||||||
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 —
|
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 —
|
||||||
@@ -1333,7 +1345,7 @@ mod pipewire {
|
|||||||
match imported {
|
match imported {
|
||||||
Ok(devbuf) => {
|
Ok(devbuf) => {
|
||||||
ud.import_fail_streak = 0;
|
ud.import_fail_streak = 0;
|
||||||
crate::zerocopy::note_gpu_import_ok();
|
pf_zerocopy::note_gpu_import_ok();
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(true);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
@@ -1371,7 +1383,7 @@ mod pipewire {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let dead = importer.dead();
|
let dead = importer.dead();
|
||||||
if dead {
|
if dead {
|
||||||
crate::zerocopy::note_gpu_import_death();
|
pf_zerocopy::note_gpu_import_death();
|
||||||
}
|
}
|
||||||
if modifier.is_some() {
|
if modifier.is_some() {
|
||||||
// Tiled buffer: the CPU fallback below would mmap TILED bytes
|
// Tiled buffer: the CPU fallback below would mmap TILED bytes
|
||||||
@@ -1549,6 +1561,9 @@ mod pipewire {
|
|||||||
want_444: bool,
|
want_444: bool,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
quit_rx: pw::channel::Receiver<()>,
|
quit_rx: pw::channel::Receiver<()>,
|
||||||
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
||||||
|
// capture→encode edge (plan §W6).
|
||||||
|
policy: ZeroCopyPolicy,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::pwinit::ensure_init();
|
crate::pwinit::ensure_init();
|
||||||
|
|
||||||
@@ -1581,15 +1596,15 @@ mod pipewire {
|
|||||||
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
// 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
|
// 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).
|
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
let backend_is_vaapi = policy.backend_is_vaapi;
|
||||||
let mut importer = if zerocopy && !backend_is_vaapi {
|
let mut importer = if zerocopy && !backend_is_vaapi {
|
||||||
if crate::zerocopy::gpu_import_disabled() {
|
if pf_zerocopy::gpu_import_disabled() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||||
);
|
);
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match crate::zerocopy::Importer::new_for_capture() {
|
match pf_zerocopy::Importer::new_for_capture() {
|
||||||
Ok(i) => Some(i),
|
Ok(i) => Some(i),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
||||||
@@ -1617,19 +1632,19 @@ mod pipewire {
|
|||||||
// radeonsi/iHD import it and any compositor can allocate it.
|
// radeonsi/iHD import it and any compositor can allocate it.
|
||||||
let mut modifiers = importer
|
let mut modifiers = importer
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||||
}
|
}
|
||||||
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
||||||
// advertisement with every modifier its device samples from, so compositors that
|
// advertisement with every modifier its device samples from, so compositors that
|
||||||
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs.
|
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
||||||
#[cfg(feature = "pyrowave")]
|
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
|
||||||
if vaapi_passthrough && crate::config::config().encoder_pref.as_str() == "pyrowave" {
|
// the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never
|
||||||
for m in crate::encode::pyrowave_capture_modifiers(
|
// calls back into `encode` and needs no feature gate of its own (the emptiness check gates it).
|
||||||
crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap(),
|
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||||
) {
|
for &m in &policy.pyrowave_modifiers {
|
||||||
if !modifiers.contains(&m) {
|
if !modifiers.contains(&m) {
|
||||||
modifiers.push(m);
|
modifiers.push(m);
|
||||||
}
|
}
|
||||||
@@ -1657,7 +1672,7 @@ mod pipewire {
|
|||||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||||
);
|
);
|
||||||
} else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() {
|
} else if backend_is_vaapi && policy.backend_is_gpu {
|
||||||
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
|
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
|
||||||
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
|
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1676,7 +1691,7 @@ mod pipewire {
|
|||||||
"4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \
|
"4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \
|
||||||
NVENC fed native full-chroma YUV, no CPU pixel path"
|
NVENC fed native full-chroma YUV, no CPU pixel path"
|
||||||
);
|
);
|
||||||
} else if want_dmabuf && !vaapi_passthrough && crate::zerocopy::nv12_enabled() {
|
} else if want_dmabuf && !vaapi_passthrough && pf_zerocopy::nv12_enabled() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \
|
"PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \
|
||||||
fed native YUV (no internal RGB→YUV CSC)"
|
fed native YUV (no internal RGB→YUV CSC)"
|
||||||
@@ -1695,7 +1710,7 @@ mod pipewire {
|
|||||||
import_fail_streak: 0,
|
import_fail_streak: 0,
|
||||||
importer,
|
importer,
|
||||||
vaapi_passthrough,
|
vaapi_passthrough,
|
||||||
nv12: crate::zerocopy::nv12_enabled(),
|
nv12: pf_zerocopy::nv12_enabled(),
|
||||||
yuv444: want_444,
|
yuv444: want_444,
|
||||||
dbg_log_n: 0,
|
dbg_log_n: 0,
|
||||||
cursor: CursorState::default(),
|
cursor: CursorState::default(),
|
||||||
+15
-207
@@ -1,20 +1,27 @@
|
|||||||
//! Shared Windows GPU primitives — D3D11 device creation, GPU scheduling priority hooks,
|
//! Windows capture GPU mechanics — the win32u GPU-preference hook, HLSL shader compilation, HDR
|
||||||
//! HLSL shader compilation, HDR FP16→P010 conversion ([`HdrP010Converter`]), video-engine
|
//! FP16→P010 conversion ([`HdrP010Converter`]), video-engine colour conversion ([`VideoConverter`]),
|
||||||
//! colour conversion ([`VideoConverter`]), and the IDD-push capture identity
|
//! and the P010 self-test. Consumed by [`super::idd_push`].
|
||||||
//! ([`WinCaptureTarget`], [`pack_luid`]). Consumed by [`super::idd_push`].
|
//!
|
||||||
//! DXGI Desktop Duplication has been removed; this module contains no capturer.
|
//! The shared IDD-push capture IDENTITY — [`WinCaptureTarget`], [`D3d11Frame`], [`pack_luid`], and
|
||||||
|
//! [`make_device`] (the D3D11 device factory + GPU scheduling-priority hardening) — moved into the
|
||||||
|
//! `pf-frame` leaf crate so capture, encode, and pf-vdisplay share one identity type without a
|
||||||
|
//! capture↔encode↔vdisplay cycle (plan §W6); this module re-exports it so every existing
|
||||||
|
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
|
||||||
|
//! module contains no capturer.
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, WinCaptureTarget};
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use windows::core::{s, Interface, PCSTR};
|
use windows::core::{s, Interface, PCSTR};
|
||||||
use windows::Win32::Foundation::{HMODULE, LUID};
|
use windows::Win32::Foundation::HMODULE;
|
||||||
use windows::Win32::Graphics::Direct3D::Fxc::D3DCompile;
|
use windows::Win32::Graphics::Direct3D::Fxc::D3DCompile;
|
||||||
use windows::Win32::Graphics::Direct3D::{
|
use windows::Win32::Graphics::Direct3D::{
|
||||||
ID3DBlob, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST,
|
ID3DBlob, D3D_FEATURE_LEVEL_11_0, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
D3D11CreateDevice, ID3D11Buffer, ID3D11Device, ID3D11DeviceContext, ID3D11PixelShader,
|
D3D11CreateDevice, ID3D11Buffer, ID3D11Device, ID3D11DeviceContext, ID3D11PixelShader,
|
||||||
@@ -32,205 +39,6 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
|||||||
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
||||||
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{IDXGIAdapter1, IDXGIDevice, IDXGIDevice1};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WinCaptureTarget {
|
|
||||||
/// Packed DXGI adapter LUID (`(HighPart << 32) | (LowPart & 0xffff_ffff)`).
|
|
||||||
pub adapter_luid: i64,
|
|
||||||
/// The output's GDI device name, e.g. `\\.\DISPLAY3`. Can CHANGE across a secure-desktop switch.
|
|
||||||
pub gdi_name: String,
|
|
||||||
/// Stable virtual-display (IddCx) target id — re-resolved to the current GDI name on every recovery.
|
|
||||||
pub target_id: u32,
|
|
||||||
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
|
|
||||||
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
|
|
||||||
/// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse).
|
|
||||||
pub wudf_pid: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
|
||||||
pub struct D3d11Frame {
|
|
||||||
pub texture: ID3D11Texture2D,
|
|
||||||
pub device: ID3D11Device,
|
|
||||||
}
|
|
||||||
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
|
||||||
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
|
||||||
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
|
|
||||||
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
|
|
||||||
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
|
|
||||||
// single-threaded immediate context.
|
|
||||||
unsafe impl Send for D3d11Frame {}
|
|
||||||
|
|
||||||
pub fn pack_luid(luid: LUID) -> i64 {
|
|
||||||
((luid.HighPart as i64) << 32) | (luid.LowPart as i64 & 0xffff_ffff)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a fresh D3D11 device + context on a specific adapter (driver_type UNKNOWN with an explicit
|
|
||||||
/// adapter). Used at open and on every ACCESS_LOST: a device created on one desktop cannot sustain a
|
|
||||||
/// duplication on a *different* desktop (perpetual ACCESS_LOST), so the secure-desktop switch needs a
|
|
||||||
/// device made while the thread is attached to that desktop.
|
|
||||||
pub(crate) unsafe fn make_device(
|
|
||||||
adapter: &IDXGIAdapter1,
|
|
||||||
) -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
|
||||||
let mut device: Option<ID3D11Device> = None;
|
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
|
||||||
D3D11CreateDevice(
|
|
||||||
adapter,
|
|
||||||
D3D_DRIVER_TYPE_UNKNOWN,
|
|
||||||
HMODULE::default(),
|
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
|
||||||
D3D11_SDK_VERSION,
|
|
||||||
Some(&mut device),
|
|
||||||
None,
|
|
||||||
Some(&mut context),
|
|
||||||
)
|
|
||||||
.context("D3D11CreateDevice")?;
|
|
||||||
let device = device.context("null D3D11 device")?;
|
|
||||||
let context = context.context("null D3D11 context")?;
|
|
||||||
|
|
||||||
// GPU scheduling hardening — the same approach Sunshine/Apollo use, reimplemented here via the
|
|
||||||
// documented D3DKMT/DXGI APIs (no GPL source copied). Our capture+encode
|
|
||||||
// shares the GPU with the streamed game; when the game saturates the GPU our process is starved of
|
|
||||||
// GPU time slices, so NVENC sits near-idle yet `lock_bitstream` waits ~20 ms for our context to be
|
|
||||||
// scheduled — capping the stream (~47 fps measured at 5K@240) and stuttering. Per-frame copy/convert
|
|
||||||
// is NOT the cause (zero-copy + thread-priority alone didn't move it); the PROCESS-level GPU
|
|
||||||
// scheduling priority class is the decisive cross-process lever. Secondary: the absolute per-device
|
|
||||||
// GPU thread priority and a 1-frame latency cap.
|
|
||||||
elevate_process_gpu_priority();
|
|
||||||
if let Ok(dxgi_dev) = device.cast::<IDXGIDevice>() {
|
|
||||||
// The absolute max GPU thread priority (0x4000001E; the same value Sunshine/Apollo use); fall back to relative +7.
|
|
||||||
if dxgi_dev.SetGPUThreadPriority(0x4000_001E).is_err()
|
|
||||||
&& dxgi_dev.SetGPUThreadPriority(7).is_err()
|
|
||||||
{
|
|
||||||
tracing::warn!("SetGPUThreadPriority failed (run as admin/SYSTEM for GPU priority)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(dxgi1) = device.cast::<IDXGIDevice1>() {
|
|
||||||
let _ = dxgi1.SetMaximumFrameLatency(1);
|
|
||||||
}
|
|
||||||
Ok((device, context))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the configured GPU scheduling-priority class from `PUNKTFUNK_GPU_PRIORITY_CLASS`
|
|
||||||
/// (`off|normal|high|realtime`, default high). `None` = leave it at the OS default (the `off` opt-out).
|
|
||||||
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, REALTIME 5.
|
|
||||||
fn configured_gpu_priority_class() -> Option<i32> {
|
|
||||||
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
|
||||||
.ok()
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("off") => None,
|
|
||||||
Some("normal") => Some(2),
|
|
||||||
Some("realtime") => Some(5),
|
|
||||||
_ => Some(4), // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable SE_INC_BASE_PRIORITY on the CURRENT process token (best-effort) — the kernel gates the
|
|
||||||
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
|
|
||||||
/// token does NOT have it, which is why `elevate_process_gpu_priority` may silently no-op in a
|
|
||||||
/// restricted service context.
|
|
||||||
unsafe fn enable_inc_base_priority() {
|
|
||||||
use windows::core::PCWSTR;
|
|
||||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
|
||||||
use windows::Win32::Security::{
|
|
||||||
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES,
|
|
||||||
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES,
|
|
||||||
TOKEN_QUERY,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
|
||||||
let mut token = HANDLE::default();
|
|
||||||
if OpenProcessToken(
|
|
||||||
GetCurrentProcess(),
|
|
||||||
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
|
|
||||||
&mut token,
|
|
||||||
)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
let mut luid = LUID::default();
|
|
||||||
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
|
|
||||||
let tp = TOKEN_PRIVILEGES {
|
|
||||||
PrivilegeCount: 1,
|
|
||||||
Privileges: [LUID_AND_ATTRIBUTES {
|
|
||||||
Luid: luid,
|
|
||||||
Attributes: SE_PRIVILEGE_ENABLED,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
if AdjustTokenPrivileges(
|
|
||||||
token,
|
|
||||||
false,
|
|
||||||
Some(&tp as *const TOKEN_PRIVILEGES),
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = CloseHandle(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call `gdi32!D3DKMTSetProcessSchedulingPriorityClass(process, prio)` (no stable windows-rs binding —
|
|
||||||
/// loaded by name). Returns the NTSTATUS (0 = success) or `None` if the export can't be resolved. The
|
|
||||||
/// CALLING process must hold SE_INC_BASE_PRIORITY ([`enable_inc_base_priority`]) for HIGH/REALTIME; the
|
|
||||||
/// kernel checks the caller's privilege whether the target is self or a child we created.
|
|
||||||
unsafe fn d3dkmt_set_scheduling_priority_class(
|
|
||||||
process: windows::Win32::Foundation::HANDLE,
|
|
||||||
prio: i32,
|
|
||||||
) -> Option<i32> {
|
|
||||||
use windows::core::s;
|
|
||||||
use windows::Win32::Foundation::HANDLE;
|
|
||||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
|
||||||
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
|
|
||||||
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
|
|
||||||
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
|
|
||||||
let f: SetPrio = std::mem::transmute(p);
|
|
||||||
Some(f(process, prio))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GPU scheduling-priority hardening — the same approach as Sunshine/Apollo, independently
|
|
||||||
/// implemented via the documented D3DKMT APIs (no GPL source copied). On a
|
|
||||||
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
|
||||||
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
|
||||||
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
|
||||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT
|
|
||||||
/// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this).
|
|
||||||
/// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime`
|
|
||||||
/// (default high). Best-effort: silently no-ops under a UAC-filtered token (the process will not
|
|
||||||
/// hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a no-op).
|
|
||||||
fn elevate_process_gpu_priority() {
|
|
||||||
use std::sync::Once;
|
|
||||||
static ONCE: Once = Once::new();
|
|
||||||
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
|
|
||||||
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
|
|
||||||
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
|
|
||||||
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
|
|
||||||
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
|
|
||||||
// `Once::call_once`; no raw pointers are dereferenced here.
|
|
||||||
ONCE.call_once(|| unsafe {
|
|
||||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
|
||||||
let Some(prio) = configured_gpu_priority_class() else {
|
|
||||||
tracing::info!("GPU process scheduling priority class left at default (off)");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
enable_inc_base_priority();
|
|
||||||
match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
|
|
||||||
Some(0) => tracing::info!(
|
|
||||||
priority_class = prio,
|
|
||||||
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
|
|
||||||
),
|
|
||||||
Some(st) => tracing::warn!(
|
|
||||||
status = format!("0x{st:08X}"),
|
|
||||||
"D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
|
|
||||||
),
|
|
||||||
None => tracing::warn!("D3DKMTSetProcessSchedulingPriorityClass export not found"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this
|
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this
|
||||||
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this
|
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this
|
||||||
@@ -269,7 +77,7 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
|||||||
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented
|
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented
|
||||||
/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware).
|
/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware).
|
||||||
/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo).
|
/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo).
|
||||||
pub(crate) fn install_gpu_pref_hook() {
|
pub fn install_gpu_pref_hook() {
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
static HOOK: Once = Once::new();
|
static HOOK: Once = Once::new();
|
||||||
// SAFETY: this one-time hook install only touches a region it has just validated.
|
// SAFETY: this one-time hook install only touches a region it has just validated.
|
||||||
+177
-28
@@ -65,8 +65,8 @@ use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos};
|
|||||||
// `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
|
// `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
|
||||||
// `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
|
// `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
|
||||||
use frame::{
|
use frame::{
|
||||||
SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
|
unpack_opened_detail, SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NONE,
|
||||||
DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION,
|
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
|
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
|
||||||
@@ -219,6 +219,14 @@ impl Drop for KeyedMutexGuard<'_> {
|
|||||||
/// the cursor layer of the display it lands on, so the target composes at least one frame; the
|
/// the cursor layer of the display it lands on, so the target composes at least one frame; the
|
||||||
/// round trip is sub-millisecond and throttled). Best-effort — injection can be unavailable on the
|
/// round trip is sub-millisecond and throttled). Best-effort — injection can be unavailable on the
|
||||||
/// secure desktop, where a fresh compose just happened anyway.
|
/// secure desktop, where a fresh compose just happened anyway.
|
||||||
|
///
|
||||||
|
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
|
||||||
|
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
|
||||||
|
/// HID device is real input to win32k — delivered regardless of this process's session or the
|
||||||
|
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
|
||||||
|
/// modern standby) and counts as user presence — every condition under which `SendInput` is
|
||||||
|
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
|
||||||
|
/// nothing composes at all). That set is exactly the lid-closed field-report state.
|
||||||
fn kick_dwm_compose(target_id: u32) {
|
fn kick_dwm_compose(target_id: u32) {
|
||||||
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
||||||
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
||||||
@@ -240,7 +248,21 @@ fn kick_dwm_compose(target_id: u32) {
|
|||||||
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
||||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||||
// buffers; the `Copy` target id crosses by value.
|
// buffers; the `Copy` target id crosses by value.
|
||||||
let rect = unsafe { crate::win_display::source_desktop_rect(target_id) };
|
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||||
|
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
|
||||||
|
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
|
||||||
|
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
|
||||||
|
// through to SendInput only when the hook isn't registered / the mouse isn't up.
|
||||||
|
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
|
||||||
|
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||||
|
// buffers.
|
||||||
|
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
|
||||||
|
if let Some(bounds) = bounds {
|
||||||
|
if kick(rect, bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
||||||
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
||||||
if !inside {
|
if !inside {
|
||||||
@@ -295,7 +317,7 @@ fn kick_dwm_compose(target_id: u32) {
|
|||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
|
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
|
||||||
pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
|
pub unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
|
||||||
let mut buf = [0u16; 512];
|
let mut buf = [0u16; 512];
|
||||||
let mut len = buf.len() as u32;
|
let mut len = buf.len() as u32;
|
||||||
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
|
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
|
||||||
@@ -395,7 +417,7 @@ pub struct IddPushCapturer {
|
|||||||
/// periodic-stutter diagnostic.
|
/// periodic-stutter diagnostic.
|
||||||
stall_watch: StallWatch,
|
stall_watch: StallWatch,
|
||||||
/// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session,
|
/// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session,
|
||||||
/// and how many had a coinciding [`crate::display_events`] event in their gap window — the
|
/// and how many had a coinciding a `pf_win_display::display_events` event in their gap window — the
|
||||||
/// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the
|
/// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the
|
||||||
/// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver
|
/// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver
|
||||||
/// servicing a standby sink / display-poller software).
|
/// servicing a standby sink / display-poller software).
|
||||||
@@ -420,6 +442,13 @@ pub struct IddPushCapturer {
|
|||||||
last_seq: u64,
|
last_seq: u64,
|
||||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||||
status_logged: bool,
|
status_logged: bool,
|
||||||
|
/// Session-lifetime `PowerRequestDisplayRequired` (RAII, `powercfg /requests`-visible): keeps
|
||||||
|
/// the console out of display-off while this capturer lives — DWM composes nothing (for ANY
|
||||||
|
/// display) once the console's displays power down, so without this a lid-closed/idle box can
|
||||||
|
/// go dark mid-stream and the ring runs dry. Prevention only; waking an ALREADY-off display is
|
||||||
|
/// the HID compose kick's job ([`crate::HID_COMPOSE_KICK`]). `None` when the kernel refused
|
||||||
|
/// (best-effort, the pre-existing behavior).
|
||||||
|
_display_wake: Option<pf_frame::session_tuning::DisplayWakeRequest>,
|
||||||
_keepalive: Box<dyn Send>,
|
_keepalive: Box<dyn Send>,
|
||||||
}
|
}
|
||||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||||
@@ -533,11 +562,12 @@ impl IddPushCapturer {
|
|||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||||
crate::display_events::spawn_once();
|
pf_win_display::display_events::spawn_once();
|
||||||
match Self::open_inner(target, preferred, client_10bit, want_444) {
|
match Self::open_inner(target, preferred, client_10bit, want_444, sender) {
|
||||||
Ok(mut me) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
Ok(me)
|
Ok(me)
|
||||||
@@ -551,6 +581,7 @@ impl IddPushCapturer {
|
|||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||||
@@ -561,11 +592,18 @@ impl IddPushCapturer {
|
|||||||
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||||
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||||
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||||
let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID {
|
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||||
HighPart: (target.adapter_luid >> 32) as i32,
|
HighPart: (target.adapter_luid >> 32) as i32,
|
||||||
});
|
});
|
||||||
match Self::open_on(target.clone(), preferred, client_10bit, want_444, luid) {
|
match Self::open_on(
|
||||||
|
target.clone(),
|
||||||
|
preferred,
|
||||||
|
client_10bit,
|
||||||
|
want_444,
|
||||||
|
luid,
|
||||||
|
sender.clone(),
|
||||||
|
) {
|
||||||
Ok(me) => Ok(me),
|
Ok(me) => Ok(me),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||||
@@ -576,7 +614,7 @@ impl IddPushCapturer {
|
|||||||
let driver_luid = e
|
let driver_luid = e
|
||||||
.downcast_ref::<AttachTexFail>()
|
.downcast_ref::<AttachTexFail>()
|
||||||
.map(|tf| tf.driver_luid)
|
.map(|tf| tf.driver_luid)
|
||||||
.filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid));
|
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
|
||||||
let Some(packed) = driver_luid else {
|
let Some(packed) = driver_luid else {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
};
|
};
|
||||||
@@ -590,7 +628,7 @@ impl IddPushCapturer {
|
|||||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||||
driver's reported adapter"
|
driver's reported adapter"
|
||||||
);
|
);
|
||||||
Self::open_on(target, preferred, client_10bit, want_444, drv)
|
Self::open_on(target, preferred, client_10bit, want_444, drv, sender)
|
||||||
.context("IDD-push rebind to the driver's reported render adapter")
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,6 +640,7 @@ impl IddPushCapturer {
|
|||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (pw, ph, _hz) = preferred
|
let (pw, ph, _hz) = preferred
|
||||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||||
@@ -612,8 +651,8 @@ impl IddPushCapturer {
|
|||||||
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||||
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||||
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||||
let (w, h) =
|
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
|
||||||
unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph));
|
.unwrap_or((pw, ph));
|
||||||
if (w, h) != (pw, ph) {
|
if (w, h) != (pw, ph) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = target.target_id,
|
target_id = target.target_id,
|
||||||
@@ -656,16 +695,35 @@ impl IddPushCapturer {
|
|||||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||||
let enabled_hdr =
|
let enabled_hdr = client_10bit
|
||||||
client_10bit && crate::win_display::set_advanced_color(target.target_id, true);
|
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||||
if enabled_hdr {
|
if enabled_hdr {
|
||||||
// Let the colorspace change settle before the driver composes + we size the ring.
|
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||||
|
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||||
|
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||||
|
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||||
|
// stash/format-guard machinery absorbs).
|
||||||
|
let hdr_settle = Instant::now();
|
||||||
|
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||||
|
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||||
|
"IDD push: advanced-color (HDR) enable settle"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||||
let display_hdr = enabled_hdr
|
let display_hdr = enabled_hdr
|
||||||
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
|
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
|
.unwrap_or(false);
|
||||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||||
@@ -757,7 +815,7 @@ impl IddPushCapturer {
|
|||||||
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||||
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||||
// the delivery the driver can never attach.
|
// the delivery the driver can never attach.
|
||||||
let broker = ChannelBroker::open(target.wudf_pid)?;
|
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
|
||||||
broker
|
broker
|
||||||
.send(
|
.send(
|
||||||
target.target_id,
|
target.target_id,
|
||||||
@@ -819,6 +877,9 @@ impl IddPushCapturer {
|
|||||||
last_seq: 0,
|
last_seq: 0,
|
||||||
last_present: None,
|
last_present: None,
|
||||||
status_logged: false,
|
status_logged: false,
|
||||||
|
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||||
|
// wait for the first compose) until the capturer drops with the session.
|
||||||
|
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||||
// it back to the caller for the DDA fallback (audit §5.1).
|
// it back to the caller for the DDA fallback (audit §5.1).
|
||||||
_keepalive: Box::new(()),
|
_keepalive: Box::new(()),
|
||||||
@@ -935,12 +996,67 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
if Instant::now() > deadline {
|
if Instant::now() > deadline {
|
||||||
bail!(
|
bail!(
|
||||||
"IDD-push: driver_status={st} but no frame published within 4s (despite compose \
|
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
|
||||||
kicks) — the virtual display is likely in a format/size the ring can't match \
|
falling back",
|
||||||
(fullscreen game?); falling back"
|
self.no_first_frame_diagnosis(st)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||||
|
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||||
|
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||||
|
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||||
|
// event, for truth.
|
||||||
|
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||||
|
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||||
|
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
|
||||||
|
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
|
||||||
|
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
|
||||||
|
/// field report burned days for lack of exactly this line. Appends a console-session hint when
|
||||||
|
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
|
||||||
|
fn no_first_frame_diagnosis(&self, st: u32) -> String {
|
||||||
|
let what = match st {
|
||||||
|
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
|
||||||
|
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
|
||||||
|
consumed, so the OS ran no swap-chain worker for this monitor (display not \
|
||||||
|
composed at all: console display-off / modern standby, or the mode commit \
|
||||||
|
never reached the adapter)"
|
||||||
|
.to_string(),
|
||||||
|
DRV_STATUS_OPENED => {
|
||||||
|
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
|
||||||
|
// (same best-effort diagnostic access as the `driver_status` read in the caller);
|
||||||
|
// no reference into the shared region is formed.
|
||||||
|
let detail = unsafe { (*self.header).driver_status_detail };
|
||||||
|
match unpack_opened_detail(detail) {
|
||||||
|
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
|
||||||
|
ZERO frames — an undamaged or powered-off desktop, and the compose \
|
||||||
|
kicks didn't bite (synthetic input is blocked on the secure desktop)"
|
||||||
|
.to_string(),
|
||||||
|
Some((offered, mismatched)) => format!(
|
||||||
|
"driver attached and DWM composed {offered} frame(s), but none matched \
|
||||||
|
the ring — {mismatched} dropped for a size/format mismatch (the \
|
||||||
|
display's actual mode differs from what the host sized the ring to: \
|
||||||
|
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
|
||||||
|
),
|
||||||
|
// A pre-detail driver never stamps the live bit — say so rather than guess.
|
||||||
|
None => "driver attached but published nothing; this pf-vdisplay build \
|
||||||
|
predates attach diagnostics, so the cause can't be named — update the \
|
||||||
|
driver for a precise line here"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => format!("driver_status={other} (unexpected at this point)"),
|
||||||
|
};
|
||||||
|
match pf_win_display::console_session_mismatch() {
|
||||||
|
Some((own, console)) => format!(
|
||||||
|
"{what} [host is in session {own} but the console is session {console} — display \
|
||||||
|
writes and input kicks cannot work from a non-console session; reconnect the \
|
||||||
|
console or run via the installed service]"
|
||||||
|
),
|
||||||
|
None => what,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1340,7 +1456,7 @@ impl IddPushCapturer {
|
|||||||
let window = stall.gap + Duration::from_millis(300);
|
let window = stall.gap + Duration::from_millis(300);
|
||||||
let events = now
|
let events = now
|
||||||
.checked_sub(window)
|
.checked_sub(window)
|
||||||
.map(|from| crate::display_events::events_between(from, now))
|
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.stalls_seen = self.stalls_seen.saturating_add(1);
|
self.stalls_seen = self.stalls_seen.saturating_add(1);
|
||||||
if !events.is_empty() {
|
if !events.is_empty() {
|
||||||
@@ -1351,12 +1467,12 @@ impl IddPushCapturer {
|
|||||||
// at debug level, and the web-console debug ring captures these.
|
// at debug level, and the web-console debug ring captures these.
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
gap_ms = stall.gap.as_millis() as u64,
|
gap_ms = stall.gap.as_millis() as u64,
|
||||||
os_display_events = %crate::display_events::summarize(&events),
|
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
delivered no frame for the gap; the present path stalled below capture"
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
);
|
);
|
||||||
if let Some(period) = stall.metronomic {
|
if let Some(period) = stall.metronomic {
|
||||||
let suspects = crate::display_events::connected_inactive_externals();
|
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||||
let suspects = if suspects.is_empty() {
|
let suspects = if suspects.is_empty() {
|
||||||
"none".to_string()
|
"none".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -1514,7 +1630,7 @@ impl Capturer for IddPushCapturer {
|
|||||||
// PQ VUI; pair that with a mastering-display SEI so any decoder tone-maps from a real grade. The
|
// PQ VUI; pair that with a mastering-display SEI so any decoder tone-maps from a real grade. The
|
||||||
// driver doesn't (yet) forward the OS's IDDCX_HDR10_METADATA, so use the generic HDR10 baseline
|
// driver doesn't (yet) forward the OS's IDDCX_HDR10_METADATA, so use the generic HDR10 baseline
|
||||||
// (the same metadata the native HDR path sends on the 0xCE datagram).
|
// (the same metadata the native HDR path sends on the 0xCE datagram).
|
||||||
self.display_hdr.then(crate::hdr::generic_hdr10)
|
self.display_hdr.then(pf_frame::hdr::generic_hdr10)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pipeline_depth(&self) -> usize {
|
fn pipeline_depth(&self) -> usize {
|
||||||
@@ -1522,7 +1638,40 @@ impl Capturer for IddPushCapturer {
|
|||||||
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
|
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
|
||||||
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
|
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
|
||||||
// always has its own texture).
|
// always has its own texture).
|
||||||
crate::config::config().idd_depth.clamp(1, OUT_RING)
|
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
Some(self.target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_output(&mut self, width: u32, height: u32) -> bool {
|
||||||
|
// Host-initiated resize (latency plan P2.3): the session's resize handler has already
|
||||||
|
// committed the display's new mode (the manager's in-place mode set), so recreate the ring
|
||||||
|
// at the new size NOW — no DescriptorPoller two-strike debounce (that stays, unchanged,
|
||||||
|
// for EXTERNAL changes: HDR flips, game mode-sets). The driver re-attaches to the fresh
|
||||||
|
// ring and republishes; on an in-place mode set the OS's mode-set full redraw gives the
|
||||||
|
// stash/first frame within the recover window. Same recover-or-drop arming as the
|
||||||
|
// poller-driven recreate, so a ring that can't re-attach still fails the session cleanly
|
||||||
|
// instead of freezing.
|
||||||
|
if (width, height) == (self.width, self.height) {
|
||||||
|
return true; // already at the requested size (refresh-only change) — nothing to do
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target_id = self.target_id,
|
||||||
|
from = format!("{}x{}", self.width, self.height),
|
||||||
|
to = format!("{width}x{height}"),
|
||||||
|
"IDD push: host-initiated resize — recreating the ring at the new mode"
|
||||||
|
);
|
||||||
|
self.recovering_since.get_or_insert_with(Instant::now);
|
||||||
|
if let Err(e) = self.recreate_ring(self.display_hdr, width, height) {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"IDD push: host-initiated ring recreate failed — falling back to a full rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+11
-11
@@ -20,9 +20,11 @@ pub(super) struct ChannelBroker {
|
|||||||
process: OwnedHandle,
|
process: OwnedHandle,
|
||||||
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
|
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
|
||||||
pub(super) wudf_pid: u32,
|
pub(super) wudf_pid: u32,
|
||||||
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
|
/// Delivers a filled `SetFrameChannelRequest` to the pf-vdisplay driver
|
||||||
/// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound.
|
/// (`IOCTL_SET_FRAME_CHANNEL`). The host facade builds this from the vdisplay control device +
|
||||||
control: HANDLE,
|
/// `send_frame_channel` IOCTL wrapper, so this crate delivers the channel without reaching into
|
||||||
|
/// the orchestrator's `vdisplay` module (plan §W6). Called once per generation, never per-frame.
|
||||||
|
sender: crate::FrameChannelSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelBroker {
|
impl ChannelBroker {
|
||||||
@@ -35,13 +37,10 @@ impl ChannelBroker {
|
|||||||
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
|
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
|
||||||
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
|
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
|
||||||
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
|
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
|
||||||
pub(super) fn open(wudf_pid: u32) -> Result<Self> {
|
pub(super) fn open(wudf_pid: u32, sender: crate::FrameChannelSender) -> Result<Self> {
|
||||||
if wudf_pid == 0 {
|
if wudf_pid == 0 {
|
||||||
bail!("driver reported no WUDFHost pid for the frame channel");
|
bail!("driver reported no WUDFHost pid for the frame channel");
|
||||||
}
|
}
|
||||||
let control = crate::vdisplay::manager::control_device_handle().context(
|
|
||||||
"pf-vdisplay control device not open (monitor not created via the manager?)",
|
|
||||||
)?;
|
|
||||||
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
|
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
|
||||||
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
|
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
|
||||||
// for the duration of the synchronous check and forms no lasting alias.
|
// for the duration of the synchronous check and forms no lasting alias.
|
||||||
@@ -59,7 +58,7 @@ impl ChannelBroker {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
process,
|
process,
|
||||||
wudf_pid,
|
wudf_pid,
|
||||||
control,
|
sender,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +181,9 @@ impl ChannelBroker {
|
|||||||
slots: &[HostSlot],
|
slots: &[HostSlot],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
|
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
|
||||||
// handles of this process, and `self.control` is the manager's control handle, never closed for
|
// handles of this process. The `sender` closure encapsulates the manager's control handle +
|
||||||
// the process lifetime (`send_frame_channel`'s precondition).
|
// the `send_frame_channel` IOCTL (its precondition — a live control handle — is upheld by the
|
||||||
|
// host facade that built it).
|
||||||
unsafe {
|
unsafe {
|
||||||
// Least privilege per handle: the header maps read/write, the event is only signalled, and
|
// Least privilege per handle: the header maps read/write, the event is only signalled, and
|
||||||
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
|
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
|
||||||
@@ -192,7 +192,7 @@ impl ChannelBroker {
|
|||||||
for (k, s) in slots.iter().enumerate() {
|
for (k, s) in slots.iter().enumerate() {
|
||||||
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
|
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
|
||||||
}
|
}
|
||||||
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
|
(self.sender)(req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -63,8 +63,8 @@ impl DescriptorPoller {
|
|||||||
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
|
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
|
||||||
let (hdr, res) = unsafe {
|
let (hdr, res) = unsafe {
|
||||||
(
|
(
|
||||||
crate::win_display::advanced_color_enabled(target_id),
|
pf_win_display::win_display::advanced_color_enabled(target_id),
|
||||||
crate::win_display::active_resolution(target_id),
|
pf_win_display::win_display::active_resolution(target_id),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let took = t.elapsed();
|
let took = t.elapsed();
|
||||||
+4
-4
@@ -12,7 +12,7 @@ pub(super) struct Stall {
|
|||||||
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||||||
pub(super) gap: Duration,
|
pub(super) gap: Duration,
|
||||||
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||||||
/// [`crate::metronome::Metronome`]).
|
/// [`pf_frame::metronome::Metronome`]).
|
||||||
pub(super) metronomic: Option<Duration>,
|
pub(super) metronomic: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,14 +23,14 @@ pub(super) struct Stall {
|
|||||||
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
||||||
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
||||||
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
||||||
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
|
/// mouse twitch. Each stall feeds a [`pf_frame::metronome::Metronome`], so periodic stalls self-diagnose
|
||||||
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
||||||
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
||||||
/// below; the caller does the logging.
|
/// below; the caller does the logging.
|
||||||
pub(super) struct StallWatch {
|
pub(super) struct StallWatch {
|
||||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||||
recent: std::collections::VecDeque<Instant>,
|
recent: std::collections::VecDeque<Instant>,
|
||||||
cadence: crate::metronome::Metronome,
|
cadence: pf_frame::metronome::Metronome,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StallWatch {
|
impl StallWatch {
|
||||||
@@ -47,7 +47,7 @@ impl StallWatch {
|
|||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||||
cadence: crate::metronome::Metronome::new(),
|
cadence: pf_frame::metronome::Metronome::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+4
-4
@@ -2,15 +2,15 @@
|
|||||||
//! without a real capture session.
|
//! without a real capture session.
|
||||||
//!
|
//!
|
||||||
//! The native AMF path (and the D3D11 zero-copy NVENC/QSV paths) require an NV12 texture that lives
|
//! The native AMF path (and the D3D11 zero-copy NVENC/QSV paths) require an NV12 texture that lives
|
||||||
//! on the GPU — the CPU-Bgrx [`SyntheticCapturer`](crate::capture::SyntheticCapturer) can't provide
|
//! on the GPU — the CPU-Bgrx [`SyntheticCapturer`](crate::SyntheticCapturer) can't provide
|
||||||
//! one, and DXGI Desktop Duplication can't create one under an ssh session-0 (E_ACCESSDENIED). This
|
//! one, and DXGI Desktop Duplication can't create one under an ssh session-0 (E_ACCESSDENIED). This
|
||||||
//! source builds an NV12 texture on the selected render adapter and fills it with a **moving** luma
|
//! source builds an NV12 texture on the selected render adapter and fills it with a **moving** luma
|
||||||
//! ramp each frame, so the encoder sees genuine motion (P-frame residuals + the intra-refresh wave
|
//! ramp each frame, so the encoder sees genuine motion (P-frame residuals + the intra-refresh wave
|
||||||
//! under content change) — exactly what an intra-refresh recovery validation needs. Driven by
|
//! under content change) — exactly what an intra-refresh recovery validation needs. Driven by
|
||||||
//! `spike --source synthetic-nv12`.
|
//! `spike --source synthetic-nv12`.
|
||||||
|
|
||||||
use crate::capture::dxgi::{make_device, D3d11Frame};
|
use crate::dxgi::{make_device, D3d11Frame};
|
||||||
use crate::capture::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
use crate::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE,
|
ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE,
|
||||||
@@ -140,7 +140,7 @@ impl Capturer for SyntheticNv12Capturer {
|
|||||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||||
if let Some(luid) = crate::win_adapter::resolve_render_adapter_luid() {
|
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||||
return Ok(a);
|
return Ok(a);
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
//!
|
//!
|
||||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::{ActuatorQuirks, NativeClient};
|
||||||
use punktfunk_core::config::GamepadPref;
|
use punktfunk_core::config::GamepadPref;
|
||||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
@@ -61,24 +61,14 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
|||||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
|
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
|
||||||
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
|
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
|
||||||
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
|
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
|
||||||
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
|
/// to a no-op device write — so a steady level is felt as a periodic pulse without sub-decay
|
||||||
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
|
/// re-kicks; 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. The engine owns the
|
||||||
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
|
/// re-kick timing, the 1-LSB dedupe-defeat jitter, and every staleness/lease bound — this worker
|
||||||
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
|
/// only applies the commands it emits (`design/rumble-root-fix.md` §D).
|
||||||
/// left untouched.
|
const DECK_RUMBLE_KEEPALIVE_MS: u16 = 40;
|
||||||
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
|
||||||
|
|
||||||
/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host
|
|
||||||
/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh,
|
|
||||||
/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every
|
|
||||||
/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely)
|
|
||||||
/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s,
|
|
||||||
/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its
|
|
||||||
/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]).
|
|
||||||
const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000;
|
|
||||||
|
|
||||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||||
@@ -626,32 +616,6 @@ impl Ds5Feedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-controller rumble render state (the Steam Deck keep-alive + the host's v2 lease). Held
|
|
||||||
/// per [`Slot`] so a rumble the host addressed to pad N drives only pad N's actuator.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct RumbleState {
|
|
||||||
/// Last rumble value handed to this pad (the logical host value, pre-jitter) and when —
|
|
||||||
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
|
||||||
last: (u16, u16),
|
|
||||||
last_at: Option<Instant>,
|
|
||||||
/// When the last *real* host rumble datagram landed on this slot — set only in the feedback
|
|
||||||
/// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive
|
|
||||||
/// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what
|
|
||||||
/// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive
|
|
||||||
/// stops and issues one (0, 0). See [`Worker::render_feedback`].
|
|
||||||
updated_at: Option<Instant>,
|
|
||||||
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
|
|
||||||
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
|
|
||||||
jitter: bool,
|
|
||||||
/// The host lease from a v2 rumble envelope: last non-zero level expires at this instant
|
|
||||||
/// unless the host renews it. `None` outside a live rumble or against a legacy host (which
|
|
||||||
/// sends no lease — the pad then relies on SDL's own duration expiry as before).
|
|
||||||
deadline: Option<Instant>,
|
|
||||||
/// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble`
|
|
||||||
/// duration; `0` = legacy host (fall back to the proven 1.5 s duration).
|
|
||||||
ttl_ms: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||||
/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback
|
/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback
|
||||||
/// state that used to be single-scalar on the Worker. Opening the device is what grabs the
|
/// state that used to be single-scalar on the Worker. Opening the device is what grabs the
|
||||||
@@ -683,7 +647,6 @@ struct Slot {
|
|||||||
/// close lift a click held across detach/unplug.
|
/// close lift a click held across detach/unplug.
|
||||||
held_clicks: [bool; 2],
|
held_clicks: [bool; 2],
|
||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
rumble: RumbleState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Slot {
|
impl Slot {
|
||||||
@@ -699,7 +662,6 @@ impl Slot {
|
|||||||
surface_last: [(0, 0, false); 2],
|
surface_last: [(0, 0, false); 2],
|
||||||
held_clicks: [false; 2],
|
held_clicks: [false; 2],
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
rumble: RumbleState::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,6 +890,20 @@ impl Worker {
|
|||||||
// uses the session-default kind.
|
// uses the session-default kind.
|
||||||
if let Some(c) = &self.attached {
|
if let Some(c) = &self.attached {
|
||||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
||||||
|
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||||
|
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||||
|
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||||
|
// behind for the next pad on the same index.
|
||||||
|
let quirks = if pref == GamepadPref::SteamDeck {
|
||||||
|
ActuatorQuirks {
|
||||||
|
keepalive_ms: DECK_RUMBLE_KEEPALIVE_MS,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ActuatorQuirks::default()
|
||||||
|
};
|
||||||
|
c.set_rumble_quirks(index as u16, quirks);
|
||||||
}
|
}
|
||||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
||||||
self.slots.push(slot);
|
self.slots.push(slot);
|
||||||
@@ -940,6 +916,10 @@ impl Worker {
|
|||||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||||
/// already gone (unplug).
|
/// already gone (unplug).
|
||||||
fn close_slot_at(&mut self, i: usize) {
|
fn close_slot_at(&mut self, i: usize) {
|
||||||
|
// Best-effort physical silence before the handle drops: a slot closed mid-buzz (detach /
|
||||||
|
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||||
|
// expected for an already-unplugged pad.
|
||||||
|
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||||
if let Some(c) = self.attached.clone() {
|
if let Some(c) = self.attached.clone() {
|
||||||
Self::flush_slot(&c, &mut self.slots[i]);
|
Self::flush_slot(&c, &mut self.slots[i]);
|
||||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||||
@@ -1464,107 +1444,47 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a rumble value to SDL on one slot's pad, remembering it for the Deck keep-alive.
|
/// Hand one policy-engine command to SDL on a slot's pad, verbatim. The core engine owns all
|
||||||
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
|
/// rumble policy — leases, legacy-host staleness, the Deck keepalive + its dedupe-defeat
|
||||||
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
|
/// jitter (declared as quirks at slot open) — so this worker keeps no rumble state at all.
|
||||||
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
|
/// `backstop_ms` becomes the SDL duration: the hardware-level net under a stalled worker
|
||||||
/// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
|
/// thread (the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||||
/// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
|
/// mechanism).
|
||||||
/// self-silences at the TTL); against a legacy host (`ttl_ms == 0`) it stays the proven 1.5 s.
|
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, backstop_ms: u32) {
|
||||||
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, deck: bool) {
|
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||||
let dur_ms: u32 = if slot.rumble.ttl_ms == 0 {
|
100 // a stop takes effect immediately; the duration is irrelevant
|
||||||
1_500 // legacy host: no lease — keep the proven duration
|
|
||||||
} else {
|
} else {
|
||||||
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
|
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||||
// actuator between SDL writes.
|
|
||||||
(slot.rumble.ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
|
|
||||||
};
|
};
|
||||||
let (out_low, out_high) =
|
|
||||||
if deck && (low, high) == slot.rumble.last && (low, high) != (0, 0) {
|
|
||||||
slot.rumble.jitter = !slot.rumble.jitter;
|
|
||||||
(low ^ slot.rumble.jitter as u16, high)
|
|
||||||
} else {
|
|
||||||
(low, high)
|
|
||||||
};
|
|
||||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||||
// on 0xCA with the pad index, so the two together pinpoint host-game vs client-render.
|
// on 0xCA with the pad index, so the two together pinpoint host-game vs client-render.
|
||||||
match slot.pad.set_rumble(out_low, out_high, dur_ms) {
|
match slot.pad.set_rumble(low, high, dur_ms) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
||||||
}
|
}
|
||||||
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
||||||
}
|
}
|
||||||
slot.rumble.last = (low, high);
|
|
||||||
slot.rumble.last_at = Some(Instant::now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs /
|
/// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs /
|
||||||
/// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this
|
/// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this
|
||||||
/// thread is their single consumer. Rumble arrives as self-terminating v2 envelopes: each
|
/// thread is their single consumer. Rumble arrives as EFFECTIVE commands from the core's
|
||||||
/// carries a TTL the host renews while the level holds and lets expire when it stops, so the
|
/// shared policy engine, which already applied every policy — v2 lease expiry, legacy-host
|
||||||
/// actuator's divergence from the host's intent is bounded by the wire, not by a client guess.
|
/// staleness, the Deck actuator keepalive + jitter (via the quirks declared at slot open),
|
||||||
/// A legacy host (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration
|
/// and connection-close drain zeros — so this worker applies commands verbatim and keeps no
|
||||||
/// expiry as before.
|
/// rumble state of its own (`design/rumble-root-fix.md` §D).
|
||||||
fn render_feedback(&mut self) {
|
fn render_feedback(&mut self) {
|
||||||
let Some(connector) = self.attached.clone() else {
|
let Some(connector) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Rumble envelopes (0xCA) → the slot holding that wire pad index. An update for an index
|
// Engine commands → the slot holding that wire pad index. A command for an index with no
|
||||||
// with no live slot (a pad that just unplugged) is dropped.
|
// live slot (a pad that just unplugged) is dropped. The loop ends on NoFrame (drained
|
||||||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
// dry this tick) or Closed (session over — the engine delivered its close-drain zeros
|
||||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == pad) {
|
// first; the physical silence backstop is in `close_slot_at`).
|
||||||
let deck = slot.pref == GamepadPref::SteamDeck;
|
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||||
slot.rumble.ttl_ms = ttl.unwrap_or(0);
|
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||||
// A v2 lease sets an explicit client-side deadline; a legacy update clears it and
|
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||||
// leans on SDL's own duration expiry (unchanged behaviour).
|
|
||||||
slot.rumble.deadline = match ttl {
|
|
||||||
Some(ms) if (low, high) != (0, 0) => {
|
|
||||||
Some(Instant::now() + Duration::from_millis(ms as u64))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
// Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive
|
|
||||||
// re-kick refreshes every ~40 ms), this clock advances only here, so a legacy
|
|
||||||
// lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below.
|
|
||||||
slot.rumble.updated_at = Some(Instant::now());
|
|
||||||
Self::issue_rumble(slot, low, high, deck);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Steam Deck keep-alive, per slot: the built-in actuator decays inside SDL's ~2 s internal
|
|
||||||
// rumble resend, and SDL dedupes an unchanged `set_rumble` to a no-op device write — so a
|
|
||||||
// steady host value is felt as a periodic pulse. Re-kick a Deck slot below the decay
|
|
||||||
// (`DECK_RUMBLE_KEEPALIVE_MS`) so its discrete bursts fuse into a continuous buzz, but
|
|
||||||
// silence it once the host's lease expires (the host stopped renewing — a lost stop, or the
|
|
||||||
// host died). The per-slot timing guards make this idempotent with a fresh datagram this
|
|
||||||
// tick (a just-set `last_at`/`deadline` fails both checks). Non-Deck slots sustain/expire at
|
|
||||||
// the SDL/hardware level and never enter here.
|
|
||||||
for slot in self.slots.iter_mut() {
|
|
||||||
if slot.pref != GamepadPref::SteamDeck || slot.rumble.last == (0, 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if slot.rumble.deadline.is_some_and(|d| Instant::now() >= d) {
|
|
||||||
slot.rumble.deadline = None;
|
|
||||||
slot.rumble.ttl_ms = 0;
|
|
||||||
Self::issue_rumble(slot, 0, 0, true);
|
|
||||||
} else if slot.rumble.ttl_ms == 0
|
|
||||||
&& slot
|
|
||||||
.rumble
|
|
||||||
.updated_at
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS))
|
|
||||||
{
|
|
||||||
// Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so
|
|
||||||
// this only trips on a lost stop-frame the host never followed up — silence the
|
|
||||||
// actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble`
|
|
||||||
// sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks.
|
|
||||||
Self::issue_rumble(slot, 0, 0, true);
|
|
||||||
} else if slot
|
|
||||||
.rumble
|
|
||||||
.last_at
|
|
||||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
|
||||||
{
|
|
||||||
let (low, high) = slot.rumble.last;
|
|
||||||
Self::issue_rumble(slot, low, high, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index.
|
// HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index.
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ pub mod session;
|
|||||||
pub mod trust;
|
pub mod trust;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
mod video_color;
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
mod video_software;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod video_vaapi;
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
mod video_vulkan;
|
||||||
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||||
// present-path decision and the Apple Metal port are their own phases).
|
// present-path decision and the Apple Metal port are their own phases).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -461,6 +461,14 @@ pub struct Settings {
|
|||||||
pub refresh_hz: u32,
|
pub refresh_hz: u32,
|
||||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
|
/// Render-resolution multiplier: the client asks the host to render/encode at
|
||||||
|
/// `resolved mode × render_scale` and the presenter downscales the larger decoded frame to the
|
||||||
|
/// window (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under
|
||||||
|
/// native for a lighter host/link). `1.0` = Native (the prior behaviour). Applied at connect
|
||||||
|
/// (and each match-window resize) via [`punktfunk_core::render_scale`], clamped even + to the
|
||||||
|
/// codec's max dimension. Missing in a pre-existing store → the `Default` (1.0) via the
|
||||||
|
/// container `#[serde(default)]`.
|
||||||
|
pub render_scale: f64,
|
||||||
pub gamepad: String,
|
pub gamepad: String,
|
||||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||||
@@ -590,6 +598,7 @@ impl Default for Settings {
|
|||||||
height: 0,
|
height: 0,
|
||||||
refresh_hz: 0,
|
refresh_hz: 0,
|
||||||
bitrate_kbps: 0,
|
bitrate_kbps: 0,
|
||||||
|
render_scale: 1.0,
|
||||||
gamepad: "auto".into(),
|
gamepad: "auto".into(),
|
||||||
forward_pad: String::new(),
|
forward_pad: String::new(),
|
||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
|
|||||||
+10
-1101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
|||||||
|
//! The stream's per-frame colour signalling (`ColorDesc`) + the Y′CbCr→RGB CSC matrix (`csc_rows`).
|
||||||
|
#![allow(clippy::unnecessary_cast)]
|
||||||
|
|
||||||
|
use ffmpeg_next as ffmpeg;
|
||||||
|
|
||||||
|
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
|
||||||
|
/// `AVFrame` CICP fields). The Windows host switches an HDR desktop to Main10 BT.2020 PQ
|
||||||
|
/// **in-band** (the Welcome still says SDR — clients are expected to follow the VUI, as
|
||||||
|
/// the Windows/Apple/Android clients do), so rendering must follow the frames, not the
|
||||||
|
/// handshake — else PQ content drawn as BT.709 comes out washed out and desaturated.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub struct ColorDesc {
|
||||||
|
/// H.273 code points as signaled (2 = unspecified → the renderer picks the SDR default).
|
||||||
|
pub primaries: u8,
|
||||||
|
pub transfer: u8,
|
||||||
|
pub matrix: u8,
|
||||||
|
pub full_range: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorDesc {
|
||||||
|
/// Read the CICP fields off a raw decoded frame. Public: the Windows client's raw-FFI
|
||||||
|
/// D3D11VA/software decoders build their per-frame `ColorDesc` with it too (same
|
||||||
|
/// `ffmpeg-next` major, so the `AVFrame` type unifies across the workspace).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
||||||
|
pub unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
||||||
|
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
||||||
|
unsafe {
|
||||||
|
ColorDesc {
|
||||||
|
primaries: (*frame).color_primaries as u32 as u8,
|
||||||
|
transfer: (*frame).color_trc as u32 as u8,
|
||||||
|
matrix: (*frame).colorspace as u32 as u8,
|
||||||
|
full_range: (*frame).color_range == ffmpeg::ffi::AVColorRange::AVCOL_RANGE_JPEG,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PQ (SMPTE ST.2084) transfer — the HDR10 signal.
|
||||||
|
pub fn is_pq(&self) -> bool {
|
||||||
|
self.transfer == 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Y′CbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
|
||||||
|
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
|
||||||
|
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
|
||||||
|
/// client's D3D11 constant buffer), so a stream's signaled matrix/range is honored identically
|
||||||
|
/// everywhere; the Apple client ports this function (and its tests) to Swift.
|
||||||
|
///
|
||||||
|
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
|
||||||
|
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
|
||||||
|
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
||||||
|
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
||||||
|
/// `65535/65472` recovers exact `code/1023`.
|
||||||
|
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
||||||
|
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||||
|
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||||
|
let (kr, kb) = match desc.matrix {
|
||||||
|
5 | 6 => (0.299, 0.114),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
||||||
|
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
||||||
|
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
||||||
|
let (sy, oy, sc) = if desc.full_range {
|
||||||
|
(pack, 0.0f64, pack)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
pack * max / (219.0 * step),
|
||||||
|
-(16.0 * step) / max,
|
||||||
|
pack * max / (224.0 * step),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
||||||
|
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
||||||
|
// factor to land on the same scale.
|
||||||
|
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
|
||||||
|
let m = [
|
||||||
|
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
||||||
|
[
|
||||||
|
sy,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
],
|
||||||
|
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
||||||
|
];
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
||||||
|
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix,
|
||||||
|
full_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
||||||
|
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||||
|
#[test]
|
||||||
|
fn bt2020_10bit_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(9, false), 10, true);
|
||||||
|
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
||||||
|
let white = apply(&rows, [s(940), s(512), s(512)]);
|
||||||
|
let black = apply(&rows, [s(64), s(512), s(512)]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.002, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
||||||
|
/// — the GL presenter's test, in row form.
|
||||||
|
#[test]
|
||||||
|
fn bt709_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(1, false), 8, false);
|
||||||
|
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.005, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
||||||
|
/// matrix-code dispatch), same as the GL presenter's test.
|
||||||
|
#[test]
|
||||||
|
fn full_range_and_red_excursion() {
|
||||||
|
let rows = csc_rows(desc(5, true), 8, false);
|
||||||
|
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
||||||
|
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||||
|
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
||||||
|
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||||
|
let rows709 = csc_rows(desc(1, true), 8, false);
|
||||||
|
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
||||||
|
assert!(
|
||||||
|
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||||
|
"{red709:?}"
|
||||||
|
);
|
||||||
|
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
||||||
|
/// grid of inputs — same math, different packing.
|
||||||
|
#[test]
|
||||||
|
fn rows_match_the_gl_matrix_form() {
|
||||||
|
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
||||||
|
let d = desc(matrix, full);
|
||||||
|
let rows = csc_rows(d, 8, false);
|
||||||
|
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
||||||
|
let (kr, kb) = match matrix {
|
||||||
|
5 | 6 => (0.299f32, 0.114f32),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let (sy, oy, sc) = if full {
|
||||||
|
(1.0f32, 0.0f32, 1.0f32)
|
||||||
|
} else {
|
||||||
|
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||||
|
};
|
||||||
|
let mat = [
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
0.0,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
2.0 * (1.0 - kb) * sc,
|
||||||
|
2.0 * (1.0 - kr) * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
0.0,
|
||||||
|
];
|
||||||
|
let off = [oy, -0.5, -0.5];
|
||||||
|
for yuv in [
|
||||||
|
[0.1f32, 0.3, 0.7],
|
||||||
|
[0.9, 0.5, 0.5],
|
||||||
|
[0.5, 0.2, 0.8],
|
||||||
|
[16.0 / 255.0, 0.5, 0.5],
|
||||||
|
] {
|
||||||
|
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||||
|
let gl: [f32; 3] =
|
||||||
|
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
||||||
|
let ours = apply(&rows, yuv);
|
||||||
|
for (a, b) in gl.iter().zip(ours) {
|
||||||
|
assert!(
|
||||||
|
(a - b).abs() < 1e-5,
|
||||||
|
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! CPU/libavcodec software decode backend (swscale → RGBA).
|
||||||
|
|
||||||
|
use crate::video::{averr, CpuFrame};
|
||||||
|
use crate::video_color::ColorDesc;
|
||||||
|
use anyhow::{anyhow, Context as _, Result};
|
||||||
|
use ffmpeg::format::Pixel;
|
||||||
|
use ffmpeg::software::scaling;
|
||||||
|
use ffmpeg::util::frame::Video as AvFrame;
|
||||||
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
// --- software backend ---------------------------------------------------------------
|
||||||
|
|
||||||
|
pub(crate) struct SoftwareDecoder {
|
||||||
|
decoder: ffmpeg::decoder::Video,
|
||||||
|
/// Rebuilt whenever the decoded format/size — or the colour signaling (a mid-stream
|
||||||
|
/// SDR↔HDR flip) — changes.
|
||||||
|
sws: Option<(scaling::Context, Pixel, u32, u32, ColorDesc)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoftwareDecoder {
|
||||||
|
pub(crate) fn new(codec_id: ffmpeg::codec::Id) -> Result<SoftwareDecoder> {
|
||||||
|
let codec = ffmpeg::decoder::find(codec_id)
|
||||||
|
.ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?;
|
||||||
|
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
||||||
|
unsafe {
|
||||||
|
let raw = ctx.as_mut_ptr();
|
||||||
|
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
|
// Slice threading adds no frame delay (frame threading adds thread_count-1).
|
||||||
|
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
||||||
|
(*raw).thread_count = 0; // auto
|
||||||
|
}
|
||||||
|
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||||
|
Ok(SoftwareDecoder { decoder, sws: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
|
||||||
|
let packet = ffmpeg::Packet::copy(au);
|
||||||
|
self.decoder
|
||||||
|
.send_packet(&packet)
|
||||||
|
.map_err(|e| anyhow!("send_packet: {e}"))?;
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
let mut out = None;
|
||||||
|
while self.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
out = Some(self.convert_rgba(&frame)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||||
|
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||||
|
// SAFETY: `frame.as_ptr()` is the decoder-owned live AVFrame for this call.
|
||||||
|
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||||
|
let rebuild = !matches!(&self.sws,
|
||||||
|
Some((_, f, sw, sh, c)) if *f == fmt && *sw == w && *sh == h && *c == color);
|
||||||
|
if rebuild {
|
||||||
|
let mut ctx =
|
||||||
|
scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
|
||||||
|
.context("swscale context")?;
|
||||||
|
// swscale defaults to BT.601 coefficients — set them from the FRAME's signaling
|
||||||
|
// (unspecified → BT.709 limited, the host's SDR default; a Windows HDR desktop
|
||||||
|
// streams BT.2020 in-band). Without this, YUV→RGB decodes with the wrong matrix
|
||||||
|
// and colours shift. Destination = full-range RGB; the transfer function stays
|
||||||
|
// baked in (the presenter tags PQ textures so GTK applies the EOTF).
|
||||||
|
const SWS_CS_ITU709: i32 = 1;
|
||||||
|
const SWS_CS_ITU601: i32 = 5;
|
||||||
|
const SWS_CS_BT2020: i32 = 9;
|
||||||
|
let cs = match color.matrix {
|
||||||
|
9 | 10 => SWS_CS_BT2020,
|
||||||
|
5 | 6 => SWS_CS_ITU601,
|
||||||
|
_ => SWS_CS_ITU709,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let coeffs = ffmpeg::ffi::sws_getCoefficients(cs);
|
||||||
|
ffmpeg::ffi::sws_setColorspaceDetails(
|
||||||
|
ctx.as_mut_ptr(),
|
||||||
|
coeffs, // inv_table: source (YUV) coefficients per the VUI
|
||||||
|
color.full_range as i32, // srcRange: 0 = limited/studio (MPEG)
|
||||||
|
coeffs, // table: destination coefficients (ignored for RGB output)
|
||||||
|
1, // dstRange: 1 = full-range RGB
|
||||||
|
0,
|
||||||
|
1 << 16,
|
||||||
|
1 << 16, // brightness, contrast, saturation (defaults)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.sws = Some((ctx, fmt, w, h, color));
|
||||||
|
}
|
||||||
|
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||||
|
// Single-pass conversion: swscale writes straight into the Vec the texture will
|
||||||
|
// wrap. (The old path scaled into a scratch AVFrame and then copied `data(0)` out
|
||||||
|
// — a second full-frame pass per frame.) 64-byte row alignment keeps swscale on
|
||||||
|
// aligned SIMD stores; `GdkMemoryTexture` takes the resulting stride explicitly.
|
||||||
|
const ALIGN: i32 = 64;
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
let dst_fmt = ffi::AVPixelFormat::AV_PIX_FMT_RGBA;
|
||||||
|
// SAFETY: pure size computation from format/dimensions; no pointers involved.
|
||||||
|
let size = unsafe { ffi::av_image_get_buffer_size(dst_fmt, w as i32, h as i32, ALIGN) };
|
||||||
|
if size < 0 {
|
||||||
|
return Err(averr("av_image_get_buffer_size", size));
|
||||||
|
}
|
||||||
|
let rgba = vec![0u8; size as usize];
|
||||||
|
let mut dst_data: [*mut u8; 4] = [ptr::null_mut(); 4];
|
||||||
|
let mut dst_linesize: [i32; 4] = [0; 4];
|
||||||
|
// SAFETY: fill_arrays only derives plane pointers/strides into `rgba` (sized by
|
||||||
|
// av_image_get_buffer_size above, same format/align) — no allocation, no
|
||||||
|
// ownership transfer; `rgba` outlives the scale below.
|
||||||
|
let r = unsafe {
|
||||||
|
ffi::av_image_fill_arrays(
|
||||||
|
dst_data.as_mut_ptr(),
|
||||||
|
dst_linesize.as_mut_ptr(),
|
||||||
|
rgba.as_ptr(),
|
||||||
|
dst_fmt,
|
||||||
|
w as i32,
|
||||||
|
h as i32,
|
||||||
|
ALIGN,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("av_image_fill_arrays", r));
|
||||||
|
}
|
||||||
|
// SAFETY: src pointers/strides belong to the decoder-owned `frame` (alive for the
|
||||||
|
// call); dst pointers were just filled over `rgba`, and sws_scale writes rows
|
||||||
|
// [0, h) only — exactly the buffer fill_arrays sized.
|
||||||
|
let r = unsafe {
|
||||||
|
ffi::sws_scale(
|
||||||
|
sws.as_mut_ptr(),
|
||||||
|
(*frame.as_ptr()).data.as_ptr() as *const *const u8,
|
||||||
|
(*frame.as_ptr()).linesize.as_ptr(),
|
||||||
|
0,
|
||||||
|
h as i32,
|
||||||
|
dst_data.as_ptr(),
|
||||||
|
dst_linesize.as_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("sws_scale", r));
|
||||||
|
}
|
||||||
|
Ok(CpuFrame {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
stride: dst_linesize[0] as usize,
|
||||||
|
rgba,
|
||||||
|
color,
|
||||||
|
// `is_key()` reads the same intra flag `frame_is_keyframe` derives from pict_type
|
||||||
|
// for the hardware paths; ffmpeg-next handles the FFmpeg-version binding split.
|
||||||
|
keyframe: frame.is_key(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The wire → `ColorDesc` plumbing: an HDR10 stream's VUI (BT.2020 primaries, PQ
|
||||||
|
/// transfer, BT.2020-NCL matrix, limited range) must arrive on the decoded frame —
|
||||||
|
/// this is what the Windows host emits in-band for an HDR desktop, and mis-rendering
|
||||||
|
/// it as BT.709 is the washed-out-colors bug. Fixture: one 64×64 Main10 IDR
|
||||||
|
/// (`tests/pq-frame.h265`, x265 with explicit VUI).
|
||||||
|
#[test]
|
||||||
|
fn software_decode_carries_pq_signaling() {
|
||||||
|
let au = include_bytes!("../tests/pq-frame.h265");
|
||||||
|
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
|
||||||
|
let mut got = dec.decode(au).expect("decode");
|
||||||
|
if got.is_none() {
|
||||||
|
// Low-delay decoders may still hold the frame until a flush — send EOF.
|
||||||
|
dec.decoder.send_eof().ok();
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
if dec.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
got = Some(dec.convert_rgba(&frame).expect("convert"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let f = got.expect("no frame decoded from the PQ fixture");
|
||||||
|
assert_eq!(
|
||||||
|
f.color,
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 9,
|
||||||
|
transfer: 16,
|
||||||
|
matrix: 9,
|
||||||
|
full_range: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(f.color.is_pq());
|
||||||
|
assert_eq!((f.width, f.height), (64, 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Golden colour fixtures: one 256×64 LOSSLESS x265 IDR of 8 fully-saturated colour bars per
|
||||||
|
/// signaling variant (generated offline with ffmpeg/libx265; the RGB→YUV conversion matched
|
||||||
|
/// to the VUI each fixture declares, so the original RGB is recoverable ±1 code). Decoding
|
||||||
|
/// through the real CPU path (`SoftwareDecoder` → per-frame `ColorDesc` → swscale with the
|
||||||
|
/// signaled matrix/range) must reproduce the bars — the end-to-end guard for the
|
||||||
|
/// signaling-driven CSC across BT.601/709 × limited/full. A hardcoded-709 regression fails
|
||||||
|
/// the 601 fixture by tens of code points; a range mix-up fails the full-range one.
|
||||||
|
#[test]
|
||||||
|
fn software_decode_reproduces_golden_bars() {
|
||||||
|
const BARS: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
];
|
||||||
|
let fixtures: [(&str, &[u8], ColorDesc); 3] = [
|
||||||
|
(
|
||||||
|
"601-limited",
|
||||||
|
include_bytes!("../tests/bars-601-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 5, // BT.470BG — what a Linux host's RGB-input NVENC signals
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-limited",
|
||||||
|
include_bytes!("../tests/bars-709-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-full",
|
||||||
|
include_bytes!("../tests/bars-709-full.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: true, // the PUNKTFUNK_444_FULLRANGE experiment's signaling
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, au, want_color) in fixtures {
|
||||||
|
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
|
||||||
|
let mut got = dec.decode(au).expect("decode");
|
||||||
|
if got.is_none() {
|
||||||
|
dec.decoder.send_eof().ok();
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
if dec.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
got = Some(dec.convert_rgba(&frame).expect("convert"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let f = got.unwrap_or_else(|| panic!("{name}: no frame decoded"));
|
||||||
|
assert_eq!(f.color, want_color, "{name}: signaling");
|
||||||
|
assert_eq!((f.width, f.height), (256, 64), "{name}: dims");
|
||||||
|
for (i, (r, g, b)) in BARS.iter().enumerate() {
|
||||||
|
let (cx, cy) = (i * 32 + 16, 32usize);
|
||||||
|
let o = cy * f.stride + cx * 4;
|
||||||
|
let px = &f.rgba[o..o + 3];
|
||||||
|
for (got, want) in px.iter().zip([r, g, b]) {
|
||||||
|
assert!(
|
||||||
|
got.abs_diff(*want) <= 3,
|
||||||
|
"{name} bar {i}: got {px:?}, want ({r},{g},{b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//! VAAPI (libavcodec hwaccel) decode backend → DRM-PRIME dmabuf for the presenter. Linux-only.
|
||||||
|
|
||||||
|
use crate::video::{
|
||||||
|
averr, drm_fourcc_for, frame_is_keyframe, DmabufFrame, DmabufPlane, DrmFrameGuard,
|
||||||
|
AVERROR_EAGAIN,
|
||||||
|
};
|
||||||
|
use crate::video_color::ColorDesc;
|
||||||
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
/// libavcodec offers the formats it can decode into; pick the VAAPI hw surface. Falling
|
||||||
|
/// back to the first (software) entry would silently decode on the CPU *and* break our
|
||||||
|
/// dmabuf mapping — return NONE instead so the error surfaces and the session demotes
|
||||||
|
/// to the software backend explicitly.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
unsafe extern "C" fn pick_vaapi(
|
||||||
|
_ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||||
|
) -> ffmpeg::ffi::AVPixelFormat {
|
||||||
|
unsafe {
|
||||||
|
while *list != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
|
||||||
|
if *list == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI {
|
||||||
|
return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||||
|
}
|
||||||
|
list = list.add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) struct VaapiDecoder {
|
||||||
|
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||||
|
packet: *mut ffmpeg::ffi::AVPacket,
|
||||||
|
frame: *mut ffmpeg::ffi::AVFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-owner pointers, only touched from the session pump thread.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
unsafe impl Send for VaapiDecoder {}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
impl VaapiDecoder {
|
||||||
|
pub(crate) fn new(codec_id: ffmpeg::codec::Id) -> Result<VaapiDecoder> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||||
|
let r = ffi::av_hwdevice_ctx_create(
|
||||||
|
&mut hw_device,
|
||||||
|
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if r < 0 {
|
||||||
|
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
|
||||||
|
}
|
||||||
|
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||||
|
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||||
|
if codec.is_null() {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("no {codec_id:?} decoder");
|
||||||
|
}
|
||||||
|
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||||
|
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||||
|
(*ctx).get_format = Some(pick_vaapi);
|
||||||
|
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
|
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||||
|
|
||||||
|
// The presenter holds mapped surfaces PAST receive_frame (the paintable's
|
||||||
|
// current texture + the newest frame in flight each pin one until GDK's
|
||||||
|
// release func) — surfaces libavcodec doesn't know are missing from its
|
||||||
|
// fixed-size VAAPI pool. Without headroom the decoder can recycle a surface
|
||||||
|
// the renderer is still sampling (intermittent block corruption) or fail
|
||||||
|
// allocation under scheduling jitter.
|
||||||
|
(*ctx).extra_hw_frames = 4;
|
||||||
|
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||||
|
if r < 0 {
|
||||||
|
let mut ctx = ctx;
|
||||||
|
ffi::avcodec_free_context(&mut ctx);
|
||||||
|
let mut hw_device = hw_device;
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
|
||||||
|
}
|
||||||
|
Ok(VaapiDecoder {
|
||||||
|
ctx,
|
||||||
|
hw_device,
|
||||||
|
packet: ffi::av_packet_alloc(),
|
||||||
|
frame: ffi::av_frame_alloc(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("av_new_packet", r));
|
||||||
|
}
|
||||||
|
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||||
|
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||||
|
ffi::av_packet_unref(self.packet);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("send_packet", r));
|
||||||
|
}
|
||||||
|
let mut out = None;
|
||||||
|
loop {
|
||||||
|
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||||
|
if r == AVERROR_EAGAIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("receive_frame", r));
|
||||||
|
}
|
||||||
|
out = Some(self.map_dmabuf()?); // newest wins; older guards drop here
|
||||||
|
ffi::av_frame_unref(self.frame);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
|
||||||
|
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
|
||||||
|
///
|
||||||
|
/// FFmpeg's VAAPI export uses `VA_EXPORT_SURFACE_SEPARATE_LAYERS`, so an NV12 surface
|
||||||
|
/// comes back as TWO layers (`R8` luma + `GR88` chroma), each one plane — NOT a single
|
||||||
|
/// `NV12` layer. The previous code took `layers[0]` only: GTK then saw an `R8`
|
||||||
|
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
|
||||||
|
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
|
||||||
|
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
|
||||||
|
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
||||||
|
bail!("decoder returned a software frame (no VAAPI surface)");
|
||||||
|
}
|
||||||
|
// The real pixel layout lives on the hardware frames context, not the
|
||||||
|
// DRM-PRIME layer formats (those are the per-plane R8/GR88 component formats).
|
||||||
|
let sw_format = {
|
||||||
|
let hwfc = (*self.frame).hw_frames_ctx;
|
||||||
|
if hwfc.is_null() {
|
||||||
|
bail!("VAAPI frame without a hardware frames context");
|
||||||
|
}
|
||||||
|
(*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
|
||||||
|
};
|
||||||
|
let fourcc = drm_fourcc_for(sw_format)
|
||||||
|
.ok_or_else(|| anyhow!("unsupported VAAPI output format {sw_format:?}"))?;
|
||||||
|
|
||||||
|
let drm = ffi::av_frame_alloc();
|
||||||
|
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||||
|
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
|
||||||
|
if r < 0 {
|
||||||
|
let mut drm = drm;
|
||||||
|
ffi::av_frame_free(&mut drm);
|
||||||
|
return Err(averr("av_hwframe_map", r));
|
||||||
|
}
|
||||||
|
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
|
||||||
|
let guard = DrmFrameGuard(drm);
|
||||||
|
let d = &*desc;
|
||||||
|
if d.nb_layers < 1 || d.nb_objects < 1 {
|
||||||
|
bail!("DRM descriptor without layers/objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten planes across ALL layers, in declared order — the combined fourcc's
|
||||||
|
// plane order (Y, then UV for NV12) matches the layer order FFmpeg emits.
|
||||||
|
let mut planes = Vec::new();
|
||||||
|
for layer in &d.layers[..d.nb_layers as usize] {
|
||||||
|
for p in &layer.planes[..layer.nb_planes as usize] {
|
||||||
|
let obj = &d.objects[p.object_index as usize];
|
||||||
|
planes.push(DmabufPlane {
|
||||||
|
fd: obj.fd,
|
||||||
|
offset: p.offset as u32,
|
||||||
|
stride: p.pitch as u32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole surface shares one tiling modifier (one BO on radeonsi); GTK takes
|
||||||
|
// a single modifier for the texture.
|
||||||
|
let modifier = d.objects[0].format_modifier;
|
||||||
|
|
||||||
|
log_descriptor_once(d, sw_format, fourcc, modifier);
|
||||||
|
|
||||||
|
Ok(DmabufFrame {
|
||||||
|
width: (*self.frame).width as u32,
|
||||||
|
height: (*self.frame).height as u32,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
planes,
|
||||||
|
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
|
||||||
|
// this returns); plain CICP field reads.
|
||||||
|
color: ColorDesc::from_raw(self.frame),
|
||||||
|
keyframe: frame_is_keyframe(self.frame),
|
||||||
|
guard,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time dump of the DRM descriptor layout (objects, layers, planes, modifier) — so a
|
||||||
|
/// new client/driver combination's real layout is visible in the logs without a debugger.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn log_descriptor_once(
|
||||||
|
d: &ffmpeg_next::ffi::AVDRMFrameDescriptor,
|
||||||
|
sw: ffmpeg_next::ffi::AVPixelFormat,
|
||||||
|
fourcc: u32,
|
||||||
|
modifier: u64,
|
||||||
|
) {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||||
|
if !ONCE.swap(false, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let layers: Vec<(u32, i32)> = d.layers[..d.nb_layers.max(0) as usize]
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.format, l.nb_planes))
|
||||||
|
.collect();
|
||||||
|
tracing::info!(
|
||||||
|
sw_format = ?sw,
|
||||||
|
chosen_fourcc = format_args!("{:#010x}", fourcc),
|
||||||
|
nb_objects = d.nb_objects,
|
||||||
|
nb_layers = d.nb_layers,
|
||||||
|
?layers,
|
||||||
|
modifier = format_args!("{:#018x}", modifier),
|
||||||
|
"VAAPI dmabuf descriptor layout (first frame)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
impl Drop for VaapiDecoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
ffi::av_packet_free(&mut self.packet);
|
||||||
|
ffi::av_frame_free(&mut self.frame);
|
||||||
|
ffi::avcodec_free_context(&mut self.ctx);
|
||||||
|
ffi::av_buffer_unref(&mut self.hw_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
|
||||||
|
#![allow(clippy::unnecessary_cast)]
|
||||||
|
|
||||||
|
use crate::video::{
|
||||||
|
averr, frame_is_keyframe, DrmFrameGuard, QueueLock, VkVideoFrame, VulkanDecodeDevice,
|
||||||
|
AVERROR_EAGAIN,
|
||||||
|
};
|
||||||
|
use crate::video_color::ColorDesc;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
// --- Vulkan Video backend -------------------------------------------------------------
|
||||||
|
|
||||||
|
/// FFmpeg's Vulkan Video decoder over the PRESENTER's device: the hwdevice context is
|
||||||
|
/// built from [`VulkanDecodeDevice`]'s handles (not `av_hwdevice_ctx_create`, which
|
||||||
|
/// would make FFmpeg create its own device the presenter can't sample from). Output
|
||||||
|
/// frames are `AVVkFrame`s whose VkImage the presenter feeds straight to its CSC pass.
|
||||||
|
pub(crate) struct VulkanDecoder {
|
||||||
|
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||||
|
packet: *mut ffmpeg::ffi::AVPacket,
|
||||||
|
frame: *mut ffmpeg::ffi::AVFrame,
|
||||||
|
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
|
||||||
|
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||||
|
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||||
|
vk_device: pf_ffvk::VkDevice,
|
||||||
|
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||||
|
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||||
|
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||||
|
_ctx_storage: Box<VkCtxStorage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-owner pointers, only touched from the session pump thread.
|
||||||
|
unsafe impl Send for VulkanDecoder {}
|
||||||
|
|
||||||
|
struct VkCtxStorage {
|
||||||
|
_inst: Vec<std::ffi::CString>,
|
||||||
|
inst_ptrs: Vec<*const std::os::raw::c_char>,
|
||||||
|
_dev: Vec<std::ffi::CString>,
|
||||||
|
dev_ptrs: Vec<*const std::os::raw::c_char>,
|
||||||
|
f11: pf_ffvk::VkPhysicalDeviceVulkan11Features,
|
||||||
|
f12: pf_ffvk::VkPhysicalDeviceVulkan12Features,
|
||||||
|
f13: pf_ffvk::VkPhysicalDeviceVulkan13Features,
|
||||||
|
/// Keeps the shared queue lock alive for `AVHWDeviceContext.user_opaque` — the
|
||||||
|
/// `lock_queue`/`unlock_queue` trampolines below dereference it for as long as the
|
||||||
|
/// hw device context can fire them.
|
||||||
|
_queue_lock: std::sync::Arc<QueueLock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FFmpeg `AVVulkanDeviceContext.lock_queue` trampoline: take the device's shared
|
||||||
|
/// [`QueueLock`] (stashed in `AVHWDeviceContext.user_opaque`; owned by
|
||||||
|
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
|
||||||
|
/// which only serializes FFmpeg against itself — the presenter submits to the same
|
||||||
|
/// graphics queue from another thread and holds this same lock around its calls.
|
||||||
|
unsafe extern "C" fn ffvk_lock_queue(
|
||||||
|
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||||
|
_queue_family: u32,
|
||||||
|
_index: u32,
|
||||||
|
) {
|
||||||
|
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||||
|
let lock = (*dev).user_opaque as *const QueueLock;
|
||||||
|
(*lock).lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The matching `unlock_queue` trampoline — see [`ffvk_lock_queue`].
|
||||||
|
unsafe extern "C" fn ffvk_unlock_queue(
|
||||||
|
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||||
|
_queue_family: u32,
|
||||||
|
_index: u32,
|
||||||
|
) {
|
||||||
|
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||||
|
let lock = (*dev).user_opaque as *const QueueLock;
|
||||||
|
(*lock).unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VulkanDecoder {
|
||||||
|
pub(crate) fn new(
|
||||||
|
codec_id: ffmpeg::codec::Id,
|
||||||
|
vk: &VulkanDecodeDevice,
|
||||||
|
) -> Result<VulkanDecoder> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let mut hw_device =
|
||||||
|
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VULKAN);
|
||||||
|
if hw_device.is_null() {
|
||||||
|
bail!("av_hwdevice_ctx_alloc(VULKAN) failed (FFmpeg built without Vulkan?)");
|
||||||
|
}
|
||||||
|
let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext;
|
||||||
|
let hwctx = (*devctx).hwctx as *mut pf_ffvk::AVVulkanDeviceContext;
|
||||||
|
|
||||||
|
// Pinned storage for everything the context points into.
|
||||||
|
let mut store = Box::new(VkCtxStorage {
|
||||||
|
_inst: vk.instance_extensions.clone(),
|
||||||
|
inst_ptrs: Vec::new(),
|
||||||
|
_dev: vk.device_extensions.clone(),
|
||||||
|
dev_ptrs: Vec::new(),
|
||||||
|
f11: std::mem::zeroed(),
|
||||||
|
f12: std::mem::zeroed(),
|
||||||
|
f13: std::mem::zeroed(),
|
||||||
|
_queue_lock: vk.queue_lock.clone(),
|
||||||
|
});
|
||||||
|
store.inst_ptrs = store._inst.iter().map(|c| c.as_ptr()).collect();
|
||||||
|
store.dev_ptrs = store._dev.iter().map(|c| c.as_ptr()).collect();
|
||||||
|
// The features enabled at device creation, as the 1.1/1.2/1.3 chain FFmpeg
|
||||||
|
// walks to learn what it may use (sType values are vulkan.h constants).
|
||||||
|
store.f11.sType =
|
||||||
|
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES;
|
||||||
|
store.f11.samplerYcbcrConversion = vk.f_sampler_ycbcr as u32;
|
||||||
|
store.f12.sType =
|
||||||
|
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
|
||||||
|
store.f12.timelineSemaphore = vk.f_timeline_semaphore as u32;
|
||||||
|
store.f13.sType =
|
||||||
|
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||||
|
store.f13.synchronization2 = vk.f_synchronization2 as u32;
|
||||||
|
store.f11.pNext = &mut store.f12 as *mut _ as *mut std::ffi::c_void;
|
||||||
|
store.f12.pNext = &mut store.f13 as *mut _ as *mut std::ffi::c_void;
|
||||||
|
|
||||||
|
(*hwctx).get_proc_addr = std::mem::transmute::<usize, pf_ffvk::PFN_vkGetInstanceProcAddr>(
|
||||||
|
vk.get_instance_proc_addr,
|
||||||
|
);
|
||||||
|
(*hwctx).inst = vk.instance as pf_ffvk::VkInstance;
|
||||||
|
(*hwctx).phys_dev = vk.physical_device as pf_ffvk::VkPhysicalDevice;
|
||||||
|
(*hwctx).act_dev = vk.device as pf_ffvk::VkDevice;
|
||||||
|
(*hwctx).device_features.sType =
|
||||||
|
pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||||
|
(*hwctx).device_features.pNext = &mut store.f11 as *mut _ as *mut std::ffi::c_void;
|
||||||
|
(*hwctx).enabled_inst_extensions = store.inst_ptrs.as_ptr();
|
||||||
|
(*hwctx).nb_enabled_inst_extensions = store.inst_ptrs.len() as i32;
|
||||||
|
(*hwctx).enabled_dev_extensions = store.dev_ptrs.as_ptr();
|
||||||
|
(*hwctx).nb_enabled_dev_extensions = store.dev_ptrs.len() as i32;
|
||||||
|
|
||||||
|
// Queue map: the deprecated per-role indices (tx/comp are "Required") plus
|
||||||
|
// the qf[] list, which per the header must also carry every family named
|
||||||
|
// above. One merged entry when decode shares the graphics family.
|
||||||
|
let g = vk.graphics_qf as i32;
|
||||||
|
let d = vk.decode_qf as i32;
|
||||||
|
(*hwctx).queue_family_index = g;
|
||||||
|
(*hwctx).nb_graphics_queues = 1;
|
||||||
|
(*hwctx).queue_family_tx_index = g;
|
||||||
|
(*hwctx).nb_tx_queues = 1;
|
||||||
|
(*hwctx).queue_family_comp_index = g;
|
||||||
|
(*hwctx).nb_comp_queues = 1;
|
||||||
|
(*hwctx).queue_family_encode_index = -1;
|
||||||
|
(*hwctx).nb_encode_queues = 0;
|
||||||
|
(*hwctx).queue_family_decode_index = d;
|
||||||
|
(*hwctx).nb_decode_queues = 1;
|
||||||
|
const VIDEO_DECODE_BIT: u32 = 0x20; // VK_QUEUE_VIDEO_DECODE_BIT_KHR
|
||||||
|
// `flags`/`video_caps` are bindgen enum types: i32 under MSVC, u32 under
|
||||||
|
// Linux clang — the `as _` casts absorb the difference.
|
||||||
|
if g == d {
|
||||||
|
(*hwctx).qf[0] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||||
|
idx: g,
|
||||||
|
num: 1,
|
||||||
|
flags: (vk.graphics_queue_flags | VIDEO_DECODE_BIT) as _,
|
||||||
|
video_caps: vk.decode_video_caps as _,
|
||||||
|
};
|
||||||
|
(*hwctx).nb_qf = 1;
|
||||||
|
} else {
|
||||||
|
(*hwctx).qf[0] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||||
|
idx: g,
|
||||||
|
num: 1,
|
||||||
|
flags: vk.graphics_queue_flags as _,
|
||||||
|
video_caps: 0,
|
||||||
|
};
|
||||||
|
(*hwctx).qf[1] = pf_ffvk::AVVulkanDeviceQueueFamily {
|
||||||
|
idx: d,
|
||||||
|
num: 1,
|
||||||
|
flags: VIDEO_DECODE_BIT as _,
|
||||||
|
video_caps: vk.decode_video_caps as _,
|
||||||
|
};
|
||||||
|
(*hwctx).nb_qf = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared-queue external sync (see [`QueueLock`]): FFmpeg must take the
|
||||||
|
// same lock the presenter holds around its own submits/presents — set
|
||||||
|
// BEFORE init so FFmpeg never installs its internal defaults (which only
|
||||||
|
// serialize FFmpeg against itself; the cross-thread race with the
|
||||||
|
// presenter's queue was an intermittent VK_ERROR_DEVICE_LOST).
|
||||||
|
(*devctx).user_opaque =
|
||||||
|
std::sync::Arc::as_ptr(&store._queue_lock) as *mut std::ffi::c_void;
|
||||||
|
(*hwctx).lock_queue = Some(ffvk_lock_queue);
|
||||||
|
(*hwctx).unlock_queue = Some(ffvk_unlock_queue);
|
||||||
|
|
||||||
|
let r = ffi::av_hwdevice_ctx_init(hw_device);
|
||||||
|
if r < 0 {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// vkWaitSemaphores for the pump's decode-complete stat: loader →
|
||||||
|
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
|
||||||
|
let gipa = (*hwctx)
|
||||||
|
.get_proc_addr
|
||||||
|
.expect("get_proc_addr was just set above");
|
||||||
|
let gdpa: pf_ffvk::PFN_vkGetDeviceProcAddr =
|
||||||
|
std::mem::transmute(gipa((*hwctx).inst, c"vkGetDeviceProcAddr".as_ptr()));
|
||||||
|
let wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores = std::mem::transmute(gdpa
|
||||||
|
.expect("vkGetDeviceProcAddr resolvable")(
|
||||||
|
(*hwctx).act_dev,
|
||||||
|
c"vkWaitSemaphores".as_ptr(),
|
||||||
|
));
|
||||||
|
if wait_semaphores.is_none() {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("vkWaitSemaphores unresolvable on this device");
|
||||||
|
}
|
||||||
|
let vk_device = (*hwctx).act_dev;
|
||||||
|
|
||||||
|
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||||
|
if codec.is_null() {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("no {codec_id:?} decoder");
|
||||||
|
}
|
||||||
|
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||||
|
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||||
|
(*ctx).get_format = Some(pick_vulkan);
|
||||||
|
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
|
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||||
|
// Same pool headroom rationale as VAAPI: the presenter pins the on-screen
|
||||||
|
// frame + the newest in flight past receive_frame.
|
||||||
|
(*ctx).extra_hw_frames = 4;
|
||||||
|
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||||
|
if r < 0 {
|
||||||
|
let mut ctx = ctx;
|
||||||
|
ffi::avcodec_free_context(&mut ctx);
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
return Err(averr("avcodec_open2 (vulkan)", r));
|
||||||
|
}
|
||||||
|
Ok(VulkanDecoder {
|
||||||
|
ctx,
|
||||||
|
hw_device,
|
||||||
|
packet: ffi::av_packet_alloc(),
|
||||||
|
frame: ffi::av_frame_alloc(),
|
||||||
|
wait_semaphores,
|
||||||
|
vk_device,
|
||||||
|
_ctx_storage: store,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<VkVideoFrame>> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("av_new_packet", r));
|
||||||
|
}
|
||||||
|
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||||
|
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||||
|
ffi::av_packet_unref(self.packet);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("send_packet", r));
|
||||||
|
}
|
||||||
|
let mut out = None;
|
||||||
|
loop {
|
||||||
|
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||||
|
if r == AVERROR_EAGAIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("receive_frame", r));
|
||||||
|
}
|
||||||
|
out = Some(self.extract()?); // newest wins; older guards drop here
|
||||||
|
ffi::av_frame_unref(self.frame);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until the timeline semaphore reaches `value` (GPU decode complete) or the
|
||||||
|
/// timeout passes. Pure measurement — the presenter's own GPU wait is what gates
|
||||||
|
/// sampling, so a timeout here only degrades the stat, never the picture.
|
||||||
|
pub(crate) fn wait_timeline(&self, sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||||||
|
let sems = [sem as pf_ffvk::VkSemaphore];
|
||||||
|
let values = [value];
|
||||||
|
let info = pf_ffvk::VkSemaphoreWaitInfo {
|
||||||
|
sType: pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO,
|
||||||
|
pNext: std::ptr::null(),
|
||||||
|
flags: 0,
|
||||||
|
semaphoreCount: 1,
|
||||||
|
pSemaphores: sems.as_ptr(),
|
||||||
|
pValues: values.as_ptr(),
|
||||||
|
};
|
||||||
|
// SAFETY: resolved from this device at init; handles outlive the decoder.
|
||||||
|
let r = unsafe {
|
||||||
|
self.wait_semaphores.expect("checked at init")(self.vk_device, &info, timeout_ns)
|
||||||
|
};
|
||||||
|
r == 0 // VK_SUCCESS (VK_TIMEOUT = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the
|
||||||
|
/// guard — keeps the image + frames context alive through present) and ship the
|
||||||
|
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||||
|
/// at its own submit time.
|
||||||
|
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
|
||||||
|
bail!("decoder returned a non-Vulkan frame");
|
||||||
|
}
|
||||||
|
let hwfc_ref = (*self.frame).hw_frames_ctx;
|
||||||
|
if hwfc_ref.is_null() {
|
||||||
|
bail!("Vulkan frame without a hardware frames context");
|
||||||
|
}
|
||||||
|
let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext;
|
||||||
|
let sw = (*fc).sw_format;
|
||||||
|
if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||||
|
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||||
|
{
|
||||||
|
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010 only)");
|
||||||
|
}
|
||||||
|
let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext;
|
||||||
|
let vk_format = (*vkfc).format[0] as i32;
|
||||||
|
let lock_frame = (*vkfc).lock_frame.map_or(0, |f| f as usize);
|
||||||
|
let unlock_frame = (*vkfc).unlock_frame.map_or(0, |f| f as usize);
|
||||||
|
if lock_frame == 0 || unlock_frame == 0 {
|
||||||
|
bail!("Vulkan frames context without lock functions");
|
||||||
|
}
|
||||||
|
|
||||||
|
let clone = ffi::av_frame_clone(self.frame);
|
||||||
|
if clone.is_null() {
|
||||||
|
bail!("av_frame_clone failed");
|
||||||
|
}
|
||||||
|
let vkf = (*clone).data[0] as *mut pf_ffvk::AVVkFrame;
|
||||||
|
// v1 handles the (default) single multiplanar image; a disjoint/multi-image
|
||||||
|
// pool would need per-plane images — bail so the session demotes cleanly.
|
||||||
|
if !(*vkf).img[1].is_null() {
|
||||||
|
let mut clone = clone;
|
||||||
|
ffi::av_frame_free(&mut clone);
|
||||||
|
bail!("multi-image Vulkan frames unsupported (disjoint pool)");
|
||||||
|
}
|
||||||
|
// Safe without the frames lock: the handle is creation-constant and
|
||||||
|
// sem_value was last written by the decode submission on THIS thread.
|
||||||
|
let timeline_sem = (*vkf).sem[0] as u64;
|
||||||
|
let decode_done_value = (*vkf).sem_value[0];
|
||||||
|
Ok(VkVideoFrame {
|
||||||
|
vkframe: vkf as usize,
|
||||||
|
frames_ctx: fc as usize,
|
||||||
|
lock_frame,
|
||||||
|
unlock_frame,
|
||||||
|
vk_format,
|
||||||
|
timeline_sem,
|
||||||
|
decode_done_value,
|
||||||
|
width: (*self.frame).width as u32,
|
||||||
|
height: (*self.frame).height as u32,
|
||||||
|
color: ColorDesc::from_raw(self.frame),
|
||||||
|
keyframe: frame_is_keyframe(self.frame),
|
||||||
|
guard: DrmFrameGuard(clone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VulkanDecoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
ffi::av_packet_free(&mut self.packet);
|
||||||
|
ffi::av_frame_free(&mut self.frame);
|
||||||
|
ffi::avcodec_free_context(&mut self.ctx);
|
||||||
|
ffi::av_buffer_unref(&mut self.hw_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// libavcodec offers the formats it can decode into; pick the Vulkan hw surface and
|
||||||
|
/// hand the decoder OUR frames context — the default one lacks
|
||||||
|
/// `VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT`, without which the presenter can't create the
|
||||||
|
/// per-plane views its CSC pass samples. Returning NONE (over the software entry) keeps
|
||||||
|
/// failures loud: the session demotes explicitly instead of silently CPU-decoding.
|
||||||
|
unsafe extern "C" fn pick_vulkan(
|
||||||
|
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||||
|
) -> ffmpeg::ffi::AVPixelFormat {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let mut offered = false;
|
||||||
|
while *list != ffi::AVPixelFormat::AV_PIX_FMT_NONE {
|
||||||
|
if *list == ffi::AVPixelFormat::AV_PIX_FMT_VULKAN {
|
||||||
|
offered = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
list = list.add(1);
|
||||||
|
}
|
||||||
|
if !offered {
|
||||||
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
|
}
|
||||||
|
let mut fr: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||||
|
let r = ffi::avcodec_get_hw_frames_parameters(
|
||||||
|
ctx,
|
||||||
|
(*ctx).hw_device_ctx,
|
||||||
|
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN,
|
||||||
|
&mut fr,
|
||||||
|
);
|
||||||
|
if r < 0 || fr.is_null() {
|
||||||
|
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
|
||||||
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
|
}
|
||||||
|
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||||
|
let vkfc = (*fc).hwctx as *mut pf_ffvk::AVVulkanFramesContext;
|
||||||
|
// MUTABLE_FORMAT: per-plane views (spec requirement); ALIAS is FFmpeg's default.
|
||||||
|
// (`as _`: the FlagBits constants are i32 under MSVC, the img_flags field u32.)
|
||||||
|
(*vkfc).img_flags = (pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT
|
||||||
|
| pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_ALIAS_BIT)
|
||||||
|
as _;
|
||||||
|
let r = ffi::av_hwframe_ctx_init(fr);
|
||||||
|
if r < 0 {
|
||||||
|
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
|
||||||
|
let mut fr = fr;
|
||||||
|
ffi::av_buffer_unref(&mut fr);
|
||||||
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
|
}
|
||||||
|
if !(*ctx).hw_frames_ctx.is_null() {
|
||||||
|
ffi::av_buffer_unref(&mut (*ctx).hw_frames_ctx);
|
||||||
|
}
|
||||||
|
(*ctx).hw_frames_ctx = fr; // the codec owns our ref now
|
||||||
|
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,22 +9,22 @@
|
|||||||
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
|
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
|
||||||
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
|
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
|
||||||
|
|
||||||
use crate::anim::{approach, ease_out_cubic, Progress};
|
use crate::anim::Progress;
|
||||||
use crate::glyphs::{hint_bar, GlyphStyle, Hint, HintKey};
|
use crate::glyphs::GlyphStyle;
|
||||||
use crate::library::{mesh_sksl, LibraryShared};
|
use crate::library::{mesh_sksl, LibraryShared};
|
||||||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||||
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
||||||
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||||||
use pf_client_core::trust;
|
use pf_client_core::trust;
|
||||||
use pf_presenter::overlay::OverlayAction;
|
use pf_presenter::overlay::OverlayAction;
|
||||||
use skia_safe::{
|
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
||||||
gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode,
|
|
||||||
};
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
mod overlays;
|
||||||
|
mod render;
|
||||||
|
|
||||||
const TRANSITION_S: f64 = 0.26;
|
const TRANSITION_S: f64 = 0.26;
|
||||||
/// Chrome bands (design units): the pinned title above, hints below.
|
/// Chrome bands (design units): the pinned title above, hints below.
|
||||||
const TOP_BAND: f64 = 64.0;
|
const TOP_BAND: f64 = 64.0;
|
||||||
@@ -425,176 +425,6 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rendering -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(crate) fn render(
|
|
||||||
&mut self,
|
|
||||||
canvas: &Canvas,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
fonts: &Fonts,
|
|
||||||
pad: Option<&str>,
|
|
||||||
pad_pref: Option<punktfunk_core::config::GamepadPref>,
|
|
||||||
pads: &[PadInfo],
|
|
||||||
) {
|
|
||||||
let now = Instant::now();
|
|
||||||
let dt = self
|
|
||||||
.last_frame
|
|
||||||
.replace(now)
|
|
||||||
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
|
|
||||||
self.sync();
|
|
||||||
self.pads = pads.to_vec();
|
|
||||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
|
||||||
self.chip = Some(pad.map_or_else(
|
|
||||||
|| "No controller — keyboard works too".to_string(),
|
|
||||||
str::to_owned,
|
|
||||||
));
|
|
||||||
|
|
||||||
let (w, h) = (f64::from(width), f64::from(height));
|
|
||||||
let k = (h / 800.0).clamp(0.75, 3.0);
|
|
||||||
let t = self.t();
|
|
||||||
|
|
||||||
// Advance the transition; a finished pop finally drops its leaving screen.
|
|
||||||
let motion_p = match &mut self.motion {
|
|
||||||
Motion::None => None,
|
|
||||||
Motion::Push(p) => {
|
|
||||||
p.advance(dt);
|
|
||||||
let v = p.value();
|
|
||||||
if p.done() {
|
|
||||||
self.motion = Motion::None;
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Motion::Pop { t, .. } => {
|
|
||||||
t.advance(dt);
|
|
||||||
let v = t.value();
|
|
||||||
if t.done() {
|
|
||||||
self.motion = Motion::None;
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Backdrop crossfade follows the top screen.
|
|
||||||
let bg_target = match self.stack.last().expect("non-empty").background() {
|
|
||||||
Bg::Aurora => 0.0,
|
|
||||||
Bg::Form => 1.0,
|
|
||||||
};
|
|
||||||
self.bg_mix = approach(self.bg_mix, bg_target, dt, 0.12);
|
|
||||||
if (self.bg_mix - bg_target).abs() < 0.005 {
|
|
||||||
self.bg_mix = bg_target;
|
|
||||||
}
|
|
||||||
if self.bg_mix < 1.0 {
|
|
||||||
self.draw_aurora(canvas, w, h, t);
|
|
||||||
} else {
|
|
||||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
|
||||||
}
|
|
||||||
if self.bg_mix > 0.0 {
|
|
||||||
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
|
|
||||||
crate::theme::draw_form_background(canvas, w, h);
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The screens, through the transition choreography.
|
|
||||||
let content = Rect::from_ltrb(
|
|
||||||
0.0,
|
|
||||||
(TOP_BAND * k) as f32,
|
|
||||||
w as f32,
|
|
||||||
(h - BOTTOM_BAND * k) as f32,
|
|
||||||
);
|
|
||||||
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
|
|
||||||
// disjoint fields of `self` per call, so the borrow checker stays happy.
|
|
||||||
let mut env = LayerEnv {
|
|
||||||
canvas,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
content,
|
|
||||||
k,
|
|
||||||
dt,
|
|
||||||
fonts,
|
|
||||||
hosts: &self.hosts,
|
|
||||||
library: &self.library,
|
|
||||||
settings: &mut self.settings,
|
|
||||||
pads: &self.pads,
|
|
||||||
deck: self.deck,
|
|
||||||
device_name: &self.device_name,
|
|
||||||
t,
|
|
||||||
glyphs: self.glyphs,
|
|
||||||
// A modal card owns B/A while it's up — the screen's legend would lie.
|
|
||||||
show_hints: self.connecting.is_none() && self.wake.is_none(),
|
|
||||||
};
|
|
||||||
match (&mut self.motion, motion_p) {
|
|
||||||
(Motion::Push(_), Some(raw)) => {
|
|
||||||
let p = ease_out_cubic(raw);
|
|
||||||
let n = self.stack.len();
|
|
||||||
// Outgoing recedes underneath…
|
|
||||||
if n >= 2 {
|
|
||||||
let (below, top) = self.stack.split_at_mut(n - 1);
|
|
||||||
env.paint(&mut below[n - 2], 1.0 - p, 0.0, 1.0 - 0.04 * p);
|
|
||||||
// …while the incoming slides up out of a fade.
|
|
||||||
env.paint(&mut top[0], p, 36.0 * k * (1.0 - p), 0.985 + 0.015 * p);
|
|
||||||
} else {
|
|
||||||
env.paint(
|
|
||||||
&mut self.stack[0],
|
|
||||||
p,
|
|
||||||
36.0 * k * (1.0 - p),
|
|
||||||
0.985 + 0.015 * p,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Motion::Pop { leaving, .. }, Some(raw)) => {
|
|
||||||
let p = ease_out_cubic(raw);
|
|
||||||
// The revealed screen grows back in…
|
|
||||||
let n = self.stack.len();
|
|
||||||
env.paint(&mut self.stack[n - 1], 0.4 + 0.6 * p, 0.0, 0.96 + 0.04 * p);
|
|
||||||
// …while the leaving one slides down into a fade.
|
|
||||||
env.paint(leaving.as_mut(), 1.0 - p, 36.0 * k * p, 1.0);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let n = self.stack.len();
|
|
||||||
env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistent chrome: the controller chip (top-right, above every layer).
|
|
||||||
if let Some(chip) = &self.chip {
|
|
||||||
let size = 12.0 * k;
|
|
||||||
let tw = f64::from(fonts.measure(chip, W::Medium, size));
|
|
||||||
let (bh, pad_x) = (24.0 * k, 12.0 * k);
|
|
||||||
let bx = w - 24.0 * k - tw - 2.0 * pad_x;
|
|
||||||
let rect = Rect::from_xywh(
|
|
||||||
bx as f32,
|
|
||||||
(18.0 * k) as f32,
|
|
||||||
(tw + 2.0 * pad_x) as f32,
|
|
||||||
bh as f32,
|
|
||||||
);
|
|
||||||
crate::theme::panel(
|
|
||||||
canvas,
|
|
||||||
rect,
|
|
||||||
(bh / 2.0 / k) as f32,
|
|
||||||
None,
|
|
||||||
PanelStroke::Plain(0.12),
|
|
||||||
k as f32,
|
|
||||||
);
|
|
||||||
fonts.draw(
|
|
||||||
canvas,
|
|
||||||
chip,
|
|
||||||
bx + pad_x,
|
|
||||||
18.0 * k + 16.0 * k,
|
|
||||||
W::Medium,
|
|
||||||
size,
|
|
||||||
white(0.7),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.draw_overlays(canvas, w, h, k, dt, t, fonts);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
||||||
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
||||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
||||||
@@ -609,604 +439,7 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Overlays (connecting / waking / toast) --------------------------------------------
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn draw_overlays(
|
|
||||||
&mut self,
|
|
||||||
canvas: &Canvas,
|
|
||||||
w: f64,
|
|
||||||
h: f64,
|
|
||||||
k: f64,
|
|
||||||
dt: f64,
|
|
||||||
t: f64,
|
|
||||||
fonts: &Fonts,
|
|
||||||
) {
|
|
||||||
// Resolve the connect/wake takeover — the two phases of reaching a host — into one
|
|
||||||
// full-screen shape (spinner, title, one detail line, its own hints). Connecting flows
|
|
||||||
// straight out of a wake (see `sync`) so they share the same backdrop and never blink
|
|
||||||
// between them. Mirrors the Android client's unified `ConnectOverlay`.
|
|
||||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
|
||||||
if let Some(c) = &mut self.connecting {
|
|
||||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
|
||||||
if c.canceling {
|
|
||||||
Some((
|
|
||||||
c.appear,
|
|
||||||
true,
|
|
||||||
"Canceling…".to_string(),
|
|
||||||
String::new(),
|
|
||||||
vec![],
|
|
||||||
))
|
|
||||||
} else if c.request_access {
|
|
||||||
Some((
|
|
||||||
c.appear,
|
|
||||||
true,
|
|
||||||
"Waiting for approval…".to_string(),
|
|
||||||
format!(
|
|
||||||
"Approve this device in {}'s console or web UI — no PIN needed.",
|
|
||||||
c.title
|
|
||||||
),
|
|
||||||
vec![Hint::new(HintKey::Back, "Cancel")],
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Some((
|
|
||||||
c.appear,
|
|
||||||
true,
|
|
||||||
format!("Connecting to {}…", c.title),
|
|
||||||
"Starting the stream in this window.".to_string(),
|
|
||||||
vec![Hint::new(HintKey::Back, "Cancel")],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} else if let Some(wk) = &self.wake {
|
|
||||||
// Service-driven, so it appears settled (no fade-in).
|
|
||||||
if wk.timed_out {
|
|
||||||
Some((
|
|
||||||
1.0,
|
|
||||||
false,
|
|
||||||
format!("{} didn't wake", wk.name),
|
|
||||||
"Check its power settings, or wake it manually and try again.".to_string(),
|
|
||||||
vec![
|
|
||||||
Hint::new(HintKey::Confirm, "Try Again"),
|
|
||||||
Hint::new(HintKey::Back, "Cancel"),
|
|
||||||
],
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Some((
|
|
||||||
1.0,
|
|
||||||
true,
|
|
||||||
format!("Waking {}…", wk.name),
|
|
||||||
format!("Waiting for it to come online · {} s", wk.seconds),
|
|
||||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect
|
|
||||||
// is a plain "Cancel".
|
|
||||||
vec![Hint::new(
|
|
||||||
HintKey::Back,
|
|
||||||
if wk.then_connect {
|
|
||||||
"Cancel"
|
|
||||||
} else {
|
|
||||||
"Stop Waiting"
|
|
||||||
},
|
|
||||||
)],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some((appear, spinner, title, body, hints)) = takeover {
|
|
||||||
self.draw_takeover(
|
|
||||||
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The toast: a transient pill above the hint bar; slides in, fades out.
|
|
||||||
if self.toast.as_ref().is_some_and(|toast| t - toast.at > 4.0) {
|
|
||||||
self.toast = None;
|
|
||||||
}
|
|
||||||
if let Some(toast) = &self.toast {
|
|
||||||
let age = t - toast.at;
|
|
||||||
{
|
|
||||||
let slide = ease_out_cubic((age / 0.25).min(1.0));
|
|
||||||
let fade = if age > 3.4 {
|
|
||||||
(1.0 - (age - 3.4) / 0.6).max(0.0)
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
let alpha = (slide * fade) as f32;
|
|
||||||
let size = 13.0 * k;
|
|
||||||
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
|
|
||||||
let (pad_x, bh) = (16.0 * k, 34.0 * k);
|
|
||||||
let bw = tw + 2.0 * pad_x;
|
|
||||||
let bx = (w - bw) / 2.0;
|
|
||||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
|
||||||
canvas.save_layer_alpha_f(None, alpha);
|
|
||||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
|
||||||
canvas.draw_rrect(
|
|
||||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
|
||||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None),
|
|
||||||
);
|
|
||||||
crate::theme::panel(
|
|
||||||
canvas,
|
|
||||||
rect,
|
|
||||||
(bh / 2.0 / k) as f32,
|
|
||||||
None,
|
|
||||||
PanelStroke::Plain(0.14),
|
|
||||||
k as f32,
|
|
||||||
);
|
|
||||||
fonts.draw(
|
|
||||||
canvas,
|
|
||||||
&toast.text,
|
|
||||||
bx + pad_x,
|
|
||||||
by + bh / 2.0 + size * 0.36,
|
|
||||||
W::Medium,
|
|
||||||
size,
|
|
||||||
white(0.92),
|
|
||||||
);
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything one screen layer needs to paint — bundled so the transition arms stay
|
|
||||||
/// readable and each `paint` call borrows `Shell` fields disjointly.
|
|
||||||
struct LayerEnv<'a> {
|
|
||||||
canvas: &'a Canvas,
|
|
||||||
w: f64,
|
|
||||||
h: f64,
|
|
||||||
content: Rect,
|
|
||||||
k: f64,
|
|
||||||
dt: f64,
|
|
||||||
fonts: &'a Fonts,
|
|
||||||
hosts: &'a [HostRow],
|
|
||||||
library: &'a LibraryShared,
|
|
||||||
settings: &'a mut trust::Settings,
|
|
||||||
pads: &'a [PadInfo],
|
|
||||||
deck: bool,
|
|
||||||
device_name: &'a str,
|
|
||||||
t: f64,
|
|
||||||
glyphs: GlyphStyle,
|
|
||||||
show_hints: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LayerEnv<'_> {
|
|
||||||
/// One screen composited as a unit: `alpha` fade, `dy` vertical slide, `scale`
|
|
||||||
/// about the screen center — its pinned title and hint bar ride inside the layer,
|
|
||||||
/// so chrome travels with content through a transition.
|
|
||||||
fn paint(&mut self, screen: &mut Screen, alpha: f64, dy: f64, scale: f64) {
|
|
||||||
let canvas = self.canvas;
|
|
||||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
|
||||||
canvas.translate((0.0, dy as f32));
|
|
||||||
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
|
|
||||||
canvas.translate((cx, cy));
|
|
||||||
canvas.scale((scale as f32, scale as f32));
|
|
||||||
canvas.translate((-cx, -cy));
|
|
||||||
|
|
||||||
let mut ctx = Ctx {
|
|
||||||
hosts: self.hosts,
|
|
||||||
library: self.library,
|
|
||||||
settings: self.settings,
|
|
||||||
pads: self.pads,
|
|
||||||
deck: self.deck,
|
|
||||||
device_name: self.device_name,
|
|
||||||
t: self.t,
|
|
||||||
};
|
|
||||||
self.fonts.centered(
|
|
||||||
canvas,
|
|
||||||
&screen.title(&ctx),
|
|
||||||
W::Bold,
|
|
||||||
30.0 * self.k,
|
|
||||||
WHITE,
|
|
||||||
self.w / 2.0,
|
|
||||||
18.0 * self.k,
|
|
||||||
self.w * 0.7,
|
|
||||||
);
|
|
||||||
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
|
|
||||||
if self.show_hints {
|
|
||||||
let hints = screen.hints(&ctx);
|
|
||||||
hint_bar(
|
|
||||||
canvas,
|
|
||||||
self.fonts,
|
|
||||||
&hints,
|
|
||||||
self.glyphs,
|
|
||||||
18.0 * self.k,
|
|
||||||
self.h - 18.0 * self.k,
|
|
||||||
self.k,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Shell {
|
|
||||||
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
|
|
||||||
/// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
|
|
||||||
/// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home;
|
|
||||||
/// a wake that hands off to a connect passes 1.0 so the two never blink between them. The
|
|
||||||
/// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a
|
|
||||||
/// centered modal card.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn draw_takeover(
|
|
||||||
&self,
|
|
||||||
canvas: &Canvas,
|
|
||||||
w: f64,
|
|
||||||
h: f64,
|
|
||||||
k: f64,
|
|
||||||
appear: f64,
|
|
||||||
t: f64,
|
|
||||||
fonts: &Fonts,
|
|
||||||
spinner: bool,
|
|
||||||
title: &str,
|
|
||||||
body: &str,
|
|
||||||
hints: &[Hint],
|
|
||||||
) {
|
|
||||||
let cx = w / 2.0;
|
|
||||||
canvas.save_layer_alpha_f(None, appear as f32);
|
|
||||||
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
|
||||||
// console taking over rather than a card popping up.
|
|
||||||
self.draw_aurora(canvas, w, h, t);
|
|
||||||
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
|
||||||
let mut vignette = Paint::default();
|
|
||||||
vignette.set_shader(gradient_shader::radial(
|
|
||||||
Point::new(cx as f32, (h / 2.0) as f32),
|
|
||||||
(w.max(h) * 0.42) as f32,
|
|
||||||
gradient_shader::GradientShaderColors::Colors(&[
|
|
||||||
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
|
|
||||||
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
|
||||||
]),
|
|
||||||
None,
|
|
||||||
TileMode::Clamp,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
|
|
||||||
|
|
||||||
// Centre the spinner + title + detail as a group around the middle of the screen.
|
|
||||||
let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 };
|
|
||||||
if spinner {
|
|
||||||
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
|
|
||||||
}
|
|
||||||
fonts.centered(
|
|
||||||
canvas,
|
|
||||||
title,
|
|
||||||
W::SemiBold,
|
|
||||||
23.0 * k,
|
|
||||||
WHITE,
|
|
||||||
cx,
|
|
||||||
title_y,
|
|
||||||
w * 0.82,
|
|
||||||
);
|
|
||||||
if !body.is_empty() {
|
|
||||||
fonts.centered(
|
|
||||||
canvas,
|
|
||||||
body,
|
|
||||||
W::Regular,
|
|
||||||
14.0 * k,
|
|
||||||
DIM,
|
|
||||||
cx,
|
|
||||||
title_y + 32.0 * k,
|
|
||||||
w * 0.66,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !hints.is_empty() {
|
|
||||||
// Centered near the bottom, where every console screen's legend sits.
|
|
||||||
let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
|
|
||||||
hint_bar(
|
|
||||||
canvas,
|
|
||||||
fonts,
|
|
||||||
hints,
|
|
||||||
self.glyphs,
|
|
||||||
cx - probe.0 / 2.0,
|
|
||||||
h - 34.0 * k,
|
|
||||||
k,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::model::WakeStatus;
|
|
||||||
use crate::screens::home::HomeScreen;
|
|
||||||
use crate::screens::library::LibraryScreen;
|
|
||||||
use punktfunk_core::config::GamepadPref;
|
|
||||||
|
|
||||||
/// Point the settings/known-hosts stores at a throwaway HOME — the settings screen
|
|
||||||
/// SAVES on adjust, and a test must never write the developer's real config.
|
|
||||||
fn fake_home() {
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
|
||||||
let dir = HOME.get_or_init(|| {
|
|
||||||
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::env::set_var("HOME", &dir);
|
|
||||||
dir.clone()
|
|
||||||
});
|
|
||||||
std::env::set_var("HOME", dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hosts() -> Vec<HostRow> {
|
|
||||||
let base = HostRow {
|
|
||||||
key: String::new(),
|
|
||||||
name: String::new(),
|
|
||||||
addr: "10.0.0.20".into(),
|
|
||||||
port: 9777,
|
|
||||||
fp_hex: String::new(),
|
|
||||||
paired: false,
|
|
||||||
saved: true,
|
|
||||||
online: false,
|
|
||||||
mgmt_port: 47990,
|
|
||||||
can_wake: false,
|
|
||||||
last_used: None,
|
|
||||||
};
|
|
||||||
vec![
|
|
||||||
HostRow {
|
|
||||||
key: "aa11".into(),
|
|
||||||
name: "Living Room PC".into(),
|
|
||||||
fp_hex: "aa11".into(),
|
|
||||||
paired: true,
|
|
||||||
online: true,
|
|
||||||
last_used: Some(1),
|
|
||||||
..base.clone()
|
|
||||||
},
|
|
||||||
HostRow {
|
|
||||||
key: "bb22".into(),
|
|
||||||
name: "Office Tower".into(),
|
|
||||||
addr: "10.0.0.21".into(),
|
|
||||||
fp_hex: "bb22".into(),
|
|
||||||
paired: true,
|
|
||||||
can_wake: true,
|
|
||||||
..base.clone()
|
|
||||||
},
|
|
||||||
HostRow {
|
|
||||||
key: "10.0.0.30:9777".into(),
|
|
||||||
name: "steambox".into(),
|
|
||||||
addr: "10.0.0.30".into(),
|
|
||||||
saved: false,
|
|
||||||
online: true,
|
|
||||||
..base
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shell(stack: Vec<Screen>) -> (Shell, ConsoleShared, LibraryShared) {
|
|
||||||
fake_home();
|
|
||||||
let console = ConsoleShared::default();
|
|
||||||
console.set_hosts(hosts());
|
|
||||||
let library = LibraryShared::default();
|
|
||||||
let bus = ConsoleBus::default();
|
|
||||||
let shell = Shell::new(
|
|
||||||
console.clone(),
|
|
||||||
library.clone(),
|
|
||||||
bus,
|
|
||||||
ConsoleOptions {
|
|
||||||
device_name: "deck".into(),
|
|
||||||
deck: false,
|
|
||||||
},
|
|
||||||
stack,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
(shell, console, library)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shell survives a full navigation lap (a smoke test over every screen's
|
|
||||||
/// input handling — no rendering, no GPU).
|
|
||||||
#[test]
|
|
||||||
fn navigation_lap() {
|
|
||||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
|
||||||
s.sync();
|
|
||||||
// Home → Settings (X), adjust something, back out.
|
|
||||||
s.handle_menu(MenuEvent::Tertiary);
|
|
||||||
assert_eq!(s.stack.len(), 2);
|
|
||||||
finish_motion(&mut s);
|
|
||||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
|
||||||
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
finish_motion(&mut s);
|
|
||||||
assert_eq!(s.stack.len(), 1);
|
|
||||||
// Home → Library on the paired host (Y), then back.
|
|
||||||
s.handle_menu(MenuEvent::Secondary);
|
|
||||||
assert_eq!(s.stack.len(), 2);
|
|
||||||
finish_motion(&mut s);
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
finish_motion(&mut s);
|
|
||||||
assert_eq!(s.stack.len(), 1);
|
|
||||||
// B at the root quits.
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
assert!(matches!(s.take_action(), Some(OverlayAction::Quit)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_flow_raises_launch_and_cancel() {
|
|
||||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
|
||||||
s.sync();
|
|
||||||
s.handle_menu(MenuEvent::Confirm); // paired+online host focused first
|
|
||||||
assert!(matches!(
|
|
||||||
s.take_action(),
|
|
||||||
Some(OverlayAction::Launch { launch: None, .. })
|
|
||||||
));
|
|
||||||
assert!(s.connecting.is_some());
|
|
||||||
// While connecting: B cancels exactly once.
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
assert!(matches!(
|
|
||||||
s.take_action(),
|
|
||||||
Some(OverlayAction::CancelConnect)
|
|
||||||
));
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
assert!(s.take_action().is_none(), "cancel is idempotent");
|
|
||||||
// The canceled dial ends silently.
|
|
||||||
s.session_ended(None);
|
|
||||||
assert!(s.connecting.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_motion(s: &mut Shell) {
|
|
||||||
// Transitions block input; tests fast-forward them.
|
|
||||||
s.motion = Motion::None;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wake_gates_input_in_the_same_press() {
|
|
||||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
|
||||||
s.sync();
|
|
||||||
// Focus "Office Tower" (offline + wakeable), then A: the wake starts.
|
|
||||||
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
|
||||||
s.handle_menu(MenuEvent::Confirm);
|
|
||||||
let w = s
|
|
||||||
.wake
|
|
||||||
.as_ref()
|
|
||||||
.expect("Waking card raised in the SAME call as the A press");
|
|
||||||
assert_eq!(w.name, "Office Tower");
|
|
||||||
assert!(!w.online);
|
|
||||||
// The very next input is modal-gated — the cursor can't drift onto Add Host —
|
|
||||||
// and sync (which runs first in handle_menu) must not clear the placeholder
|
|
||||||
// before the service thread reports its first real status.
|
|
||||||
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Right)).is_none());
|
|
||||||
assert!(
|
|
||||||
s.wake.is_some(),
|
|
||||||
"optimistic card survived a sync with no service status"
|
|
||||||
);
|
|
||||||
// B cancels: the gate releases and navigation works again.
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
assert!(s.wake.is_none());
|
|
||||||
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
|
|
||||||
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
|
|
||||||
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
|
|
||||||
#[test]
|
|
||||||
#[ignore]
|
|
||||||
fn dump_console_screens() {
|
|
||||||
let dir = std::env::var("PF_CONSOLE_DUMP").expect("set PF_CONSOLE_DUMP to an output dir");
|
|
||||||
let fonts = crate::theme::build_fonts().unwrap();
|
|
||||||
let (w, h) = (1280, 800);
|
|
||||||
let pads: Vec<PadInfo> = Vec::new();
|
|
||||||
let dump = |shell: &mut Shell, frames: usize, sleep_ms: u64, name: &str, pad: bool| {
|
|
||||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
|
||||||
for _ in 0..frames {
|
|
||||||
shell.render(
|
|
||||||
surface.canvas(),
|
|
||||||
w as u32,
|
|
||||||
h as u32,
|
|
||||||
&fonts,
|
|
||||||
pad.then_some("Xbox Wireless Controller"),
|
|
||||||
pad.then_some(GamepadPref::Xbox360),
|
|
||||||
&pads,
|
|
||||||
);
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
|
|
||||||
}
|
|
||||||
let png = surface
|
|
||||||
.image_snapshot()
|
|
||||||
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(format!("{dir}/{name}.png"), png.as_bytes()).unwrap();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Home, settled, with a pad (Letters glyphs).
|
|
||||||
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
|
||||||
dump(&mut s, 40, 8, "01-home", true);
|
|
||||||
|
|
||||||
// Mid-push into Settings (the transition still): a couple of fast frames land
|
|
||||||
// the capture around p ≈ 0.4 — both layers visible.
|
|
||||||
s.handle_menu(MenuEvent::Tertiary);
|
|
||||||
dump(&mut s, 3, 25, "02-transition", true);
|
|
||||||
dump(&mut s, 40, 8, "03-settings", true);
|
|
||||||
|
|
||||||
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
|
|
||||||
s.handle_menu(MenuEvent::Back);
|
|
||||||
dump(&mut s, 40, 8, "_back", true);
|
|
||||||
for _ in 0..3 {
|
|
||||||
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
|
||||||
}
|
|
||||||
s.handle_menu(MenuEvent::Confirm); // Add Host screen
|
|
||||||
dump(&mut s, 40, 8, "04-addhost", false);
|
|
||||||
s.handle_menu(MenuEvent::Confirm); // open the Name keyboard
|
|
||||||
for ev in [
|
|
||||||
MenuEvent::Move(MenuDir::Down),
|
|
||||||
MenuEvent::Confirm,
|
|
||||||
MenuEvent::Confirm,
|
|
||||||
] {
|
|
||||||
s.handle_menu(ev);
|
|
||||||
}
|
|
||||||
dump(&mut s, 40, 8, "05-addhost-keyboard", false);
|
|
||||||
|
|
||||||
// Pair (focused on the unpaired discovered host).
|
|
||||||
s.handle_menu(MenuEvent::Back); // close keyboard
|
|
||||||
s.handle_menu(MenuEvent::Back); // leave add-host
|
|
||||||
dump(&mut s, 40, 8, "_back2", true);
|
|
||||||
s.handle_menu(MenuEvent::Move(MenuDir::Left)); // onto "steambox"
|
|
||||||
s.handle_menu(MenuEvent::Confirm);
|
|
||||||
dump(&mut s, 40, 8, "06-pair", true);
|
|
||||||
|
|
||||||
// Library with placeholder posters.
|
|
||||||
library.set_games(
|
|
||||||
[
|
|
||||||
"Hades II",
|
|
||||||
"Elden Ring",
|
|
||||||
"Hollow Knight",
|
|
||||||
"Baldur's Gate 3",
|
|
||||||
"Celeste",
|
|
||||||
"Deep Rock Galactic",
|
|
||||||
"Portal 2",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, t)| crate::library::LibraryGame {
|
|
||||||
id: format!("steam:{i}"),
|
|
||||||
title: (*t).to_string(),
|
|
||||||
store: "steam".into(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
let (mut s2, _c2, _l2) = {
|
|
||||||
let console2 = ConsoleShared::default();
|
|
||||||
console2.set_hosts(hosts());
|
|
||||||
let bus = ConsoleBus::default();
|
|
||||||
let sh = Shell::new(
|
|
||||||
console2.clone(),
|
|
||||||
library.clone(),
|
|
||||||
bus,
|
|
||||||
ConsoleOptions {
|
|
||||||
device_name: "deck".into(),
|
|
||||||
deck: false,
|
|
||||||
},
|
|
||||||
vec![
|
|
||||||
Screen::Home(HomeScreen::new()),
|
|
||||||
Screen::Library(LibraryScreen::new(&hosts()[0])),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
(sh, console2, library.clone())
|
|
||||||
};
|
|
||||||
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
|
||||||
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
|
||||||
dump(&mut s2, 40, 8, "07-library", true);
|
|
||||||
|
|
||||||
// The wake and connecting overlays + a toast.
|
|
||||||
console.set_wake(Some(WakeStatus {
|
|
||||||
key: "bb22".into(),
|
|
||||||
name: "Office Tower".into(),
|
|
||||||
seconds: 12,
|
|
||||||
timed_out: false,
|
|
||||||
online: false,
|
|
||||||
then_connect: true,
|
|
||||||
}));
|
|
||||||
dump(&mut s, 10, 8, "08-waking", true);
|
|
||||||
console.set_wake(Some(WakeStatus {
|
|
||||||
key: "bb22".into(),
|
|
||||||
name: "Office Tower".into(),
|
|
||||||
seconds: 90,
|
|
||||||
timed_out: true,
|
|
||||||
online: false,
|
|
||||||
then_connect: true,
|
|
||||||
}));
|
|
||||||
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
|
|
||||||
console.set_wake(None);
|
|
||||||
s.set_connecting(Some("Elden Ring".into()));
|
|
||||||
dump(&mut s, 10, 8, "09-connecting", true);
|
|
||||||
s.set_connecting(None);
|
|
||||||
s.session_failed("Connection timed out");
|
|
||||||
dump(&mut s, 10, 8, "10-toast", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! The console shell's modal overlays (connecting / waking / toast / full-screen takeover).
|
||||||
|
|
||||||
|
use crate::anim::{approach, ease_out_cubic};
|
||||||
|
use crate::glyphs::{hint_bar, Hint, HintKey};
|
||||||
|
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
|
||||||
|
use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode};
|
||||||
|
|
||||||
|
use super::{Shell, BOTTOM_BAND};
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(in crate::shell) fn draw_overlays(
|
||||||
|
&mut self,
|
||||||
|
canvas: &Canvas,
|
||||||
|
w: f64,
|
||||||
|
h: f64,
|
||||||
|
k: f64,
|
||||||
|
dt: f64,
|
||||||
|
t: f64,
|
||||||
|
fonts: &Fonts,
|
||||||
|
) {
|
||||||
|
// Resolve the connect/wake takeover — the two phases of reaching a host — into one
|
||||||
|
// full-screen shape (spinner, title, one detail line, its own hints). Connecting flows
|
||||||
|
// straight out of a wake (see `sync`) so they share the same backdrop and never blink
|
||||||
|
// between them. Mirrors the Android client's unified `ConnectOverlay`.
|
||||||
|
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||||
|
if let Some(c) = &mut self.connecting {
|
||||||
|
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||||
|
if c.canceling {
|
||||||
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
"Canceling…".to_string(),
|
||||||
|
String::new(),
|
||||||
|
vec![],
|
||||||
|
))
|
||||||
|
} else if c.request_access {
|
||||||
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
"Waiting for approval…".to_string(),
|
||||||
|
format!(
|
||||||
|
"Approve this device in {}'s console or web UI — no PIN needed.",
|
||||||
|
c.title
|
||||||
|
),
|
||||||
|
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
format!("Connecting to {}…", c.title),
|
||||||
|
"Starting the stream in this window.".to_string(),
|
||||||
|
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else if let Some(wk) = &self.wake {
|
||||||
|
// Service-driven, so it appears settled (no fade-in).
|
||||||
|
if wk.timed_out {
|
||||||
|
Some((
|
||||||
|
1.0,
|
||||||
|
false,
|
||||||
|
format!("{} didn't wake", wk.name),
|
||||||
|
"Check its power settings, or wake it manually and try again.".to_string(),
|
||||||
|
vec![
|
||||||
|
Hint::new(HintKey::Confirm, "Try Again"),
|
||||||
|
Hint::new(HintKey::Back, "Cancel"),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some((
|
||||||
|
1.0,
|
||||||
|
true,
|
||||||
|
format!("Waking {}…", wk.name),
|
||||||
|
format!("Waiting for it to come online · {} s", wk.seconds),
|
||||||
|
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect
|
||||||
|
// is a plain "Cancel".
|
||||||
|
vec![Hint::new(
|
||||||
|
HintKey::Back,
|
||||||
|
if wk.then_connect {
|
||||||
|
"Cancel"
|
||||||
|
} else {
|
||||||
|
"Stop Waiting"
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some((appear, spinner, title, body, hints)) = takeover {
|
||||||
|
self.draw_takeover(
|
||||||
|
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The toast: a transient pill above the hint bar; slides in, fades out.
|
||||||
|
if self.toast.as_ref().is_some_and(|toast| t - toast.at > 4.0) {
|
||||||
|
self.toast = None;
|
||||||
|
}
|
||||||
|
if let Some(toast) = &self.toast {
|
||||||
|
let age = t - toast.at;
|
||||||
|
{
|
||||||
|
let slide = ease_out_cubic((age / 0.25).min(1.0));
|
||||||
|
let fade = if age > 3.4 {
|
||||||
|
(1.0 - (age - 3.4) / 0.6).max(0.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let alpha = (slide * fade) as f32;
|
||||||
|
let size = 13.0 * k;
|
||||||
|
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
|
||||||
|
let (pad_x, bh) = (16.0 * k, 34.0 * k);
|
||||||
|
let bw = tw + 2.0 * pad_x;
|
||||||
|
let bx = (w - bw) / 2.0;
|
||||||
|
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||||
|
canvas.save_layer_alpha_f(None, alpha);
|
||||||
|
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||||
|
canvas.draw_rrect(
|
||||||
|
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||||
|
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None),
|
||||||
|
);
|
||||||
|
crate::theme::panel(
|
||||||
|
canvas,
|
||||||
|
rect,
|
||||||
|
(bh / 2.0 / k) as f32,
|
||||||
|
None,
|
||||||
|
PanelStroke::Plain(0.14),
|
||||||
|
k as f32,
|
||||||
|
);
|
||||||
|
fonts.draw(
|
||||||
|
canvas,
|
||||||
|
&toast.text,
|
||||||
|
bx + pad_x,
|
||||||
|
by + bh / 2.0 + size * 0.36,
|
||||||
|
W::Medium,
|
||||||
|
size,
|
||||||
|
white(0.92),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
|
||||||
|
/// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
|
||||||
|
/// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home;
|
||||||
|
/// a wake that hands off to a connect passes 1.0 so the two never blink between them. The
|
||||||
|
/// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a
|
||||||
|
/// centered modal card.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn draw_takeover(
|
||||||
|
&self,
|
||||||
|
canvas: &Canvas,
|
||||||
|
w: f64,
|
||||||
|
h: f64,
|
||||||
|
k: f64,
|
||||||
|
appear: f64,
|
||||||
|
t: f64,
|
||||||
|
fonts: &Fonts,
|
||||||
|
spinner: bool,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
hints: &[Hint],
|
||||||
|
) {
|
||||||
|
let cx = w / 2.0;
|
||||||
|
canvas.save_layer_alpha_f(None, appear as f32);
|
||||||
|
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
||||||
|
// console taking over rather than a card popping up.
|
||||||
|
self.draw_aurora(canvas, w, h, t);
|
||||||
|
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
||||||
|
let mut vignette = Paint::default();
|
||||||
|
vignette.set_shader(gradient_shader::radial(
|
||||||
|
Point::new(cx as f32, (h / 2.0) as f32),
|
||||||
|
(w.max(h) * 0.42) as f32,
|
||||||
|
gradient_shader::GradientShaderColors::Colors(&[
|
||||||
|
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
|
||||||
|
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
||||||
|
]),
|
||||||
|
None,
|
||||||
|
TileMode::Clamp,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
|
||||||
|
|
||||||
|
// Centre the spinner + title + detail as a group around the middle of the screen.
|
||||||
|
let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 };
|
||||||
|
if spinner {
|
||||||
|
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
|
||||||
|
}
|
||||||
|
fonts.centered(
|
||||||
|
canvas,
|
||||||
|
title,
|
||||||
|
W::SemiBold,
|
||||||
|
23.0 * k,
|
||||||
|
WHITE,
|
||||||
|
cx,
|
||||||
|
title_y,
|
||||||
|
w * 0.82,
|
||||||
|
);
|
||||||
|
if !body.is_empty() {
|
||||||
|
fonts.centered(
|
||||||
|
canvas,
|
||||||
|
body,
|
||||||
|
W::Regular,
|
||||||
|
14.0 * k,
|
||||||
|
DIM,
|
||||||
|
cx,
|
||||||
|
title_y + 32.0 * k,
|
||||||
|
w * 0.66,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !hints.is_empty() {
|
||||||
|
// Centered near the bottom, where every console screen's legend sits.
|
||||||
|
let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
|
||||||
|
hint_bar(
|
||||||
|
canvas,
|
||||||
|
fonts,
|
||||||
|
hints,
|
||||||
|
self.glyphs,
|
||||||
|
cx - probe.0 / 2.0,
|
||||||
|
h - 34.0 * k,
|
||||||
|
k,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! The console shell's per-frame screen compose/transition render path.
|
||||||
|
|
||||||
|
use crate::anim::{approach, ease_out_cubic};
|
||||||
|
use crate::glyphs::{hint_bar, GlyphStyle};
|
||||||
|
use crate::library::LibraryShared;
|
||||||
|
use crate::model::HostRow;
|
||||||
|
use crate::screens::{Bg, Ctx, Screen};
|
||||||
|
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
|
||||||
|
use pf_client_core::gamepad::PadInfo;
|
||||||
|
use pf_client_core::trust;
|
||||||
|
use skia_safe::{Canvas, Color4f, Rect};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn render(
|
||||||
|
&mut self,
|
||||||
|
canvas: &Canvas,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fonts: &Fonts,
|
||||||
|
pad: Option<&str>,
|
||||||
|
pad_pref: Option<punktfunk_core::config::GamepadPref>,
|
||||||
|
pads: &[PadInfo],
|
||||||
|
) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let dt = self
|
||||||
|
.last_frame
|
||||||
|
.replace(now)
|
||||||
|
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
|
||||||
|
self.sync();
|
||||||
|
self.pads = pads.to_vec();
|
||||||
|
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||||
|
self.chip = Some(pad.map_or_else(
|
||||||
|
|| "No controller — keyboard works too".to_string(),
|
||||||
|
str::to_owned,
|
||||||
|
));
|
||||||
|
|
||||||
|
let (w, h) = (f64::from(width), f64::from(height));
|
||||||
|
let k = (h / 800.0).clamp(0.75, 3.0);
|
||||||
|
let t = self.t();
|
||||||
|
|
||||||
|
// Advance the transition; a finished pop finally drops its leaving screen.
|
||||||
|
let motion_p = match &mut self.motion {
|
||||||
|
Motion::None => None,
|
||||||
|
Motion::Push(p) => {
|
||||||
|
p.advance(dt);
|
||||||
|
let v = p.value();
|
||||||
|
if p.done() {
|
||||||
|
self.motion = Motion::None;
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Motion::Pop { t, .. } => {
|
||||||
|
t.advance(dt);
|
||||||
|
let v = t.value();
|
||||||
|
if t.done() {
|
||||||
|
self.motion = Motion::None;
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backdrop crossfade follows the top screen.
|
||||||
|
let bg_target = match self.stack.last().expect("non-empty").background() {
|
||||||
|
Bg::Aurora => 0.0,
|
||||||
|
Bg::Form => 1.0,
|
||||||
|
};
|
||||||
|
self.bg_mix = approach(self.bg_mix, bg_target, dt, 0.12);
|
||||||
|
if (self.bg_mix - bg_target).abs() < 0.005 {
|
||||||
|
self.bg_mix = bg_target;
|
||||||
|
}
|
||||||
|
if self.bg_mix < 1.0 {
|
||||||
|
self.draw_aurora(canvas, w, h, t);
|
||||||
|
} else {
|
||||||
|
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||||
|
}
|
||||||
|
if self.bg_mix > 0.0 {
|
||||||
|
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
|
||||||
|
crate::theme::draw_form_background(canvas, w, h);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The screens, through the transition choreography.
|
||||||
|
let content = Rect::from_ltrb(
|
||||||
|
0.0,
|
||||||
|
(TOP_BAND * k) as f32,
|
||||||
|
w as f32,
|
||||||
|
(h - BOTTOM_BAND * k) as f32,
|
||||||
|
);
|
||||||
|
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
|
||||||
|
// disjoint fields of `self` per call, so the borrow checker stays happy.
|
||||||
|
let mut env = LayerEnv {
|
||||||
|
canvas,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
content,
|
||||||
|
k,
|
||||||
|
dt,
|
||||||
|
fonts,
|
||||||
|
hosts: &self.hosts,
|
||||||
|
library: &self.library,
|
||||||
|
settings: &mut self.settings,
|
||||||
|
pads: &self.pads,
|
||||||
|
deck: self.deck,
|
||||||
|
device_name: &self.device_name,
|
||||||
|
t,
|
||||||
|
glyphs: self.glyphs,
|
||||||
|
// A modal card owns B/A while it's up — the screen's legend would lie.
|
||||||
|
show_hints: self.connecting.is_none() && self.wake.is_none(),
|
||||||
|
};
|
||||||
|
match (&mut self.motion, motion_p) {
|
||||||
|
(Motion::Push(_), Some(raw)) => {
|
||||||
|
let p = ease_out_cubic(raw);
|
||||||
|
let n = self.stack.len();
|
||||||
|
// Outgoing recedes underneath…
|
||||||
|
if n >= 2 {
|
||||||
|
let (below, top) = self.stack.split_at_mut(n - 1);
|
||||||
|
env.paint(&mut below[n - 2], 1.0 - p, 0.0, 1.0 - 0.04 * p);
|
||||||
|
// …while the incoming slides up out of a fade.
|
||||||
|
env.paint(&mut top[0], p, 36.0 * k * (1.0 - p), 0.985 + 0.015 * p);
|
||||||
|
} else {
|
||||||
|
env.paint(
|
||||||
|
&mut self.stack[0],
|
||||||
|
p,
|
||||||
|
36.0 * k * (1.0 - p),
|
||||||
|
0.985 + 0.015 * p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Motion::Pop { leaving, .. }, Some(raw)) => {
|
||||||
|
let p = ease_out_cubic(raw);
|
||||||
|
// The revealed screen grows back in…
|
||||||
|
let n = self.stack.len();
|
||||||
|
env.paint(&mut self.stack[n - 1], 0.4 + 0.6 * p, 0.0, 0.96 + 0.04 * p);
|
||||||
|
// …while the leaving one slides down into a fade.
|
||||||
|
env.paint(leaving.as_mut(), 1.0 - p, 36.0 * k * p, 1.0);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let n = self.stack.len();
|
||||||
|
env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent chrome: the controller chip (top-right, above every layer).
|
||||||
|
if let Some(chip) = &self.chip {
|
||||||
|
let size = 12.0 * k;
|
||||||
|
let tw = f64::from(fonts.measure(chip, W::Medium, size));
|
||||||
|
let (bh, pad_x) = (24.0 * k, 12.0 * k);
|
||||||
|
let bx = w - 24.0 * k - tw - 2.0 * pad_x;
|
||||||
|
let rect = Rect::from_xywh(
|
||||||
|
bx as f32,
|
||||||
|
(18.0 * k) as f32,
|
||||||
|
(tw + 2.0 * pad_x) as f32,
|
||||||
|
bh as f32,
|
||||||
|
);
|
||||||
|
crate::theme::panel(
|
||||||
|
canvas,
|
||||||
|
rect,
|
||||||
|
(bh / 2.0 / k) as f32,
|
||||||
|
None,
|
||||||
|
PanelStroke::Plain(0.12),
|
||||||
|
k as f32,
|
||||||
|
);
|
||||||
|
fonts.draw(
|
||||||
|
canvas,
|
||||||
|
chip,
|
||||||
|
bx + pad_x,
|
||||||
|
18.0 * k + 16.0 * k,
|
||||||
|
W::Medium,
|
||||||
|
size,
|
||||||
|
white(0.7),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw_overlays(canvas, w, h, k, dt, t, fonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one screen layer needs to paint — bundled so the transition arms stay
|
||||||
|
/// readable and each `paint` call borrows `Shell` fields disjointly.
|
||||||
|
struct LayerEnv<'a> {
|
||||||
|
canvas: &'a Canvas,
|
||||||
|
w: f64,
|
||||||
|
h: f64,
|
||||||
|
content: Rect,
|
||||||
|
k: f64,
|
||||||
|
dt: f64,
|
||||||
|
fonts: &'a Fonts,
|
||||||
|
hosts: &'a [HostRow],
|
||||||
|
library: &'a LibraryShared,
|
||||||
|
settings: &'a mut trust::Settings,
|
||||||
|
pads: &'a [PadInfo],
|
||||||
|
deck: bool,
|
||||||
|
device_name: &'a str,
|
||||||
|
t: f64,
|
||||||
|
glyphs: GlyphStyle,
|
||||||
|
show_hints: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerEnv<'_> {
|
||||||
|
/// One screen composited as a unit: `alpha` fade, `dy` vertical slide, `scale`
|
||||||
|
/// about the screen center — its pinned title and hint bar ride inside the layer,
|
||||||
|
/// so chrome travels with content through a transition.
|
||||||
|
fn paint(&mut self, screen: &mut Screen, alpha: f64, dy: f64, scale: f64) {
|
||||||
|
let canvas = self.canvas;
|
||||||
|
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||||
|
canvas.translate((0.0, dy as f32));
|
||||||
|
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
|
||||||
|
canvas.translate((cx, cy));
|
||||||
|
canvas.scale((scale as f32, scale as f32));
|
||||||
|
canvas.translate((-cx, -cy));
|
||||||
|
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
hosts: self.hosts,
|
||||||
|
library: self.library,
|
||||||
|
settings: self.settings,
|
||||||
|
pads: self.pads,
|
||||||
|
deck: self.deck,
|
||||||
|
device_name: self.device_name,
|
||||||
|
t: self.t,
|
||||||
|
};
|
||||||
|
self.fonts.centered(
|
||||||
|
canvas,
|
||||||
|
&screen.title(&ctx),
|
||||||
|
W::Bold,
|
||||||
|
30.0 * self.k,
|
||||||
|
WHITE,
|
||||||
|
self.w / 2.0,
|
||||||
|
18.0 * self.k,
|
||||||
|
self.w * 0.7,
|
||||||
|
);
|
||||||
|
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
|
||||||
|
if self.show_hints {
|
||||||
|
let hints = screen.hints(&ctx);
|
||||||
|
hint_bar(
|
||||||
|
canvas,
|
||||||
|
self.fonts,
|
||||||
|
&hints,
|
||||||
|
self.glyphs,
|
||||||
|
18.0 * self.k,
|
||||||
|
self.h - 18.0 * self.k,
|
||||||
|
self.k,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::model::WakeStatus;
|
||||||
|
use crate::screens::home::HomeScreen;
|
||||||
|
use crate::screens::library::LibraryScreen;
|
||||||
|
use punktfunk_core::config::GamepadPref;
|
||||||
|
|
||||||
|
/// Point the settings/known-hosts stores at a throwaway HOME — the settings screen
|
||||||
|
/// SAVES on adjust, and a test must never write the developer's real config.
|
||||||
|
fn fake_home() {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||||
|
let dir = HOME.get_or_init(|| {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::env::set_var("HOME", &dir);
|
||||||
|
dir.clone()
|
||||||
|
});
|
||||||
|
std::env::set_var("HOME", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hosts() -> Vec<HostRow> {
|
||||||
|
let base = HostRow {
|
||||||
|
key: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
addr: "10.0.0.20".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp_hex: String::new(),
|
||||||
|
paired: false,
|
||||||
|
saved: true,
|
||||||
|
online: false,
|
||||||
|
mgmt_port: 47990,
|
||||||
|
can_wake: false,
|
||||||
|
last_used: None,
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
HostRow {
|
||||||
|
key: "aa11".into(),
|
||||||
|
name: "Living Room PC".into(),
|
||||||
|
fp_hex: "aa11".into(),
|
||||||
|
paired: true,
|
||||||
|
online: true,
|
||||||
|
last_used: Some(1),
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
HostRow {
|
||||||
|
key: "bb22".into(),
|
||||||
|
name: "Office Tower".into(),
|
||||||
|
addr: "10.0.0.21".into(),
|
||||||
|
fp_hex: "bb22".into(),
|
||||||
|
paired: true,
|
||||||
|
can_wake: true,
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
HostRow {
|
||||||
|
key: "10.0.0.30:9777".into(),
|
||||||
|
name: "steambox".into(),
|
||||||
|
addr: "10.0.0.30".into(),
|
||||||
|
saved: false,
|
||||||
|
online: true,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell(stack: Vec<Screen>) -> (Shell, ConsoleShared, LibraryShared) {
|
||||||
|
fake_home();
|
||||||
|
let console = ConsoleShared::default();
|
||||||
|
console.set_hosts(hosts());
|
||||||
|
let library = LibraryShared::default();
|
||||||
|
let bus = ConsoleBus::default();
|
||||||
|
let shell = Shell::new(
|
||||||
|
console.clone(),
|
||||||
|
library.clone(),
|
||||||
|
bus,
|
||||||
|
ConsoleOptions {
|
||||||
|
device_name: "deck".into(),
|
||||||
|
deck: false,
|
||||||
|
},
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(shell, console, library)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell survives a full navigation lap (a smoke test over every screen's
|
||||||
|
/// input handling — no rendering, no GPU).
|
||||||
|
#[test]
|
||||||
|
fn navigation_lap() {
|
||||||
|
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
|
s.sync();
|
||||||
|
// Home → Settings (X), adjust something, back out.
|
||||||
|
s.handle_menu(MenuEvent::Tertiary);
|
||||||
|
assert_eq!(s.stack.len(), 2);
|
||||||
|
finish_motion(&mut s);
|
||||||
|
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||||
|
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
finish_motion(&mut s);
|
||||||
|
assert_eq!(s.stack.len(), 1);
|
||||||
|
// Home → Library on the paired host (Y), then back.
|
||||||
|
s.handle_menu(MenuEvent::Secondary);
|
||||||
|
assert_eq!(s.stack.len(), 2);
|
||||||
|
finish_motion(&mut s);
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
finish_motion(&mut s);
|
||||||
|
assert_eq!(s.stack.len(), 1);
|
||||||
|
// B at the root quits.
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
assert!(matches!(s.take_action(), Some(OverlayAction::Quit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_flow_raises_launch_and_cancel() {
|
||||||
|
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
|
s.sync();
|
||||||
|
s.handle_menu(MenuEvent::Confirm); // paired+online host focused first
|
||||||
|
assert!(matches!(
|
||||||
|
s.take_action(),
|
||||||
|
Some(OverlayAction::Launch { launch: None, .. })
|
||||||
|
));
|
||||||
|
assert!(s.connecting.is_some());
|
||||||
|
// While connecting: B cancels exactly once.
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
assert!(matches!(
|
||||||
|
s.take_action(),
|
||||||
|
Some(OverlayAction::CancelConnect)
|
||||||
|
));
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
assert!(s.take_action().is_none(), "cancel is idempotent");
|
||||||
|
// The canceled dial ends silently.
|
||||||
|
s.session_ended(None);
|
||||||
|
assert!(s.connecting.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_motion(s: &mut Shell) {
|
||||||
|
// Transitions block input; tests fast-forward them.
|
||||||
|
s.motion = Motion::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wake_gates_input_in_the_same_press() {
|
||||||
|
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
|
s.sync();
|
||||||
|
// Focus "Office Tower" (offline + wakeable), then A: the wake starts.
|
||||||
|
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||||
|
s.handle_menu(MenuEvent::Confirm);
|
||||||
|
let w = s
|
||||||
|
.wake
|
||||||
|
.as_ref()
|
||||||
|
.expect("Waking card raised in the SAME call as the A press");
|
||||||
|
assert_eq!(w.name, "Office Tower");
|
||||||
|
assert!(!w.online);
|
||||||
|
// The very next input is modal-gated — the cursor can't drift onto Add Host —
|
||||||
|
// and sync (which runs first in handle_menu) must not clear the placeholder
|
||||||
|
// before the service thread reports its first real status.
|
||||||
|
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Right)).is_none());
|
||||||
|
assert!(
|
||||||
|
s.wake.is_some(),
|
||||||
|
"optimistic card survived a sync with no service status"
|
||||||
|
);
|
||||||
|
// B cancels: the gate releases and navigation works again.
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
assert!(s.wake.is_none());
|
||||||
|
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
|
||||||
|
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
|
||||||
|
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn dump_console_screens() {
|
||||||
|
let dir = std::env::var("PF_CONSOLE_DUMP").expect("set PF_CONSOLE_DUMP to an output dir");
|
||||||
|
let fonts = crate::theme::build_fonts().unwrap();
|
||||||
|
let (w, h) = (1280, 800);
|
||||||
|
let pads: Vec<PadInfo> = Vec::new();
|
||||||
|
let dump = |shell: &mut Shell, frames: usize, sleep_ms: u64, name: &str, pad: bool| {
|
||||||
|
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||||
|
for _ in 0..frames {
|
||||||
|
shell.render(
|
||||||
|
surface.canvas(),
|
||||||
|
w as u32,
|
||||||
|
h as u32,
|
||||||
|
&fonts,
|
||||||
|
pad.then_some("Xbox Wireless Controller"),
|
||||||
|
pad.then_some(GamepadPref::Xbox360),
|
||||||
|
&pads,
|
||||||
|
);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
|
||||||
|
}
|
||||||
|
let png = surface
|
||||||
|
.image_snapshot()
|
||||||
|
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(format!("{dir}/{name}.png"), png.as_bytes()).unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Home, settled, with a pad (Letters glyphs).
|
||||||
|
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
|
dump(&mut s, 40, 8, "01-home", true);
|
||||||
|
|
||||||
|
// Mid-push into Settings (the transition still): a couple of fast frames land
|
||||||
|
// the capture around p ≈ 0.4 — both layers visible.
|
||||||
|
s.handle_menu(MenuEvent::Tertiary);
|
||||||
|
dump(&mut s, 3, 25, "02-transition", true);
|
||||||
|
dump(&mut s, 40, 8, "03-settings", true);
|
||||||
|
|
||||||
|
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
|
||||||
|
s.handle_menu(MenuEvent::Back);
|
||||||
|
dump(&mut s, 40, 8, "_back", true);
|
||||||
|
for _ in 0..3 {
|
||||||
|
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||||
|
}
|
||||||
|
s.handle_menu(MenuEvent::Confirm); // Add Host screen
|
||||||
|
dump(&mut s, 40, 8, "04-addhost", false);
|
||||||
|
s.handle_menu(MenuEvent::Confirm); // open the Name keyboard
|
||||||
|
for ev in [
|
||||||
|
MenuEvent::Move(MenuDir::Down),
|
||||||
|
MenuEvent::Confirm,
|
||||||
|
MenuEvent::Confirm,
|
||||||
|
] {
|
||||||
|
s.handle_menu(ev);
|
||||||
|
}
|
||||||
|
dump(&mut s, 40, 8, "05-addhost-keyboard", false);
|
||||||
|
|
||||||
|
// Pair (focused on the unpaired discovered host).
|
||||||
|
s.handle_menu(MenuEvent::Back); // close keyboard
|
||||||
|
s.handle_menu(MenuEvent::Back); // leave add-host
|
||||||
|
dump(&mut s, 40, 8, "_back2", true);
|
||||||
|
s.handle_menu(MenuEvent::Move(MenuDir::Left)); // onto "steambox"
|
||||||
|
s.handle_menu(MenuEvent::Confirm);
|
||||||
|
dump(&mut s, 40, 8, "06-pair", true);
|
||||||
|
|
||||||
|
// Library with placeholder posters.
|
||||||
|
library.set_games(
|
||||||
|
[
|
||||||
|
"Hades II",
|
||||||
|
"Elden Ring",
|
||||||
|
"Hollow Knight",
|
||||||
|
"Baldur's Gate 3",
|
||||||
|
"Celeste",
|
||||||
|
"Deep Rock Galactic",
|
||||||
|
"Portal 2",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, t)| crate::library::LibraryGame {
|
||||||
|
id: format!("steam:{i}"),
|
||||||
|
title: (*t).to_string(),
|
||||||
|
store: "steam".into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let (mut s2, _c2, _l2) = {
|
||||||
|
let console2 = ConsoleShared::default();
|
||||||
|
console2.set_hosts(hosts());
|
||||||
|
let bus = ConsoleBus::default();
|
||||||
|
let sh = Shell::new(
|
||||||
|
console2.clone(),
|
||||||
|
library.clone(),
|
||||||
|
bus,
|
||||||
|
ConsoleOptions {
|
||||||
|
device_name: "deck".into(),
|
||||||
|
deck: false,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
Screen::Home(HomeScreen::new()),
|
||||||
|
Screen::Library(LibraryScreen::new(&hosts()[0])),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(sh, console2, library.clone())
|
||||||
|
};
|
||||||
|
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||||
|
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||||
|
dump(&mut s2, 40, 8, "07-library", true);
|
||||||
|
|
||||||
|
// The wake and connecting overlays + a toast.
|
||||||
|
console.set_wake(Some(WakeStatus {
|
||||||
|
key: "bb22".into(),
|
||||||
|
name: "Office Tower".into(),
|
||||||
|
seconds: 12,
|
||||||
|
timed_out: false,
|
||||||
|
online: false,
|
||||||
|
then_connect: true,
|
||||||
|
}));
|
||||||
|
dump(&mut s, 10, 8, "08-waking", true);
|
||||||
|
console.set_wake(Some(WakeStatus {
|
||||||
|
key: "bb22".into(),
|
||||||
|
name: "Office Tower".into(),
|
||||||
|
seconds: 90,
|
||||||
|
timed_out: true,
|
||||||
|
online: false,
|
||||||
|
then_connect: true,
|
||||||
|
}));
|
||||||
|
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
|
||||||
|
console.set_wake(None);
|
||||||
|
s.set_connecting(Some("Elden Ring".into()));
|
||||||
|
dump(&mut s, 10, 8, "09-connecting", true);
|
||||||
|
s.set_connecting(None);
|
||||||
|
s.session_failed("Connection timed out");
|
||||||
|
dump(&mut s, 10, 8, "10-toast", true);
|
||||||
|
}
|
||||||
@@ -59,7 +59,19 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
|||||||
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
|
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
|
||||||
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
|
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
|
||||||
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
|
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
|
||||||
pub const PROTOCOL_VERSION: u32 = 3;
|
/// v4: ADDITIVE — [`control::IOCTL_UPDATE_MODES`] (the in-place mid-stream resize,
|
||||||
|
/// `design/first-frame-and-resize-latency.md` P2): the driver refreshes a LIVE monitor's advertised
|
||||||
|
/// target-mode list (`IddCxMonitorUpdateModes2`) so the OS can mode-set to an arbitrary new mode
|
||||||
|
/// without a REMOVE→ADD monitor hotplug. Nothing existing changed, so the host accepts a v3 driver
|
||||||
|
/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it;
|
||||||
|
/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship
|
||||||
|
/// driver+host together, as ever.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on
|
||||||
|
/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the
|
||||||
|
/// handshake-reported version and covers with the re-arrival fallback.
|
||||||
|
pub const MIN_DRIVER_PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||||
pub const fn ctl_code(func: u32) -> u32 {
|
pub const fn ctl_code(func: u32) -> u32 {
|
||||||
@@ -91,6 +103,13 @@ pub mod control {
|
|||||||
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
|
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
|
||||||
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
|
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
|
||||||
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
|
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
|
||||||
|
/// Refresh a LIVE monitor's advertised target-mode list to a new preferred mode (+ the built-in
|
||||||
|
/// fallbacks) via `IddCxMonitorUpdateModes2` — the in-place mid-stream resize (v4,
|
||||||
|
/// `design/first-frame-and-resize-latency.md` P2). Input [`UpdateModesRequest`]. The host then
|
||||||
|
/// CCD-forces the new mode active on the SAME monitor: no REMOVE→ADD hotplug, the monitor's OS
|
||||||
|
/// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3
|
||||||
|
/// driver fails this unknown IOCTL → the host falls back to the re-arrival resize.
|
||||||
|
pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907);
|
||||||
|
|
||||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||||
@@ -164,6 +183,22 @@ pub mod control {
|
|||||||
pub session_id: u64,
|
pub session_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_UPDATE_MODES` input (v4): the live monitor (by its ADD `session_id`) and the new
|
||||||
|
/// preferred mode its target-mode list should lead with. The driver replaces the stored list
|
||||||
|
/// (new mode first, then its built-in fallbacks — the same shape ADD produces) and pushes it to
|
||||||
|
/// the OS via `IddCxMonitorUpdateModes2`; success means the OS accepted the new list, after
|
||||||
|
/// which the host force-sets the mode via CCD/GDI as usual.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct UpdateModesRequest {
|
||||||
|
pub session_id: u64,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub refresh_hz: u32,
|
||||||
|
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding).
|
||||||
|
pub _reserved: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
@@ -253,6 +288,12 @@ pub mod control {
|
|||||||
assert!(size_of::<RemoveRequest>() == 8);
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
|
|
||||||
|
assert!(size_of::<UpdateModesRequest>() == 24);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, session_id) == 0);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, width) == 8);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, height) == 12);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, refresh_hz) == 16);
|
||||||
|
|
||||||
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
||||||
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
||||||
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
||||||
@@ -379,6 +420,46 @@ pub mod frame {
|
|||||||
/// `design/idd-push-security.md`); `driver_status_detail` carries the target id the ring claims.
|
/// `design/idd-push-security.md`); `driver_status_detail` carries the target id the ring claims.
|
||||||
pub const DRV_STATUS_BIND_FAIL: u32 = 4;
|
pub const DRV_STATUS_BIND_FAIL: u32 = 4;
|
||||||
|
|
||||||
|
/// While `driver_status` is [`DRV_STATUS_OPENED`], `driver_status_detail` carries a LIVE
|
||||||
|
/// diagnostic word maintained by the driver's publisher (best-effort plain writes — the same
|
||||||
|
/// visibility contract as `driver_status` itself). Layout:
|
||||||
|
///
|
||||||
|
/// - bit 31 (this constant): stamped at attach by a detail-capable driver, so the host can
|
||||||
|
/// tell "pre-detail driver, no information" (field = 0) from "zero frames offered".
|
||||||
|
/// - bits 30..16: surfaces the swap-chain worker OFFERED to the ring (15-bit, saturating) —
|
||||||
|
/// every DWM compose that reached `publish()`, whatever its outcome.
|
||||||
|
/// - bits 15..0: publishes DROPPED for a descriptor mismatch (16-bit, saturating) — the
|
||||||
|
/// surface's size/format didn't match the ring's.
|
||||||
|
///
|
||||||
|
/// The host's wait-for-attach reads this on its first-frame timeout to NAME the failure
|
||||||
|
/// instead of guessing (the lid-closed field report was undiagnosable without it):
|
||||||
|
/// `offered == 0` → DWM never composed the display (powered-off / undamaged desktop, compose
|
||||||
|
/// kicks blocked); `offered > 0` with `seq` still 0 → every compose was dropped mismatched
|
||||||
|
/// (the host sized the ring from a stale or foreign-session GDI mode).
|
||||||
|
pub const OPENED_DETAIL_LIVE: u32 = 0x8000_0000;
|
||||||
|
|
||||||
|
/// Pack the live OPENED diagnostic word (see [`OPENED_DETAIL_LIVE`]); both counters saturate.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pack_opened_detail(offered: u32, mismatched: u32) -> u32 {
|
||||||
|
let o = if offered > 0x7FFF { 0x7FFF } else { offered };
|
||||||
|
let m = if mismatched > 0xFFFF {
|
||||||
|
0xFFFF
|
||||||
|
} else {
|
||||||
|
mismatched
|
||||||
|
};
|
||||||
|
OPENED_DETAIL_LIVE | (o << 16) | m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a live OPENED diagnostic word → `(offered, mismatched)`; `None` when the driver
|
||||||
|
/// never stamped [`OPENED_DETAIL_LIVE`] (a pre-detail driver — the field carries nothing).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unpack_opened_detail(detail: u32) -> Option<(u32, u32)> {
|
||||||
|
if detail & OPENED_DETAIL_LIVE == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((detail >> 16) & 0x7FFF, detail & 0xFFFF))
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
|
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
|
||||||
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
|
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -650,10 +731,46 @@ pub mod gamepad {
|
|||||||
pub _reserved1: [u8; 20],
|
pub _reserved1: [u8; 20],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
|
/// The legacy (pre-ring) [`PadShm`] size. Old binaries on either side were built against a
|
||||||
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
|
/// 256-byte layout; every field they know sits below this offset, and the ring extension keeps
|
||||||
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping
|
/// bytes `0..256` byte-identical. Pagefile-backed sections are page-granular, so a view of
|
||||||
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
|
/// either generation's size maps against either generation's section — but a driver must
|
||||||
|
/// still be able to fall back to mapping this size if the full-size map is ever refused
|
||||||
|
/// (see `pf_umdf_util::ChannelConfig::min_data_size`).
|
||||||
|
pub const PAD_SHM_LEGACY_SIZE: usize = 256;
|
||||||
|
|
||||||
|
/// Output-report ring depth. 8 slots at the host's ~4 ms poll tolerates a sustained 2 kHz
|
||||||
|
/// writer — double any real HID output rate.
|
||||||
|
pub const OUT_RING_LEN: u32 = 8;
|
||||||
|
pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize;
|
||||||
|
|
||||||
|
/// One slot of the lossless output-report ring: the report bytes as the game wrote them
|
||||||
|
/// (report id first), with the exact length — unlike the legacy latest-report slot, whose
|
||||||
|
/// fixed 64-byte copy can carry a stale tail from a previous longer report.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
|
pub struct OutSlot {
|
||||||
|
/// Valid bytes in `data` (0..=64). `0` = never written.
|
||||||
|
pub len: u32,
|
||||||
|
pub data: [u8; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual DualSense / DualShock 4 shared section (1024 B; bytes `0..256` are the v2 legacy
|
||||||
|
/// layout verbatim — [`PAD_SHM_LEGACY_SIZE`]). The host writes the `0x01`-style HID input
|
||||||
|
/// report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
|
||||||
|
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) twice: into the legacy
|
||||||
|
/// latest-report `output` slot (bumping `out_seq` — every host generation reads this), and,
|
||||||
|
/// when the host stamped `out_ring_ver`, into the lossless `out_ring` (bumping `ring_head`).
|
||||||
|
/// The ring exists because the single slot COALESCES: a rumble-stop report overwritten by an
|
||||||
|
/// LED/trigger report inside one host poll window was gone forever — the confirmed stuck-rumble
|
||||||
|
/// path (`design/rumble-root-fix.md` §A). `device_type` selects the HID identity
|
||||||
|
/// ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
|
||||||
|
///
|
||||||
|
/// Version posture: this is a TAIL extension negotiated by zeroed-reserved capability fields,
|
||||||
|
/// deliberately NOT a [`GAMEPAD_PROTO_VERSION`] bump — the bootstrap fails CLOSED on a version
|
||||||
|
/// mismatch (no pad at all), which is the wrong failure mode for a feedback-quality fix. An
|
||||||
|
/// old driver never reads the new fields; an old host never stamps `out_ring_ver`, so a new
|
||||||
|
/// driver stays on the legacy slot against it.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
pub struct PadShm {
|
pub struct PadShm {
|
||||||
@@ -678,7 +795,20 @@ pub mod gamepad {
|
|||||||
/// The pad index this section serves (host-stamped before the magic) — see
|
/// The pad index this section serves (host-stamped before the magic) — see
|
||||||
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
|
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
|
||||||
pub pad_index: u32,
|
pub pad_index: u32,
|
||||||
pub _reserved1: [u8; 100],
|
/// Host-stamped `1` at section creation ⇔ "this section carries the `out_ring` region and
|
||||||
|
/// the host drains it". The section starts zeroed and an old host never writes it, so `0`
|
||||||
|
/// tells a new driver to stay legacy-only. Carved from v2 reserved space (v2.1).
|
||||||
|
pub out_ring_ver: u32,
|
||||||
|
/// Driver-bumped (AFTER writing `out_ring[ring_head % OUT_RING_LEN]`) count of reports
|
||||||
|
/// ever published to the ring — the host's drain cursor compares against its own tail and
|
||||||
|
/// detects overflow by `head - tail > OUT_RING_LEN`. Same publish-then-bump store order as
|
||||||
|
/// `out_seq` (the host's Acquire load orders the reads). Carved from v2 reserved space
|
||||||
|
/// (v2.1).
|
||||||
|
pub ring_head: u32,
|
||||||
|
pub _reserved1: [u8; 92],
|
||||||
|
/// The lossless output-report ring (v2.1) — see the struct docs and [`OutSlot`].
|
||||||
|
pub out_ring: [OutSlot; OUT_RING_LEN_USIZE],
|
||||||
|
pub _reserved2: [u8; 224],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||||
@@ -704,7 +834,7 @@ pub mod gamepad {
|
|||||||
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
|
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
|
||||||
assert!(offset_of!(XusbShm, pad_index) == 40);
|
assert!(offset_of!(XusbShm, pad_index) == 40);
|
||||||
|
|
||||||
assert!(size_of::<PadShm>() == 256);
|
assert!(size_of::<PadShm>() == 1024);
|
||||||
assert!(offset_of!(PadShm, magic) == 0);
|
assert!(offset_of!(PadShm, magic) == 0);
|
||||||
assert!(offset_of!(PadShm, input) == 8);
|
assert!(offset_of!(PadShm, input) == 8);
|
||||||
assert!(offset_of!(PadShm, out_seq) == 72);
|
assert!(offset_of!(PadShm, out_seq) == 72);
|
||||||
@@ -713,6 +843,11 @@ pub mod gamepad {
|
|||||||
assert!(offset_of!(PadShm, driver_proto) == 144);
|
assert!(offset_of!(PadShm, driver_proto) == 144);
|
||||||
assert!(offset_of!(PadShm, driver_heartbeat) == 148);
|
assert!(offset_of!(PadShm, driver_heartbeat) == 148);
|
||||||
assert!(offset_of!(PadShm, pad_index) == 152);
|
assert!(offset_of!(PadShm, pad_index) == 152);
|
||||||
|
// v2.1 ring extension — everything below PAD_SHM_LEGACY_SIZE is the v2 layout verbatim.
|
||||||
|
assert!(offset_of!(PadShm, out_ring_ver) == 156);
|
||||||
|
assert!(offset_of!(PadShm, ring_head) == 160);
|
||||||
|
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
|
||||||
|
assert!(size_of::<OutSlot>() == 68);
|
||||||
|
|
||||||
assert!(size_of::<PadBootstrap>() == 32);
|
assert!(size_of::<PadBootstrap>() == 32);
|
||||||
assert!(offset_of!(PadBootstrap, magic) == 0);
|
assert!(offset_of!(PadBootstrap, magic) == 0);
|
||||||
@@ -725,6 +860,106 @@ pub mod gamepad {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Virtual-pointer shared-memory layout (host ↔ the UMDF HID-mouse minidriver `pf_mouse`).
|
||||||
|
///
|
||||||
|
/// Why a virtual mouse exists at all: with no pointing device present (a headless Windows host —
|
||||||
|
/// no dongle attached), win32k reports the cursor as absent (`SM_MOUSEPRESENT` = 0) and DWM never
|
||||||
|
/// composites a cursor into the pf-vdisplay frame, so a streamed desktop has an invisible pointer
|
||||||
|
/// even though `SendInput` moves it. A resident HID mouse devnode makes Windows always consider a
|
||||||
|
/// pointer present — the Sunshine/Parsec-class fix. Injection stays `SendInput`; the report path
|
||||||
|
/// below exists for validation (`vmouse-spike`) and as the future higher-fidelity route.
|
||||||
|
///
|
||||||
|
/// The channel is the **sealed pad channel** verbatim (`design/gamepad-channel-sealing.md`): the
|
||||||
|
/// same [`gamepad::PadBootstrap`] mailbox handshake (and therefore the same
|
||||||
|
/// [`gamepad::GAMEPAD_PROTO_VERSION`] lockstep), a mouse-specific mailbox name
|
||||||
|
/// ([`mouse_boot_name`]) and DATA magic, and `pad_index` validation (a single resident mouse =
|
||||||
|
/// index 0). Reusing the handshake means `pf-umdf-util`'s audited `ChannelClient`/`PadChannel`
|
||||||
|
/// serve the mouse unchanged.
|
||||||
|
pub mod mouse {
|
||||||
|
use alloc::string::String;
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
/// Mouse DATA-section magic ("PFMO" LE) — distinct from the pad magics so a cross-wired
|
||||||
|
/// delivery fails validation.
|
||||||
|
pub const MOUSE_MAGIC: u32 = 0x4F4D_4650;
|
||||||
|
|
||||||
|
/// `Global\pfmouse-boot-<index>` — the virtual mouse's bootstrap mailbox
|
||||||
|
/// ([`crate::gamepad::PadBootstrap`]).
|
||||||
|
pub fn mouse_boot_name(index: u8) -> String {
|
||||||
|
alloc::format!("Global\\pfmouse-boot-{index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HID identity both sides report/expect ("PF" / "MO" — an obviously-virtual identity; no
|
||||||
|
/// software matches on it, unlike the pads' cloned Sony/Valve ids).
|
||||||
|
pub const MOUSE_VID: u16 = 0x5046;
|
||||||
|
pub const MOUSE_PID: u16 = 0x4D4F;
|
||||||
|
pub const MOUSE_VER: u16 = 0x0100;
|
||||||
|
|
||||||
|
/// The one input report (id `0x01`): `[id, buttons(5 bits), x_lo, x_hi, y_lo, y_hi, wheel,
|
||||||
|
/// pan]` — absolute X/Y over `0..=`[`MOUSE_ABS_MAX`], relative wheel/pan.
|
||||||
|
pub const MOUSE_REPORT_ID: u8 = 0x01;
|
||||||
|
pub const MOUSE_REPORT_LEN: usize = 8;
|
||||||
|
/// Logical maximum of the absolute X/Y axes (15-bit, the HID-descriptor convention).
|
||||||
|
pub const MOUSE_ABS_MAX: u16 = 0x7FFF;
|
||||||
|
|
||||||
|
/// Build the 8-byte input report. Pure so the byte layout is unit-tested on every dev machine
|
||||||
|
/// (the driver workspace is `panic = "abort"` and hosts no test harness); the driver only
|
||||||
|
/// ferries these bytes, it never builds them.
|
||||||
|
#[must_use]
|
||||||
|
pub fn input_report(buttons: u8, x: u16, y: u16, wheel: i8, pan: i8) -> [u8; MOUSE_REPORT_LEN] {
|
||||||
|
let x = x.min(MOUSE_ABS_MAX);
|
||||||
|
let y = y.min(MOUSE_ABS_MAX);
|
||||||
|
[
|
||||||
|
MOUSE_REPORT_ID,
|
||||||
|
buttons & 0x1F,
|
||||||
|
(x & 0xFF) as u8,
|
||||||
|
(x >> 8) as u8,
|
||||||
|
(y & 0xFF) as u8,
|
||||||
|
(y >> 8) as u8,
|
||||||
|
wheel as u8,
|
||||||
|
pan as u8,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual-mouse shared section (64 B). The host writes an input report then bumps `in_seq`
|
||||||
|
/// (Release); the driver's timer Acquire-loads `in_seq` and completes a pended `READ_REPORT`
|
||||||
|
/// with the fresh report — event-driven like a real mouse, so an idle section generates NO
|
||||||
|
/// HID traffic (a constant report stream would read as user activity to the OS).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
|
pub struct MouseShm {
|
||||||
|
pub magic: u32,
|
||||||
|
/// Bumped by the host AFTER `report` is in place (Release) — the driver's new-input
|
||||||
|
/// trigger. `0` = nothing published yet.
|
||||||
|
pub in_seq: u32,
|
||||||
|
/// The latest HID input report (id [`MOUSE_REPORT_ID`], [`MOUSE_REPORT_LEN`] bytes).
|
||||||
|
pub report: [u8; MOUSE_REPORT_LEN],
|
||||||
|
/// Written by the driver's timer while attached: [`crate::gamepad::GAMEPAD_PROTO_VERSION`]
|
||||||
|
/// (the mouse channel rides the gamepad handshake). `0` = no driver attached — the host
|
||||||
|
/// health check keys off it.
|
||||||
|
pub driver_proto: u32,
|
||||||
|
/// Bumped by the driver's timer each tick — liveness (advances whether or not input flows).
|
||||||
|
pub driver_heartbeat: u32,
|
||||||
|
/// The device index this section serves (host-stamped before the magic; the driver
|
||||||
|
/// validates it against its devnode Location — same fail-closed check as the pads).
|
||||||
|
pub pad_index: u32,
|
||||||
|
pub _reserved: [u8; 36],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsets are the cross-process wire contract — pin every one (same discipline as `gamepad`).
|
||||||
|
const _: () = {
|
||||||
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
|
assert!(size_of::<MouseShm>() == 64);
|
||||||
|
assert!(offset_of!(MouseShm, magic) == 0);
|
||||||
|
assert!(offset_of!(MouseShm, in_seq) == 4);
|
||||||
|
assert!(offset_of!(MouseShm, report) == 8);
|
||||||
|
assert!(offset_of!(MouseShm, driver_proto) == 16);
|
||||||
|
assert!(offset_of!(MouseShm, driver_heartbeat) == 20);
|
||||||
|
assert!(offset_of!(MouseShm, pad_index) == 24);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -758,6 +993,27 @@ mod tests {
|
|||||||
assert_eq!(t.pack(), (7u64 << 40) | (42u64 << 8) | 3u64);
|
assert_eq!(t.pack(), (7u64 << 40) | (42u64 << 8) | 3u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opened_detail_roundtrips_and_saturates() {
|
||||||
|
use frame::{pack_opened_detail, unpack_opened_detail, OPENED_DETAIL_LIVE};
|
||||||
|
// Zero counters still stamp LIVE — "attached, nothing offered yet" is information.
|
||||||
|
assert_eq!(pack_opened_detail(0, 0), OPENED_DETAIL_LIVE);
|
||||||
|
assert_eq!(unpack_opened_detail(pack_opened_detail(0, 0)), Some((0, 0)));
|
||||||
|
// Roundtrip within range.
|
||||||
|
assert_eq!(
|
||||||
|
unpack_opened_detail(pack_opened_detail(1234, 567)),
|
||||||
|
Some((1234, 567))
|
||||||
|
);
|
||||||
|
// Saturation at each counter's width (15-bit offered, 16-bit mismatched).
|
||||||
|
assert_eq!(
|
||||||
|
unpack_opened_detail(pack_opened_detail(u32::MAX, u32::MAX)),
|
||||||
|
Some((0x7FFF, 0xFFFF))
|
||||||
|
);
|
||||||
|
// A pre-detail driver's field (any value without the LIVE bit) carries no information.
|
||||||
|
assert_eq!(unpack_opened_detail(0), None);
|
||||||
|
assert_eq!(unpack_opened_detail(0x7FFF_FFFF), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_header_is_pod_and_64_bytes() {
|
fn shared_header_is_pod_and_64_bytes() {
|
||||||
let mut h = frame::SharedHeader::zeroed();
|
let mut h = frame::SharedHeader::zeroed();
|
||||||
@@ -889,6 +1145,27 @@ mod tests {
|
|||||||
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
|
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_modes_request_roundtrips_and_versions_cohere() {
|
||||||
|
let req = control::UpdateModesRequest {
|
||||||
|
session_id: 42,
|
||||||
|
width: 2560,
|
||||||
|
height: 1409, // deliberately arbitrary — the in-place path serves window-drag modes
|
||||||
|
refresh_hz: 120,
|
||||||
|
_reserved: 0,
|
||||||
|
};
|
||||||
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
|
assert_eq!(bytes.len(), 24);
|
||||||
|
assert_eq!(
|
||||||
|
*bytemuck::from_bytes::<control::UpdateModesRequest>(bytes),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
||||||
|
// The compat window: v4 is additive over v3, so the host floor stays one below.
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 4);
|
||||||
|
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gamepad_names_and_magics_are_stable() {
|
fn gamepad_names_and_magics_are_stable() {
|
||||||
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
||||||
@@ -930,6 +1207,7 @@ mod tests {
|
|||||||
control::IOCTL_GET_INFO,
|
control::IOCTL_GET_INFO,
|
||||||
control::IOCTL_CLEAR_ALL,
|
control::IOCTL_CLEAR_ALL,
|
||||||
control::IOCTL_SET_FRAME_CHANNEL,
|
control::IOCTL_SET_FRAME_CHANNEL,
|
||||||
|
control::IOCTL_UPDATE_MODES,
|
||||||
];
|
];
|
||||||
for (i, a) in all.iter().enumerate() {
|
for (i, a) in all.iter().enumerate() {
|
||||||
for b in &all[i + 1..] {
|
for b in &all[i + 1..] {
|
||||||
@@ -980,6 +1258,25 @@ mod tests {
|
|||||||
assert!((360..=440).contains(&back), "min decoded {back} millinits");
|
assert!((360..=440).contains(&back), "min decoded {back} millinits");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_report_and_names_are_stable() {
|
||||||
|
assert_eq!(mouse::mouse_boot_name(0), "Global\\pfmouse-boot-0");
|
||||||
|
// "PFMO" little-endian, and never colliding with a pad magic (cross-wire validation).
|
||||||
|
assert_eq!(mouse::MOUSE_MAGIC.to_le_bytes(), *b"PFMO");
|
||||||
|
assert_ne!(mouse::MOUSE_MAGIC, gamepad::XUSB_MAGIC);
|
||||||
|
assert_ne!(mouse::MOUSE_MAGIC, gamepad::PAD_MAGIC);
|
||||||
|
// The 8-byte report layout the driver ferries and the host builds.
|
||||||
|
let r = mouse::input_report(0b0000_0101, 0x1234, 0x7FFF, -3, 7);
|
||||||
|
assert_eq!(r, [0x01, 0x05, 0x34, 0x12, 0xFF, 0x7F, 0xFD, 0x07]);
|
||||||
|
// Clamps: axes to the 15-bit logical max, buttons to the declared 5.
|
||||||
|
let r = mouse::input_report(0xFF, 0xFFFF, 0, 0, 0);
|
||||||
|
assert_eq!((r[1], r[2], r[3]), (0x1F, 0xFF, 0x7F));
|
||||||
|
// A zeroed section reads as "nothing published" (in_seq 0) — the driver's idle state.
|
||||||
|
let shm = mouse::MouseShm::zeroed();
|
||||||
|
assert_eq!(shm.in_seq, 0);
|
||||||
|
assert_eq!(bytemuck::bytes_of(&shm).len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn guid_is_not_sudovda() {
|
fn guid_is_not_sudovda() {
|
||||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Hardware/software video encode (plan §7 / §W6): the per-vendor backends (NVENC, VAAPI, AMF, QSV,
|
||||||
|
# Vulkan-Video, PyroWave, openh264) behind one `Encoder` trait + `open_video` selector, extracted
|
||||||
|
# from the host so it depends on the shared frame vocabulary (pf-frame) rather than living inside
|
||||||
|
# the orchestrator. Speaks pf-frame (CapturedFrame/PixelFormat/dxgi identity) and pf-zerocopy
|
||||||
|
# (CUDA), never pf-capture — the capture→encode edge is one-way (plan §2.4).
|
||||||
|
[package]
|
||||||
|
name = "pf-encode"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
rust-version.workspace = true
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
pf-frame = { path = "../pf-frame" }
|
||||||
|
pf-gpu = { path = "../pf-gpu" }
|
||||||
|
pf-host-config = { path = "../pf-host-config" }
|
||||||
|
pf-zerocopy = { path = "../pf-zerocopy" }
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||||
|
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||||
|
openh264 = "0.9"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# libavcodec (NVENC libav + VAAPI backends). `ffmpeg-sys-next` auto-detects the FFmpeg version.
|
||||||
|
ffmpeg-next = "8"
|
||||||
|
libc = "0.2"
|
||||||
|
# Vulkan bindings for the raw Vulkan-Video encode + PyroWave compute backends (feature-gated below;
|
||||||
|
# the dep stays unconditional to mirror the host's Linux target — unused-but-declared is harmless).
|
||||||
|
ash = "0.38"
|
||||||
|
# `libnvidia-encode.so.1` is dlopen'd at runtime for the direct-SDK NVENC/CUDA backend.
|
||||||
|
libloading = "0.8"
|
||||||
|
# Direct-SDK NVENC (raw `sys::nvEncodeAPI` types; entry points resolved at runtime). `ci-check` =
|
||||||
|
# vendored bindings, no CUDA toolkit at build.
|
||||||
|
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
|
||||||
|
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under `pyrowave`.
|
||||||
|
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# NVENC (direct SDK, D3D11 input) + the shared D3D11/DXGI vocabulary via pf-frame.
|
||||||
|
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
|
||||||
|
# AMD (AMF) + Intel (QSV) hardware encode via libavcodec (behind `amf-qsv`; link-imports FFmpeg).
|
||||||
|
ffmpeg-next = { version = "8", optional = true }
|
||||||
|
# `libnvidia-encode`/`nvEncodeAPI64.dll` resolved at runtime; the NVENC status→cause table dlopen.
|
||||||
|
libloading = "0.8"
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Graphics_Direct3D11",
|
||||||
|
"Win32_Graphics_Dxgi",
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# NVENC hardware encode (Linux CUDA + Windows D3D11); entry points resolved at runtime.
|
||||||
|
nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||||
|
# AMD (AMF) + Intel (QSV) hardware encode on Windows via libavcodec.
|
||||||
|
amf-qsv = ["dep:ffmpeg-next"]
|
||||||
|
# Raw Vulkan-Video HEVC/AV1 encode on Linux (reuses the `ash` bindings; no new dep).
|
||||||
|
vulkan-encode = []
|
||||||
|
# PyroWave — the opt-in wired-LAN intra-only wavelet codec (Linux encode backend).
|
||||||
|
pyrowave = ["dep:pyrowave-sys"]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
//! The encoder contract (plan §7, Tier 1): the [`Encoder`] trait plus the plain-data value types its
|
//! The encoder contract (plan §7, Tier 1): the [`Encoder`] trait plus the plain-data value types its
|
||||||
//! signatures use — [`EncodedFrame`], [`Codec`], [`ChromaFormat`], [`EncoderCaps`] — and the
|
//! signatures use — [`EncodedFrame`], [`Codec`], [`ChromaFormat`], [`EncoderCaps`] — and the
|
||||||
//! dimension/VBV helpers [`validate_dimensions`] and [`vbv_frames_env`]. Backend selection, the
|
//! dimension/VBV helpers [`validate_dimensions`] and [`vbv_frames_env`]. Backend selection, the
|
||||||
//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent [`crate::encode`]
|
//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent the `pf-encode` crate root
|
||||||
//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::encode::*` path
|
//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::*` path
|
||||||
//! is unchanged.
|
//! is unchanged.
|
||||||
use crate::capture::CapturedFrame;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use pf_frame::CapturedFrame;
|
||||||
|
|
||||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||||
@@ -94,7 +94,7 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
|
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
|
||||||
/// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]).
|
/// the web console's session meta (the host `stats_recorder::StatsRecorder::register_session`).
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Codec::H264 => "h264",
|
Codec::H264 => "h264",
|
||||||
@@ -108,7 +108,7 @@ impl Codec {
|
|||||||
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
||||||
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
||||||
/// *codec-level* gate: the active GPU/backend must still pass
|
/// *codec-level* gate: the active GPU/backend must still pass
|
||||||
/// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit.
|
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
||||||
pub fn supports_10bit(self) -> bool {
|
pub fn supports_10bit(self) -> bool {
|
||||||
matches!(self, Codec::H265 | Codec::Av1)
|
matches!(self, Codec::H265 | Codec::Av1)
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
|
/// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC
|
||||||
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode::
|
/// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::
|
||||||
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
|
/// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on
|
||||||
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
|
/// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more —
|
||||||
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
|
/// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it).
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! (`encode/windows/ffmpeg_win.rs`) — so the byte-identical pieces live once (plan §2.2, the Tier-2
|
//! (`encode/windows/ffmpeg_win.rs`) — so the byte-identical pieces live once (plan §2.2, the Tier-2
|
||||||
//! gap). Free functions and consts over borrowed handles; nothing here is per-frame `dyn`,
|
//! gap). Free functions and consts over borrowed handles; nothing here is per-frame `dyn`,
|
||||||
//! allocating, or on the zero-copy ingest path.
|
//! allocating, or on the zero-copy ingest path.
|
||||||
use crate::encode::EncodedFrame;
|
use crate::EncodedFrame;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
use ffmpeg_next::ffi; // = ffmpeg_sys_next
|
use ffmpeg_next::ffi; // = ffmpeg_sys_next
|
||||||
@@ -54,7 +54,7 @@ pub(crate) fn apply_low_latency_rc(video: &mut encoder::video::Video, fps: u32,
|
|||||||
video.set_bit_rate(bitrate_bps as usize);
|
video.set_bit_rate(bitrate_bps as usize);
|
||||||
video.set_max_bit_rate(bitrate_bps as usize);
|
video.set_max_bit_rate(bitrate_bps as usize);
|
||||||
video.set_max_b_frames(0);
|
video.set_max_b_frames(0);
|
||||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::vbv_frames_env())
|
||||||
.clamp(1.0, i32::MAX as f64);
|
.clamp(1.0, i32::MAX as f64);
|
||||||
// SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened
|
// SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened
|
||||||
// yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain
|
// yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain
|
||||||
+12
-17
@@ -12,12 +12,12 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::util::frame::Video as VideoFrame;
|
use ffmpeg::util::frame::Video as VideoFrame;
|
||||||
use ffmpeg::{codec, encoder, Dictionary};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ impl NvencEncoder {
|
|||||||
// hwdevice/hwframes contexts and set `pix_fmt = CUDA` on the raw encoder context
|
// hwdevice/hwframes contexts and set `pix_fmt = CUDA` on the raw encoder context
|
||||||
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
||||||
let cuda_hw = if cuda {
|
let cuda_hw = if cuda {
|
||||||
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
|
let cu_ctx = pf_zerocopy::cuda::context().context("shared CUDA context")?;
|
||||||
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
|
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
|
||||||
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
|
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
|
||||||
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
|
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
|
||||||
@@ -722,12 +722,7 @@ impl NvencEncoder {
|
|||||||
/// device pointer with a bounded table, so a fresh pointer every frame would thrash/overflow
|
/// device pointer with a bounded table, so a fresh pointer every frame would thrash/overflow
|
||||||
/// it — the pool recycles a small set of pointers. The extra copy is device-local (~8 MB at
|
/// it — the pool recycles a small set of pointers. The extra copy is device-local (~8 MB at
|
||||||
/// 1080p, sub-millisecond on the GPU) and keeps the host fully off the pixel path.
|
/// 1080p, sub-millisecond on the GPU) and keeps the host fully off the pixel path.
|
||||||
fn submit_cuda(
|
fn submit_cuda(&mut self, buf: &pf_zerocopy::DeviceBuffer, pts: i64, idr: bool) -> Result<()> {
|
||||||
&mut self,
|
|
||||||
buf: &crate::zerocopy::DeviceBuffer,
|
|
||||||
pts: i64,
|
|
||||||
idr: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let frames_ref = self
|
let frames_ref = self
|
||||||
.cuda
|
.cuda
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -735,7 +730,7 @@ impl NvencEncoder {
|
|||||||
.frames_ref;
|
.frames_ref;
|
||||||
// The device→device copy below uses our shared context directly; make it current on the
|
// The device→device copy below uses our shared context directly; make it current on the
|
||||||
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
||||||
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
||||||
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
||||||
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||||
// (`make_current()?`), the precondition for the device-pointer copies below.
|
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||||
@@ -770,11 +765,11 @@ impl NvencEncoder {
|
|||||||
let copy_res = if buf.yuv444 {
|
let copy_res = if buf.yuv444 {
|
||||||
let dsts = core::array::from_fn(|i| {
|
let dsts = core::array::from_fn(|i| {
|
||||||
(
|
(
|
||||||
(*f).data[i] as crate::zerocopy::cuda::CUdeviceptr,
|
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr,
|
||||||
(*f).linesize[i] as usize,
|
(*f).linesize[i] as usize,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
crate::zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
||||||
} else if self.want_444 {
|
} else if self.want_444 {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
bail!(
|
bail!(
|
||||||
@@ -783,15 +778,15 @@ impl NvencEncoder {
|
|||||||
CPU 4:4:4 path on this compositor"
|
CPU 4:4:4 path on this compositor"
|
||||||
);
|
);
|
||||||
} else if buf.is_nv12() {
|
} else if buf.is_nv12() {
|
||||||
let y_ptr = (*f).data[0] as crate::zerocopy::cuda::CUdeviceptr;
|
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let y_pitch = (*f).linesize[0] as usize;
|
let y_pitch = (*f).linesize[0] as usize;
|
||||||
let uv_ptr = (*f).data[1] as crate::zerocopy::cuda::CUdeviceptr;
|
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let uv_pitch = (*f).linesize[1] as usize;
|
let uv_pitch = (*f).linesize[1] as usize;
|
||||||
crate::zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
||||||
} else {
|
} else {
|
||||||
let dst_ptr = (*f).data[0] as crate::zerocopy::cuda::CUdeviceptr;
|
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||||
let dst_pitch = (*f).linesize[0] as usize;
|
let dst_pitch = (*f).linesize[0] as usize;
|
||||||
crate::zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
||||||
};
|
};
|
||||||
if let Err(e) = copy_res {
|
if let Err(e) = copy_res {
|
||||||
ffi::av_frame_free(&mut f);
|
ffi::av_frame_free(&mut f);
|
||||||
@@ -827,7 +822,7 @@ impl Drop for NvencEncoder {
|
|||||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
||||||
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
||||||
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
||||||
/// by the caller ([`crate::encode::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
|
/// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
|
||||||
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
|
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
|
||||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||||
if codec != Codec::H265 {
|
if codec != Codec::H265 {
|
||||||
+15
-15
@@ -36,9 +36,9 @@ use super::nvenc_core::{
|
|||||||
};
|
};
|
||||||
use super::nvenc_status;
|
use super::nvenc_status;
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload};
|
|
||||||
use crate::zerocopy::cuda::{self, InputSurface};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload};
|
||||||
|
use pf_zerocopy::cuda::{self, InputSurface};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
@@ -321,7 +321,7 @@ impl NvencCudaEncoder {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn open(
|
pub fn open(
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
_format: crate::capture::PixelFormat,
|
_format: pf_frame::PixelFormat,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
fps: u32,
|
fps: u32,
|
||||||
@@ -1010,23 +1010,23 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
let is_idr = flags != 0 || opening;
|
let is_idr = flags != 0 || opening;
|
||||||
let mastering_sei = self
|
let mastering_sei = self
|
||||||
.hdr_meta
|
.hdr_meta
|
||||||
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
|
.map(|m| pf_frame::hdr::hevc_mastering_display_sei(&m));
|
||||||
let cll_sei = self
|
let cll_sei = self
|
||||||
.hdr_meta
|
.hdr_meta
|
||||||
.map(|m| crate::hdr::hevc_content_light_level_sei(&m));
|
.map(|m| pf_frame::hdr::hevc_content_light_level_sei(&m));
|
||||||
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
||||||
if is_idr && self.hdr {
|
if is_idr && self.hdr {
|
||||||
if let Some(p) = mastering_sei.as_ref() {
|
if let Some(p) = mastering_sei.as_ref() {
|
||||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||||
payloadSize: p.len() as u32,
|
payloadSize: p.len() as u32,
|
||||||
payloadType: crate::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
payloadType: pf_frame::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||||
payload: p.as_ptr() as *mut u8,
|
payload: p.as_ptr() as *mut u8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(p) = cll_sei.as_ref() {
|
if let Some(p) = cll_sei.as_ref() {
|
||||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||||
payloadSize: p.len() as u32,
|
payloadSize: p.len() as u32,
|
||||||
payloadType: crate::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
payloadType: pf_frame::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
||||||
payload: p.as_ptr() as *mut u8,
|
payload: p.as_ptr() as *mut u8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1253,8 +1253,8 @@ impl Drop for NvencCudaEncoder {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use crate::zerocopy::cuda::DeviceBuffer;
|
use pf_zerocopy::cuda::DeviceBuffer;
|
||||||
|
|
||||||
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
||||||
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
|
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
|
||||||
@@ -1281,7 +1281,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_smoke_rfi_anchor() {
|
fn nvenc_cuda_smoke_rfi_anchor() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
|
|
||||||
let mut enc = NvencCudaEncoder::open(
|
let mut enc = NvencCudaEncoder::open(
|
||||||
Codec::H265,
|
Codec::H265,
|
||||||
@@ -1358,7 +1358,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_yuv444() {
|
fn nvenc_cuda_yuv444() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
let mut enc = NvencCudaEncoder::open(
|
let mut enc = NvencCudaEncoder::open(
|
||||||
Codec::H265,
|
Codec::H265,
|
||||||
PixelFormat::Yuv444,
|
PixelFormat::Yuv444,
|
||||||
@@ -1403,7 +1403,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_reconfigure_no_idr() {
|
fn nvenc_cuda_reconfigure_no_idr() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
let mut enc = NvencCudaEncoder::open(
|
let mut enc = NvencCudaEncoder::open(
|
||||||
Codec::H265,
|
Codec::H265,
|
||||||
PixelFormat::Nv12,
|
PixelFormat::Nv12,
|
||||||
@@ -1510,7 +1510,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_codec_switch_reopen() {
|
fn nvenc_cuda_codec_switch_reopen() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
for (leg, codec) in [
|
for (leg, codec) in [
|
||||||
Codec::H265,
|
Codec::H265,
|
||||||
Codec::Av1,
|
Codec::Av1,
|
||||||
@@ -1552,7 +1552,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_dirty_teardown_reopen() {
|
fn nvenc_cuda_dirty_teardown_reopen() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
for round in 0..3 {
|
for round in 0..3 {
|
||||||
let mut enc = open_h265();
|
let mut enc = open_h265();
|
||||||
for f in 0..4u32 {
|
for f in 0..4u32 {
|
||||||
@@ -1581,7 +1581,7 @@ mod tests {
|
|||||||
fn nvenc_cuda_open_failure_diagnosis_and_recovery() {
|
fn nvenc_cuda_open_failure_diagnosis_and_recovery() {
|
||||||
const W: u32 = 1280;
|
const W: u32 = 1280;
|
||||||
const H: u32 = 720;
|
const H: u32 = 720;
|
||||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||||
try_api().expect("nvenc api");
|
try_api().expect("nvenc api");
|
||||||
let shared = cuda::context().expect("shared ctx");
|
let shared = cuda::context().expect("shared ctx");
|
||||||
|
|
||||||
+5
-8
@@ -24,11 +24,11 @@
|
|||||||
// Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it).
|
// Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it).
|
||||||
|
|
||||||
use super::vk_util::{color_range, find_mem, import_rgb_dmabuf, make_plain_image, pixel_to_vk};
|
use super::vk_util::{color_range, find_mem, import_rgb_dmabuf, make_plain_image, pixel_to_vk};
|
||||||
use crate::capture::{CapturedFrame, FramePayload};
|
use crate::{EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::encode::{EncodedFrame, Encoder, EncoderCaps};
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use ash::vk::Handle as _;
|
use ash::vk::Handle as _;
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload};
|
||||||
use pyrowave_sys as pw;
|
use pyrowave_sys as pw;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
@@ -637,10 +637,7 @@ impl PyroWaveEncoder {
|
|||||||
/// Records the small upload (only when the bitmap `serial` changed) + layout transition into
|
/// Records the small upload (only when the bitmap `serial` changed) + layout transition into
|
||||||
/// `cmd`, ahead of the CSC dispatch that samples binding 3. Encode is synchronous, so the single
|
/// `cmd`, ahead of the CSC dispatch that samples binding 3. Encode is synchronous, so the single
|
||||||
/// shared image never races a prior frame; the first use transitions it to SHADER_READ_ONLY.
|
/// shared image never races a prior frame; the first use transitions it to SHADER_READ_ONLY.
|
||||||
unsafe fn prep_cursor(
|
unsafe fn prep_cursor(&mut self, cursor: Option<&pf_frame::CursorOverlay>) -> Result<[i32; 4]> {
|
||||||
&mut self,
|
|
||||||
cursor: Option<&crate::capture::CursorOverlay>,
|
|
||||||
) -> Result<[i32; 4]> {
|
|
||||||
let dev = self.device.clone();
|
let dev = self.device.clone();
|
||||||
let cmd = self.cmd;
|
let cmd = self.cmd;
|
||||||
let img = self.cursor_img;
|
let img = self.cursor_img;
|
||||||
@@ -748,7 +745,7 @@ impl PyroWaveEncoder {
|
|||||||
/// Import a dmabuf with per-buffer caching — same policy as `vulkan_video.rs::import_cached`.
|
/// Import a dmabuf with per-buffer caching — same policy as `vulkan_video.rs::import_cached`.
|
||||||
unsafe fn import_cached(
|
unsafe fn import_cached(
|
||||||
&mut self,
|
&mut self,
|
||||||
d: &crate::capture::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
) -> Result<(vk::Image, vk::ImageView, bool)> {
|
) -> Result<(vk::Image, vk::ImageView, bool)> {
|
||||||
@@ -1303,7 +1300,7 @@ impl Drop for PyroWaveEncoder {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::capture::PixelFormat;
|
use pf_frame::PixelFormat;
|
||||||
|
|
||||||
fn cpu_frame(w: u32, h: u32, pts_ns: u64, fill: [u8; 4]) -> CapturedFrame {
|
fn cpu_frame(w: u32, h: u32, pts_ns: u64, fill: [u8; 4]) -> CapturedFrame {
|
||||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||||
+5
-7
@@ -23,11 +23,11 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{Codec, EncodedFrame, Encoder};
|
use super::{Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::{codec, encoder, Dictionary};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||||
use std::ffi::{CStr, CString};
|
use std::ffi::{CStr, CString};
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
@@ -44,13 +44,11 @@ const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
|||||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The render node a VAAPI/DRM device should open, from [`crate::gpu::linux_render_node`]: a
|
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
|
||||||
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
||||||
/// default.
|
/// default.
|
||||||
fn render_node() -> CString {
|
fn render_node() -> CString {
|
||||||
let p = crate::gpu::linux_render_node()
|
let p = pf_gpu::linux_render_node().to_string_lossy().into_owned();
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
CString::new(p).unwrap_or_else(|_| CString::new("/dev/dri/renderD128").unwrap())
|
CString::new(p).unwrap_or_else(|_| CString::new("/dev/dri/renderD128").unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +561,7 @@ impl DmabufInner {
|
|||||||
fps: u32,
|
fps: u32,
|
||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let drm_fourcc = crate::zerocopy::drm_fourcc(format)
|
let drm_fourcc = pf_frame::drm_fourcc(format)
|
||||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
||||||
let node = render_node();
|
let node = render_node();
|
||||||
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||||
@@ -809,7 +807,7 @@ impl DmabufInner {
|
|||||||
// Sampled breakdown of this synchronous submit under PUNKTFUNK_PERF: push = descriptor
|
// Sampled breakdown of this synchronous submit under PUNKTFUNK_PERF: push = descriptor
|
||||||
// build + buffersrc (the per-frame DRM→VA import happens inside hwmap on the pull path),
|
// build + buffersrc (the per-frame DRM→VA import happens inside hwmap on the pull path),
|
||||||
// pull = buffersink (VPP CSC + any sync), send = avcodec_send_frame. One line per ~2 s.
|
// pull = buffersink (VPP CSC + any sync), send = avcodec_send_frame. One line per ~2 s.
|
||||||
let sample = crate::config::config().perf && self.frames % 120 == 0;
|
let sample = pf_host_config::config().perf && self.frames % 120 == 0;
|
||||||
self.frames += 1;
|
self.frames += 1;
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let t_push: std::time::Duration;
|
let t_push: std::time::Duration;
|
||||||
+2
-2
@@ -3,9 +3,9 @@
|
|||||||
//! when the PyroWave backend arrived so the two don't fork copies.
|
//! when the PyroWave backend arrived so the two don't fork copies.
|
||||||
// Every unsafe block carries a `// SAFETY:` proof (parent module enforces it).
|
// Every unsafe block carries a `// SAFETY:` proof (parent module enforces it).
|
||||||
|
|
||||||
use crate::capture::PixelFormat;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
|
use pf_frame::PixelFormat;
|
||||||
|
|
||||||
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
|
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
|
||||||
vk::ImageSubresourceRange {
|
vk::ImageSubresourceRange {
|
||||||
@@ -74,7 +74,7 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
|||||||
device: &ash::Device,
|
device: &ash::Device,
|
||||||
ext_fd: &ash::khr::external_memory_fd::Device,
|
ext_fd: &ash::khr::external_memory_fd::Device,
|
||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
d: &crate::capture::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
+10
-10
@@ -11,10 +11,10 @@
|
|||||||
#![allow(clippy::too_many_arguments)]
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
use super::vk_util::{color_range, find_mem, make_plain_image, make_view, pixel_to_vk};
|
use super::vk_util::{color_range, find_mem, make_plain_image, make_view, pixel_to_vk};
|
||||||
use crate::capture::{CapturedFrame, FramePayload};
|
use crate::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::encode::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
@@ -729,7 +729,7 @@ impl VulkanVideoEncoder {
|
|||||||
&mut self,
|
&mut self,
|
||||||
slot: usize,
|
slot: usize,
|
||||||
compute_cmd: vk::CommandBuffer,
|
compute_cmd: vk::CommandBuffer,
|
||||||
cursor: Option<&crate::capture::CursorOverlay>,
|
cursor: Option<&pf_frame::CursorOverlay>,
|
||||||
) -> Result<[i32; 4]> {
|
) -> Result<[i32; 4]> {
|
||||||
let dev = self.device.clone();
|
let dev = self.device.clone();
|
||||||
let img = self.frames[slot].cursor_img;
|
let img = self.frames[slot].cursor_img;
|
||||||
@@ -837,7 +837,7 @@ impl VulkanVideoEncoder {
|
|||||||
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys.
|
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys.
|
||||||
unsafe fn import_dmabuf(
|
unsafe fn import_dmabuf(
|
||||||
&self,
|
&self,
|
||||||
d: &crate::capture::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
@@ -850,7 +850,7 @@ impl VulkanVideoEncoder {
|
|||||||
/// true only on a first import (caller uses UNDEFINED old-layout to preserve modifier-tiled data).
|
/// true only on a first import (caller uses UNDEFINED old-layout to preserve modifier-tiled data).
|
||||||
unsafe fn import_cached(
|
unsafe fn import_cached(
|
||||||
&mut self,
|
&mut self,
|
||||||
d: &crate::capture::DmabufFrame,
|
d: &pf_frame::DmabufFrame,
|
||||||
cw: u32,
|
cw: u32,
|
||||||
ch: u32,
|
ch: u32,
|
||||||
) -> Result<(vk::Image, vk::ImageView, bool)> {
|
) -> Result<(vk::Image, vk::ImageView, bool)> {
|
||||||
@@ -2680,8 +2680,8 @@ unsafe fn build_parameters_av1(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{build_h265_rps_s0, pick_recovery_slot, VulkanVideoEncoder};
|
use super::{build_h265_rps_s0, pick_recovery_slot, VulkanVideoEncoder};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::{Codec, Encoder};
|
||||||
use crate::encode::{Codec, Encoder};
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
|
|
||||||
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
|
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
|
||||||
/// slots never qualify.
|
/// slots never qualify.
|
||||||
@@ -2761,7 +2761,7 @@ mod tests {
|
|||||||
/// the reference-slot RFI end-to-end; returns the AUs. Wire frame [`SMOKE_LOST`] is "lost", one
|
/// the reference-slot RFI end-to-end; returns the AUs. Wire frame [`SMOKE_LOST`] is "lost", one
|
||||||
/// normal P referencing it is still encoded (the in-flight window), then frame [`SMOKE_ANCHOR`]
|
/// normal P referencing it is still encoded (the in-flight window), then frame [`SMOKE_ANCHOR`]
|
||||||
/// is the clean recovery anchor referencing pre-loss frame 3 (no IDR).
|
/// is the clean recovery anchor referencing pre-loss frame 3 (no IDR).
|
||||||
fn run_smoke(codec: Codec) -> Vec<crate::encode::EncodedFrame> {
|
fn run_smoke(codec: Codec) -> Vec<crate::EncodedFrame> {
|
||||||
let env_dim = |k: &str, d: u32| {
|
let env_dim = |k: &str, d: u32| {
|
||||||
std::env::var(k)
|
std::env::var(k)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -2782,7 +2782,7 @@ mod tests {
|
|||||||
[120, 200, 80, 255],
|
[120, 200, 80, 255],
|
||||||
[80, 120, 200, 255],
|
[80, 120, 200, 255],
|
||||||
];
|
];
|
||||||
let mut aus: Vec<crate::encode::EncodedFrame> = Vec::new();
|
let mut aus: Vec<crate::EncodedFrame> = Vec::new();
|
||||||
for (i, c) in colors.iter().enumerate() {
|
for (i, c) in colors.iter().enumerate() {
|
||||||
if i == SMOKE_ANCHOR {
|
if i == SMOKE_ANCHOR {
|
||||||
// The client reports wire frame SMOKE_LOST lost → the next frame must re-anchor
|
// The client reports wire frame SMOKE_LOST lost → the next frame must re-anchor
|
||||||
@@ -2836,7 +2836,7 @@ mod tests {
|
|||||||
/// concealment the client's freeze hides) and NONE at the anchor — a complaint about the
|
/// concealment the client's freeze hides) and NONE at the anchor — a complaint about the
|
||||||
/// anchor's reference (frame 3 / POC 3) means reference retention regressed and the "clean"
|
/// anchor's reference (frame 3 / POC 3) means reference retention regressed and the "clean"
|
||||||
/// re-anchor ships corruption.
|
/// re-anchor ships corruption.
|
||||||
fn dump_smoke(aus: &[crate::encode::EncodedFrame], ext: &str) {
|
fn dump_smoke(aus: &[crate::EncodedFrame], ext: &str) {
|
||||||
let Ok(home) = std::env::var("HOME") else {
|
let Ok(home) = std::env::var("HOME") else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
+1
-1
@@ -125,7 +125,7 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
|||||||
// when the GPU advertises custom-VBV support — else keep the preset default.
|
// when the GPU advertises custom-VBV support — else keep the preset default.
|
||||||
if c.custom_vbv {
|
if c.custom_vbv {
|
||||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||||
let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::vbv_frames_env())
|
||||||
.clamp(1.0, u32::MAX as f64) as u32;
|
.clamp(1.0, u32::MAX as f64) as u32;
|
||||||
cfg.rcParams.vbvBufferSize = vbv;
|
cfg.rcParams.vbvBufferSize = vbv;
|
||||||
cfg.rcParams.vbvInitialDelay = vbv;
|
cfg.rcParams.vbvInitialDelay = vbv;
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{EncodedFrame, Encoder};
|
use super::{EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{bail, ensure, Context, Result};
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
use openh264::encoder::{
|
use openh264::encoder::{
|
||||||
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
|
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
|
||||||
@@ -20,6 +19,7 @@ use openh264::encoder::{
|
|||||||
};
|
};
|
||||||
use openh264::formats::YUVSlices;
|
use openh264::formats::YUVSlices;
|
||||||
use openh264::OpenH264API;
|
use openh264::OpenH264API;
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
pub struct OpenH264Encoder {
|
pub struct OpenH264Encoder {
|
||||||
@@ -258,7 +258,7 @@ fn num_threads() -> u16 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
|
|
||||||
/// The BT.709 limited-range anchor points: reference white → (235,128,128), black →
|
/// The BT.709 limited-range anchor points: reference white → (235,128,128), black →
|
||||||
/// (16,128,128), pure red's Cr must hit the positive extreme 240 (it does exactly:
|
/// (16,128,128), pure red's Cr must hit the positive extreme 240 (it does exactly:
|
||||||
+11
-11
@@ -47,8 +47,8 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
@@ -1334,7 +1334,7 @@ impl AmfEncoder {
|
|||||||
/// same shape every backend ships. Shared by [`apply_static_props`](Self::apply_static_props)
|
/// same shape every backend ships. Shared by [`apply_static_props`](Self::apply_static_props)
|
||||||
/// and [`Encoder::reconfigure_bitrate`] so a dynamic retarget rescales the buffer it opened with.
|
/// and [`Encoder::reconfigure_bitrate`] so a dynamic retarget rescales the buffer it opened with.
|
||||||
fn vbv_bits(&self, bps: u64) -> i64 {
|
fn vbv_bits(&self, bps: u64) -> i64 {
|
||||||
((bps as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
((bps as f64 / self.fps.max(1) as f64) * crate::vbv_frames_env())
|
||||||
.clamp(1.0, i32::MAX as f64) as i64
|
.clamp(1.0, i32::MAX as f64) as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1777,7 +1777,7 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
|||||||
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
|
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
|
||||||
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
|
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
|
||||||
/// the honest per-codec answer — read *before* the Welcome by
|
/// the honest per-codec answer — read *before* the Welcome by
|
||||||
/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's
|
/// [`crate::can_encode_10bit`] so the negotiated bit depth matches what the session's
|
||||||
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
|
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
|
||||||
/// open bails on it too).
|
/// open bails on it too).
|
||||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||||
@@ -1881,8 +1881,8 @@ fn selected_adapter_device() -> Option<ID3D11Device> {
|
|||||||
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
||||||
// fills `device` only on success. Everything drops with its COM wrapper.
|
// fills `device` only on success. Everything drops with its COM wrapper.
|
||||||
unsafe {
|
unsafe {
|
||||||
let adapter: Option<IDXGIAdapter1> = crate::win_adapter::resolve_render_adapter_luid()
|
let adapter: Option<IDXGIAdapter1> =
|
||||||
.and_then(|luid| {
|
pf_gpu::resolve_render_adapter_luid().and_then(|luid| {
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
|
||||||
factory.EnumAdapterByLuid(luid).ok()
|
factory.EnumAdapterByLuid(luid).ok()
|
||||||
});
|
});
|
||||||
@@ -2785,7 +2785,7 @@ mod tests {
|
|||||||
height: h,
|
height: h,
|
||||||
pts_ns: 1 + i as u64,
|
pts_ns: 1 + i as u64,
|
||||||
format: fmt,
|
format: fmt,
|
||||||
payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
}),
|
}),
|
||||||
@@ -2856,8 +2856,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
drop(native);
|
drop(native);
|
||||||
|
|
||||||
let mut ffmpeg = crate::encode::ffmpeg_win::FfmpegWinEncoder::open(
|
let mut ffmpeg = crate::ffmpeg_win::FfmpegWinEncoder::open(
|
||||||
crate::encode::ffmpeg_win::WinVendor::Amf,
|
crate::ffmpeg_win::WinVendor::Amf,
|
||||||
Codec::H265,
|
Codec::H265,
|
||||||
PixelFormat::Nv12,
|
PixelFormat::Nv12,
|
||||||
w,
|
w,
|
||||||
@@ -2970,7 +2970,7 @@ mod tests {
|
|||||||
height: h,
|
height: h,
|
||||||
pts_ns: base + i as u64,
|
pts_ns: base + i as u64,
|
||||||
format: PixelFormat::Nv12,
|
format: PixelFormat::Nv12,
|
||||||
payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
}),
|
}),
|
||||||
@@ -3111,7 +3111,7 @@ mod tests {
|
|||||||
height: h,
|
height: h,
|
||||||
pts_ns: 1 + i as u64,
|
pts_ns: 1 + i as u64,
|
||||||
format: PixelFormat::P010,
|
format: PixelFormat::P010,
|
||||||
payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
}),
|
}),
|
||||||
@@ -3258,7 +3258,7 @@ mod tests {
|
|||||||
height: h,
|
height: h,
|
||||||
pts_ns: i,
|
pts_ns: i,
|
||||||
format: PixelFormat::Nv12,
|
format: PixelFormat::Nv12,
|
||||||
payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame {
|
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||||
texture: tex.clone(),
|
texture: tex.clone(),
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
}),
|
}),
|
||||||
+2
-2
@@ -41,11 +41,11 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use ffmpeg::format::Pixel;
|
use ffmpeg::format::Pixel;
|
||||||
use ffmpeg::{codec, encoder, Dictionary};
|
use ffmpeg::{codec, encoder, Dictionary};
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||||
use std::os::raw::{c_int, c_uint, c_void};
|
use std::os::raw::{c_int, c_uint, c_void};
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
use windows::core::Interface;
|
use windows::core::Interface;
|
||||||
@@ -122,7 +122,7 @@ impl WinVendor {
|
|||||||
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
||||||
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
||||||
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||||
crate::config::config()
|
pf_host_config::config()
|
||||||
.zerocopy
|
.zerocopy
|
||||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||||
}
|
}
|
||||||
+13
-13
@@ -41,8 +41,8 @@ use super::nvenc_core::{
|
|||||||
};
|
};
|
||||||
use super::nvenc_status;
|
use super::nvenc_status;
|
||||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
@@ -321,7 +321,7 @@ fn retrieve_loop(
|
|||||||
work_rx: mpsc::Receiver<RetrieveJob>,
|
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||||
done_tx: mpsc::Sender<RetrieveDone>,
|
done_tx: mpsc::Sender<RetrieveDone>,
|
||||||
) {
|
) {
|
||||||
crate::native::boost_thread_priority(false);
|
pf_frame::thread_qos::boost_thread_priority(false);
|
||||||
while let Ok(job) = work_rx.recv() {
|
while let Ok(job) = work_rx.recv() {
|
||||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
||||||
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
||||||
@@ -1250,23 +1250,23 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
let is_idr = flags != 0 || opening;
|
let is_idr = flags != 0 || opening;
|
||||||
let mastering_sei = self
|
let mastering_sei = self
|
||||||
.hdr_meta
|
.hdr_meta
|
||||||
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
|
.map(|m| pf_frame::hdr::hevc_mastering_display_sei(&m));
|
||||||
let cll_sei = self
|
let cll_sei = self
|
||||||
.hdr_meta
|
.hdr_meta
|
||||||
.map(|m| crate::hdr::hevc_content_light_level_sei(&m));
|
.map(|m| pf_frame::hdr::hevc_content_light_level_sei(&m));
|
||||||
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
||||||
if is_idr && self.hdr {
|
if is_idr && self.hdr {
|
||||||
if let Some(p) = mastering_sei.as_ref() {
|
if let Some(p) = mastering_sei.as_ref() {
|
||||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||||
payloadSize: p.len() as u32,
|
payloadSize: p.len() as u32,
|
||||||
payloadType: crate::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
payloadType: pf_frame::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||||
payload: p.as_ptr() as *mut u8,
|
payload: p.as_ptr() as *mut u8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(p) = cll_sei.as_ref() {
|
if let Some(p) = cll_sei.as_ref() {
|
||||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||||
payloadSize: p.len() as u32,
|
payloadSize: p.len() as u32,
|
||||||
payloadType: crate::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
payloadType: pf_frame::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
||||||
payload: p.as_ptr() as *mut u8,
|
payload: p.as_ptr() as *mut u8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1568,7 +1568,7 @@ impl Drop for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
|
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
|
||||||
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
|
/// HEVC-only; the result is cached by the caller ([`crate::can_encode_444`]) and read *before*
|
||||||
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
|
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
|
||||||
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
|
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
|
||||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||||
@@ -1580,7 +1580,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
|
|
||||||
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
|
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
|
||||||
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
|
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
|
||||||
/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the
|
/// The result is cached by the caller ([`crate::can_encode_10bit`]) and read *before* the
|
||||||
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
|
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
|
||||||
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
|
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
|
||||||
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
|
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
|
||||||
@@ -1622,8 +1622,8 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
|||||||
// Probe on the SELECTED render adapter — the GPU the session will actually encode on
|
// Probe on the SELECTED render adapter — the GPU the session will actually encode on
|
||||||
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
|
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
|
||||||
// (NULL) can be the *other* GPU on a hybrid box, answering for hardware we won't use.
|
// (NULL) can be the *other* GPU on a hybrid box, answering for hardware we won't use.
|
||||||
let adapter: Option<IDXGIAdapter1> = crate::win_adapter::resolve_render_adapter_luid()
|
let adapter: Option<IDXGIAdapter1> =
|
||||||
.and_then(|luid| {
|
pf_gpu::resolve_render_adapter_luid().and_then(|luid| {
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
|
||||||
factory.EnumAdapterByLuid(luid).ok()
|
factory.EnumAdapterByLuid(luid).ok()
|
||||||
});
|
});
|
||||||
@@ -1692,7 +1692,7 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
|
use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||||
};
|
};
|
||||||
@@ -1760,7 +1760,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let adapter = adapter.expect("no hardware DXGI adapter");
|
let adapter = adapter.expect("no hardware DXGI adapter");
|
||||||
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
|
let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device");
|
||||||
|
|
||||||
let bytes = probe_pattern(W as usize, H as usize);
|
let bytes = probe_pattern(W as usize, H as usize);
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
@@ -1860,7 +1860,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let adapter = adapter.expect("no hardware DXGI adapter");
|
let adapter = adapter.expect("no hardware DXGI adapter");
|
||||||
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
|
let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device");
|
||||||
|
|
||||||
let bytes = probe_pattern(W as usize, H as usize);
|
let bytes = probe_pattern(W as usize, H as usize);
|
||||||
let init = D3D11_SUBRESOURCE_DATA {
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
@@ -2,21 +2,27 @@
|
|||||||
//! B-frames off. The backend is per-GPU: NVENC on NVIDIA (`*_nvenc`, accepts `bgr0` and does
|
//! B-frames off. The backend is per-GPU: NVENC on NVIDIA (`*_nvenc`, accepts `bgr0` and does
|
||||||
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
||||||
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
||||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`].
|
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
||||||
|
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
||||||
|
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
||||||
|
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that
|
||||||
|
// uses them (mirrors the host crate root's allow before the extraction).
|
||||||
|
#![allow(dead_code)]
|
||||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||||
// program). As a parent module this also covers the child modules (encode::windows/linux::*).
|
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::capture::{CapturedFrame, PixelFormat};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use pf_frame::{CapturedFrame, PixelFormat};
|
||||||
|
|
||||||
|
#[path = "enc/codec.rs"]
|
||||||
mod codec;
|
mod codec;
|
||||||
pub(crate) use codec::*;
|
pub use codec::*;
|
||||||
|
|
||||||
impl Codec {
|
impl Codec {
|
||||||
/// The `quic` codec bitfield the host can currently **emit** on the punktfunk/1 native path,
|
/// The `quic` codec bitfield the host can currently **emit** on the punktfunk/1 native path,
|
||||||
/// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
|
/// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
|
||||||
/// Moonlight ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software
|
/// Moonlight (the host `gamestream::serverinfo`), in `quic::CODEC_*` bits. The GPU-less software
|
||||||
/// encoder (openh264) produces H.264 only; the probed backends (Linux VAAPI, Windows AMF/QSV)
|
/// encoder (openh264) produces H.264 only; the probed backends (Linux VAAPI, Windows AMF/QSV)
|
||||||
/// advertise exactly what the GPU encodes ([`vaapi_codec_support`] / [`windows_codec_support`] —
|
/// advertise exactly what the GPU encodes ([`vaapi_codec_support`] / [`windows_codec_support`] —
|
||||||
/// AV1 encode is narrow, an old iGPU might lack HEVC); NVENC keeps the Moonlight-validated
|
/// AV1 encode is narrow, an old iGPU might lack HEVC); NVENC keeps the Moonlight-validated
|
||||||
@@ -30,12 +36,12 @@ impl Codec {
|
|||||||
// client explicitly prefers it (resolve_codec ignores the bit in its ladder). Advertised
|
// client explicitly prefers it (resolve_codec ignores the bit in its ladder). Advertised
|
||||||
// whenever the backend could open: AMD/Intel capture hands raw dmabufs it imports
|
// whenever the backend could open: AMD/Intel capture hands raw dmabufs it imports
|
||||||
// directly, and an NVIDIA-auto host's PyroWave sessions flip capture to CPU RGB
|
// directly, and an NVIDIA-auto host's PyroWave sessions flip capture to CPU RGB
|
||||||
// per-session instead ([`crate::session_plan::SessionPlan::output_format`]) — the EGL→CUDA
|
// per-session instead (the host `session_plan::SessionPlan::output_format`) — the EGL→CUDA
|
||||||
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
||||||
// keeps the bit off (no Vulkan device to open).
|
// keeps the bit off (no Vulkan device to open).
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
let pyro = if !matches!(
|
let pyro = if !matches!(
|
||||||
crate::config::config().encoder_pref.as_str(),
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
"software" | "sw" | "openh264"
|
"software" | "sw" | "openh264"
|
||||||
) {
|
) {
|
||||||
punktfunk_core::quic::CODEC_PYROWAVE
|
punktfunk_core::quic::CODEC_PYROWAVE
|
||||||
@@ -53,7 +59,7 @@ impl Codec {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
if matches!(
|
if matches!(
|
||||||
crate::config::config().encoder_pref.as_str(),
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
"software" | "sw" | "openh264"
|
"software" | "sw" | "openh264"
|
||||||
) {
|
) {
|
||||||
return punktfunk_core::quic::CODEC_H264;
|
return punktfunk_core::quic::CODEC_H264;
|
||||||
@@ -83,7 +89,7 @@ impl Codec {
|
|||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = GPU_SUPERSET;
|
let _ = GPU_SUPERSET;
|
||||||
match crate::config::config().encoder_pref.as_str() {
|
match pf_host_config::config().encoder_pref.as_str() {
|
||||||
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
|
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
|
||||||
_ => punktfunk_core::quic::CODEC_HEVC,
|
_ => punktfunk_core::quic::CODEC_HEVC,
|
||||||
}
|
}
|
||||||
@@ -128,24 +134,24 @@ pub fn open_video(
|
|||||||
// mirroring its dispatch, which went stale the moment a backend gained an internal fallback
|
// mirroring its dispatch, which went stale the moment a backend gained an internal fallback
|
||||||
// (the default-on Vulkan Video path falls back to VAAPI on a failed open, and a dispatch
|
// (the default-on Vulkan Video path falls back to VAAPI on a failed open, and a dispatch
|
||||||
// mirror would report "vaapi" for every Vulkan session or vice versa). The GPU identity is the
|
// mirror would report "vaapi" for every Vulkan session or vice versa). The GPU identity is the
|
||||||
// same selection the capturer was created on ([`crate::gpu::selected_gpu`]). Dropping the
|
// same selection the capturer was created on ([`pf_gpu::selected_gpu`]). Dropping the
|
||||||
// returned encoder ends the record, so the live count is correct by construction.
|
// returned encoder ends the record, so the live count is correct by construction.
|
||||||
let gpu = if backend == "software" {
|
let gpu = if backend == "software" {
|
||||||
crate::gpu::ActiveGpu {
|
pf_gpu::ActiveGpu {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
name: "CPU (openh264)".into(),
|
name: "CPU (openh264)".into(),
|
||||||
vendor_id: 0,
|
vendor_id: 0,
|
||||||
backend,
|
backend,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match crate::gpu::selected_gpu() {
|
match pf_gpu::selected_gpu() {
|
||||||
Some(sel) => crate::gpu::ActiveGpu {
|
Some(sel) => pf_gpu::ActiveGpu {
|
||||||
id: sel.info.id,
|
id: sel.info.id,
|
||||||
name: sel.info.name,
|
name: sel.info.name,
|
||||||
vendor_id: sel.info.vendor_id,
|
vendor_id: sel.info.vendor_id,
|
||||||
backend,
|
backend,
|
||||||
},
|
},
|
||||||
None => crate::gpu::ActiveGpu {
|
None => pf_gpu::ActiveGpu {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
name: "GPU".into(),
|
name: "GPU".into(),
|
||||||
vendor_id: 0,
|
vendor_id: 0,
|
||||||
@@ -155,15 +161,15 @@ pub fn open_video(
|
|||||||
};
|
};
|
||||||
Ok(Box::new(TrackedEncoder {
|
Ok(Box::new(TrackedEncoder {
|
||||||
inner,
|
inner,
|
||||||
_session: crate::gpu::session_begin(gpu),
|
_session: pf_gpu::session_begin(gpu),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ties the [`crate::gpu`] live-session record to the encoder's lifetime; pure delegation
|
/// Ties the `pf_gpu` live-session record to the encoder's lifetime; pure delegation
|
||||||
/// otherwise.
|
/// otherwise.
|
||||||
struct TrackedEncoder {
|
struct TrackedEncoder {
|
||||||
inner: Box<dyn Encoder>,
|
inner: Box<dyn Encoder>,
|
||||||
_session: crate::gpu::ActiveSession,
|
_session: pf_gpu::ActiveSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Encoder for TrackedEncoder {
|
impl Encoder for TrackedEncoder {
|
||||||
@@ -262,7 +268,7 @@ fn open_video_backend(
|
|||||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||||
// its errors crisply instead of silently trying the other).
|
// its errors crisply instead of silently trying the other).
|
||||||
let pref = crate::config::config().encoder_pref.as_str();
|
let pref = pf_host_config::config().encoder_pref.as_str();
|
||||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||||
@@ -406,11 +412,11 @@ fn open_video_backend(
|
|||||||
// explicit PUNKTFUNK_ENCODER contradicts the GPU the pipeline sits on (e.g. `nvenc` forced
|
// explicit PUNKTFUNK_ENCODER contradicts the GPU the pipeline sits on (e.g. `nvenc` forced
|
||||||
// while the web-console preference pins the Intel iGPU) — the open below will then fail on
|
// while the web-console preference pins the Intel iGPU) — the open below will then fail on
|
||||||
// a wrong-vendor device; say why up front instead of leaving an opaque encoder error.
|
// a wrong-vendor device; say why up front instead of leaving an opaque encoder error.
|
||||||
if let Some(sel) = crate::gpu::selected_gpu() {
|
if let Some(sel) = pf_gpu::selected_gpu() {
|
||||||
let mismatched = match backend {
|
let mismatched = match backend {
|
||||||
WindowsBackend::Nvenc => sel.info.vendor_id != crate::gpu::VENDOR_NVIDIA,
|
WindowsBackend::Nvenc => sel.info.vendor_id != pf_gpu::VENDOR_NVIDIA,
|
||||||
WindowsBackend::Amf => sel.info.vendor_id != crate::gpu::VENDOR_AMD,
|
WindowsBackend::Amf => sel.info.vendor_id != pf_gpu::VENDOR_AMD,
|
||||||
WindowsBackend::Qsv => sel.info.vendor_id != crate::gpu::VENDOR_INTEL,
|
WindowsBackend::Qsv => sel.info.vendor_id != pf_gpu::VENDOR_INTEL,
|
||||||
WindowsBackend::Software => false,
|
WindowsBackend::Software => false,
|
||||||
};
|
};
|
||||||
if mismatched {
|
if mismatched {
|
||||||
@@ -680,14 +686,14 @@ fn nvidia_present() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `auto` Linux backend decision, shared by [`open_video`] and [`linux_zero_copy_is_vaapi`]:
|
/// The `auto` Linux backend decision, shared by [`open_video`] and [`linux_zero_copy_is_vaapi`]:
|
||||||
/// a manual web-console GPU preference (when that GPU is present — [`crate::gpu::manual_selection`])
|
/// a manual web-console GPU preference (when that GPU is present — [`pf_gpu::manual_selection`])
|
||||||
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
||||||
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
||||||
/// today's NVIDIA-presence probe, unchanged.
|
/// today's NVIDIA-presence probe, unchanged.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn linux_auto_is_vaapi() -> bool {
|
fn linux_auto_is_vaapi() -> bool {
|
||||||
if let Some(g) = crate::gpu::manual_selection() {
|
if let Some(g) = pf_gpu::manual_selection() {
|
||||||
if g.vendor_id == crate::gpu::VENDOR_NVIDIA {
|
if g.vendor_id == pf_gpu::VENDOR_NVIDIA {
|
||||||
return !nvidia_present();
|
return !nvidia_present();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -699,7 +705,7 @@ fn linux_auto_is_vaapi() -> bool {
|
|||||||
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
||||||
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
pub(crate) fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
pub fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
||||||
pyrowave::capture_modifiers(fourcc)
|
pyrowave::capture_modifiers(fourcc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +714,7 @@ pub(crate) fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
|||||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||||
match crate::config::config().encoder_pref.as_str() {
|
match pf_host_config::config().encoder_pref.as_str() {
|
||||||
"nvenc" | "nvidia" | "cuda" => false,
|
"nvenc" | "nvidia" | "cuda" => false,
|
||||||
"vaapi" | "amd" | "intel" => true,
|
"vaapi" | "amd" | "intel" => true,
|
||||||
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
||||||
@@ -788,7 +794,7 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
|||||||
// Cached per selected GPU (was a process-lifetime OnceLock): a web-console preference change
|
// Cached per selected GPU (was a process-lifetime OnceLock): a web-console preference change
|
||||||
// re-probes on the newly selected adapter before the next Welcome.
|
// re-probes on the newly selected adapter before the next Welcome.
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||||
let key = crate::gpu::selection_key();
|
let key = pf_gpu::selection_key();
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||||
return *v;
|
return *v;
|
||||||
@@ -870,7 +876,7 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
|||||||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||||||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||||
let key = (crate::gpu::selection_key(), codec.label());
|
let key = (pf_gpu::selection_key(), codec.label());
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||||
return *v;
|
return *v;
|
||||||
@@ -918,13 +924,13 @@ pub fn can_encode_10bit(_codec: Codec) -> bool {
|
|||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||||||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
||||||
// SELECTED render adapter (crate::gpu — web-console preference / env pin / max VRAM), so the
|
// SELECTED render adapter (pf_gpu — web-console preference / env pin / max VRAM), so the
|
||||||
// backend always matches the GPU the capture ring and virtual display sit on.
|
// backend always matches the GPU the capture ring and virtual display sit on.
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum WindowsBackend {
|
pub enum WindowsBackend {
|
||||||
Nvenc,
|
Nvenc,
|
||||||
Amf,
|
Amf,
|
||||||
Qsv,
|
Qsv,
|
||||||
@@ -943,9 +949,9 @@ enum GpuVendor {
|
|||||||
/// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so
|
/// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so
|
||||||
/// both agree.
|
/// both agree.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
pub fn windows_resolved_backend() -> WindowsBackend {
|
||||||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||||
match crate::config::config().encoder_pref.as_str() {
|
match pf_host_config::config().encoder_pref.as_str() {
|
||||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
||||||
"amf" | "amd" => WindowsBackend::Amf,
|
"amf" | "amd" => WindowsBackend::Amf,
|
||||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
"qsv" | "intel" => WindowsBackend::Qsv,
|
||||||
@@ -961,35 +967,35 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
|||||||
|
|
||||||
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
||||||
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
|
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
|
||||||
/// wants CPU staging. This is the single source for [`crate::capture::OutputFormat`]'s `gpu` bit:
|
/// wants CPU staging. This is the single source for [`pf_frame::OutputFormat`]'s `gpu` bit:
|
||||||
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
|
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
|
||||||
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
|
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
|
||||||
/// frames are GPU-resident (plan §2.4 / §W4).
|
/// frames are GPU-resident (plan §2.4 / §W4).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
pub fn resolved_backend_is_gpu() -> bool {
|
||||||
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||||||
}
|
}
|
||||||
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
||||||
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
pub fn resolved_backend_is_gpu() -> bool {
|
||||||
!matches!(
|
!matches!(
|
||||||
crate::config::config().encoder_pref.as_str(),
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
"software" | "sw" | "openh264"
|
"software" | "sw" | "openh264"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
|
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
|
||||||
/// the *encoder* half of the 4:4:4 capture gate ([`crate::capture::capturer_supports_444`]). Only Windows
|
/// the *encoder* half of the 4:4:4 capture gate (the host capture `capturer_supports_444`). Only Windows
|
||||||
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
|
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
|
||||||
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
|
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
|
||||||
/// backend, so the gate never consults this there.
|
/// backend, so the gate never consults this there.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
pub fn resolved_backend_ingests_rgb_444() -> bool {
|
||||||
windows_resolved_backend() == WindowsBackend::Nvenc
|
windows_resolved_backend() == WindowsBackend::Nvenc
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
pub fn resolved_backend_ingests_rgb_444() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,7 +1013,7 @@ pub fn windows_backend_is_probed() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
/// Detect the encode-GPU vendor from the **selected render adapter** ([`pf_gpu::selected_gpu`]:
|
||||||
/// web-console preference > `PUNKTFUNK_RENDER_ADAPTER` > max VRAM) — the same adapter the capture
|
/// web-console preference > `PUNKTFUNK_RENDER_ADAPTER` > max VRAM) — the same adapter the capture
|
||||||
/// ring and the IddCx render pin sit on, so the encoder backend can never disagree with where the
|
/// ring and the IddCx render pin sit on, so the encoder backend can never disagree with where the
|
||||||
/// captured frames live. The old first-DXGI-adapter scan did exactly that on hybrid boxes: adapter
|
/// captured frames live. The old first-DXGI-adapter scan did exactly that on hybrid boxes: adapter
|
||||||
@@ -1019,18 +1025,15 @@ pub fn windows_backend_is_probed() -> bool {
|
|||||||
fn windows_gpu_vendor() -> Option<GpuVendor> {
|
fn windows_gpu_vendor() -> Option<GpuVendor> {
|
||||||
fn by_id(vendor_id: u32) -> Option<GpuVendor> {
|
fn by_id(vendor_id: u32) -> Option<GpuVendor> {
|
||||||
match vendor_id {
|
match vendor_id {
|
||||||
crate::gpu::VENDOR_NVIDIA => Some(GpuVendor::Nvidia),
|
pf_gpu::VENDOR_NVIDIA => Some(GpuVendor::Nvidia),
|
||||||
crate::gpu::VENDOR_AMD => Some(GpuVendor::Amd),
|
pf_gpu::VENDOR_AMD => Some(GpuVendor::Amd),
|
||||||
crate::gpu::VENDOR_INTEL => Some(GpuVendor::Intel),
|
pf_gpu::VENDOR_INTEL => Some(GpuVendor::Intel),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sel = crate::gpu::selected_gpu()?;
|
let sel = pf_gpu::selected_gpu()?;
|
||||||
by_id(sel.info.vendor_id).or_else(|| {
|
by_id(sel.info.vendor_id)
|
||||||
crate::gpu::enumerate()
|
.or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id)))
|
||||||
.iter()
|
|
||||||
.find_map(|g| by_id(g.vendor_id))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
||||||
@@ -1050,7 +1053,7 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
||||||
let backend = windows_resolved_backend();
|
let backend = windows_resolved_backend();
|
||||||
let key = format!("{backend:?}:{}", crate::gpu::selection_key());
|
let key = format!("{backend:?}:{}", pf_gpu::selection_key());
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(c) = cache.lock().unwrap().get(&key) {
|
if let Some(c) = cache.lock().unwrap().get(&key) {
|
||||||
return *c;
|
return *c;
|
||||||
@@ -1098,7 +1101,7 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
/// degrading a live sibling's encode. NVENC is the only backend with hard session caps today
|
/// degrading a live sibling's encode. NVENC is the only backend with hard session caps today
|
||||||
/// (GeForce consumer limit); AMF/QSV equivalents follow the same seam when they grow accounting.
|
/// (GeForce consumer limit); AMF/QSV equivalents follow the same seam when they grow accounting.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn can_open_another_session() -> bool {
|
pub fn can_open_another_session() -> bool {
|
||||||
#[cfg(feature = "nvenc")]
|
#[cfg(feature = "nvenc")]
|
||||||
{
|
{
|
||||||
nvenc::can_open_another_session()
|
nvenc::can_open_another_session()
|
||||||
@@ -1111,62 +1114,65 @@ pub(crate) fn can_open_another_session() -> bool {
|
|||||||
|
|
||||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
||||||
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
||||||
// `crate::encode::*` module names flat.
|
// `crate::*` module names flat.
|
||||||
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
|
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
|
||||||
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
|
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "encode/windows/amf.rs"]
|
#[path = "enc/windows/amf.rs"]
|
||||||
mod amf;
|
mod amf;
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
#[path = "enc/windows/ffmpeg_win.rs"]
|
||||||
mod ffmpeg_win;
|
mod ffmpeg_win;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "enc/linux/mod.rs"]
|
||||||
mod linux;
|
mod linux;
|
||||||
// Direct-SDK NVENC on Linux (CUDA input; design/linux-direct-nvenc.md) — real RFI + recovery anchor
|
// Direct-SDK NVENC on Linux (CUDA input; design/linux-direct-nvenc.md) — real RFI + recovery anchor
|
||||||
// + reset() lever the libavcodec `linux::NvencEncoder` can't express. Opt-in behind
|
// + reset() lever the libavcodec `linux::NvencEncoder` can't express. Opt-in behind
|
||||||
// `PUNKTFUNK_NVENC_DIRECT` until on-glass validated; the `.so` resolves at runtime like the Windows
|
// `PUNKTFUNK_NVENC_DIRECT` until on-glass validated; the `.so` resolves at runtime like the Windows
|
||||||
// path, so `--features nvenc` stays safe on a driver-less/AMD Linux box.
|
// path, so `--features nvenc` stays safe on a driver-less/AMD Linux box.
|
||||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||||
#[path = "encode/windows/nvenc.rs"]
|
#[path = "enc/windows/nvenc.rs"]
|
||||||
mod nvenc;
|
mod nvenc;
|
||||||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||||
#[path = "encode/linux/nvenc_cuda.rs"]
|
#[path = "enc/linux/nvenc_cuda.rs"]
|
||||||
mod nvenc_cuda;
|
mod nvenc_cuda;
|
||||||
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
||||||
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
||||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||||
#[path = "encode/nvenc_status.rs"]
|
#[path = "enc/nvenc_status.rs"]
|
||||||
mod nvenc_status;
|
mod nvenc_status;
|
||||||
// Platform-agnostic direct-SDK NVENC glue (`NvStatusExt`/`nv_ok`, `codec_guid`) shared by both
|
// Platform-agnostic direct-SDK NVENC glue (`NvStatusExt`/`nv_ok`, `codec_guid`) shared by both
|
||||||
// `nvEncodeAPI` backends — the byte-identical Tier-2 leaves (plan §2.2). Sibling of `nvenc_status`.
|
// `nvEncodeAPI` backends — the byte-identical Tier-2 leaves (plan §2.2). Sibling of `nvenc_status`.
|
||||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||||
#[path = "encode/nvenc_core.rs"]
|
#[path = "enc/nvenc_core.rs"]
|
||||||
mod nvenc_core;
|
mod nvenc_core;
|
||||||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||||||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||||||
|
#[path = "enc/libav.rs"]
|
||||||
mod libav;
|
mod libav;
|
||||||
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
|
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
|
||||||
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
|
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
|
||||||
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
|
#[path = "enc/sw.rs"]
|
||||||
mod sw;
|
mod sw;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "encode/linux/vaapi.rs"]
|
#[path = "enc/linux/vaapi.rs"]
|
||||||
mod vaapi;
|
mod vaapi;
|
||||||
// Raw Vulkan Video HEVC encode on Linux (AMD/Intel; design/linux-vulkan-video-encode.md) — real RFI
|
// Raw Vulkan Video HEVC encode on Linux (AMD/Intel; design/linux-vulkan-video-encode.md) — real RFI
|
||||||
// via explicit DPB reference slots (the app owns the DPB), the open-stack twin of the direct-NVENC
|
// via explicit DPB reference slots (the app owns the DPB), the open-stack twin of the direct-NVENC
|
||||||
// path. Does an on-GPU RGB→NV12 compute CSC since capture delivers packed-RGB dmabufs. Opt-in behind
|
// path. Does an on-GPU RGB→NV12 compute CSC since capture delivers packed-RGB dmabufs. Opt-in behind
|
||||||
// `PUNKTFUNK_VULKAN_ENCODE` until on-glass validated; needs `--features vulkan-encode`.
|
// `PUNKTFUNK_VULKAN_ENCODE` until on-glass validated; needs `--features vulkan-encode`.
|
||||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
#[path = "encode/linux/vulkan_video.rs"]
|
#[path = "enc/linux/vulkan_video.rs"]
|
||||||
mod vulkan_video;
|
mod vulkan_video;
|
||||||
// Vendored `VK_KHR_video_encode_av1` bindings (host-only) — the AV1 encode structs our pinned
|
// Vendored `VK_KHR_video_encode_av1` bindings (host-only) — the AV1 encode structs our pinned
|
||||||
// `ash 0.38.0+1.3.281` predates (finalized Vulkan 1.3.290). Copied verbatim from ash-master's
|
// `ash 0.38.0+1.3.281` predates (finalized Vulkan 1.3.290). Copied verbatim from ash-master's
|
||||||
// generated code rather than bumping `ash` (which breaks the SDL/Vulkan client). Consumed by
|
// generated code rather than bumping `ash` (which breaks the SDL/Vulkan client). Consumed by
|
||||||
// `vulkan_video.rs` via `super::vk_av1_encode`.
|
// `vulkan_video.rs` via `super::vk_av1_encode`.
|
||||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||||
#[path = "encode/linux/vk_av1_encode.rs"]
|
#[path = "enc/linux/vk_av1_encode.rs"]
|
||||||
mod vk_av1_encode;
|
mod vk_av1_encode;
|
||||||
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
||||||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||||||
@@ -1174,13 +1180,13 @@ mod vk_av1_encode;
|
|||||||
target_os = "linux",
|
target_os = "linux",
|
||||||
any(feature = "vulkan-encode", feature = "pyrowave")
|
any(feature = "vulkan-encode", feature = "pyrowave")
|
||||||
))]
|
))]
|
||||||
#[path = "encode/linux/vk_util.rs"]
|
#[path = "enc/linux/vk_util.rs"]
|
||||||
mod vk_util;
|
mod vk_util;
|
||||||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md §4.3):
|
// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md §4.3):
|
||||||
// pure Vulkan compute via the vendored `pyrowave-sys`, sub-ms encode, every frame a keyframe.
|
// pure Vulkan compute via the vendored `pyrowave-sys`, sub-ms encode, every frame a keyframe.
|
||||||
// Explicit-only behind PUNKTFUNK_ENCODER=pyrowave; EXPERIMENTAL until CODEC_PYROWAVE lands.
|
// Explicit-only behind PUNKTFUNK_ENCODER=pyrowave; EXPERIMENTAL until CODEC_PYROWAVE lands.
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
#[path = "encode/linux/pyrowave.rs"]
|
#[path = "enc/linux/pyrowave.rs"]
|
||||||
mod pyrowave;
|
mod pyrowave;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# The shared media-pipeline vocabulary (plan §W6): the captured-frame types and pixel formats that
|
||||||
|
# both capture (producer) and encode (consumer) speak, plus the small pure helpers that ride the
|
||||||
|
# same seam — HDR static metadata, the metronomic-stall detector, per-thread scheduling QoS, and
|
||||||
|
# (Windows) the DXGI capture identity + D3D11 device creation. A leaf so pf-capture and pf-encode
|
||||||
|
# can depend on the vocabulary WITHOUT depending on each other.
|
||||||
|
[package]
|
||||||
|
name = "pf-frame"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
rust-version.workspace = true
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk host shared frame/format vocabulary: CapturedFrame, PixelFormat, HDR metadata, thread QoS, and the Windows DXGI capture identity."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# `FramePayload::Cuda` owns a zero-copy `DeviceBuffer`; `libc` for the per-thread `setpriority`.
|
||||||
|
pf-zerocopy = { path = "../pf-zerocopy" }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# The DXGI capture identity (`WinCaptureTarget`/`D3d11Frame`/`pack_luid`/`make_device`) + the GPU
|
||||||
|
# scheduling-priority hardening `make_device` applies, and the thread-QoS `SetThreadPriority`.
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Graphics_Dxgi",
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Graphics_Direct3D11",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
] }
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! The Windows DXGI capture identity + shared D3D11 device creation (plan §W6): the capture
|
||||||
|
//! target descriptor ([`WinCaptureTarget`]), the GPU-resident captured texture ([`D3d11Frame`]),
|
||||||
|
//! the adapter-LUID packer ([`pack_luid`]), and [`make_device`] — a fresh D3D11 device/context on
|
||||||
|
//! a chosen adapter, applying the process GPU scheduling-priority hardening. Extracted from the
|
||||||
|
//! host's `capture/windows/dxgi.rs` so the capture IDD-push path, the encode D3D11 backends, and
|
||||||
|
//! pf-vdisplay all share ONE identity type + device factory (no capture↔encode↔vdisplay cycle).
|
||||||
|
//! The win32u GPU-preference hook, the HDR/video-engine converters, and the self-tests stay in the
|
||||||
|
//! capture crate — they are capture mechanics, not shared identity.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use windows::core::Interface;
|
||||||
|
use windows::Win32::Foundation::{HMODULE, LUID};
|
||||||
|
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0};
|
||||||
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
|
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||||
|
};
|
||||||
|
use windows::Win32::Graphics::Dxgi::{IDXGIAdapter1, IDXGIDevice, IDXGIDevice1};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WinCaptureTarget {
|
||||||
|
/// Packed DXGI adapter LUID (`(HighPart << 32) | (LowPart & 0xffff_ffff)`).
|
||||||
|
pub adapter_luid: i64,
|
||||||
|
/// The output's GDI device name, e.g. `\\.\DISPLAY3`. Can CHANGE across a secure-desktop switch.
|
||||||
|
pub gdi_name: String,
|
||||||
|
/// Stable virtual-display (IddCx) target id — re-resolved to the current GDI name on every recovery.
|
||||||
|
pub target_id: u32,
|
||||||
|
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
|
||||||
|
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
|
||||||
|
/// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse).
|
||||||
|
pub wudf_pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
||||||
|
pub struct D3d11Frame {
|
||||||
|
pub texture: ID3D11Texture2D,
|
||||||
|
pub device: ID3D11Device,
|
||||||
|
}
|
||||||
|
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
||||||
|
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
||||||
|
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
|
||||||
|
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
|
||||||
|
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
|
||||||
|
// single-threaded immediate context.
|
||||||
|
unsafe impl Send for D3d11Frame {}
|
||||||
|
|
||||||
|
pub fn pack_luid(luid: LUID) -> i64 {
|
||||||
|
((luid.HighPart as i64) << 32) | (luid.LowPart as i64 & 0xffff_ffff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh D3D11 device + context on a specific adapter (driver_type UNKNOWN with an explicit
|
||||||
|
/// adapter). Used at open and on every ACCESS_LOST: a device created on one desktop cannot sustain a
|
||||||
|
/// duplication on a *different* desktop (perpetual ACCESS_LOST), so the secure-desktop switch needs a
|
||||||
|
/// device made while the thread is attached to that desktop.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `adapter` must be a live `IDXGIAdapter1` for the duration of the call. The fn calls the D3D11 /
|
||||||
|
/// DXGI FFI (`D3D11CreateDevice`, GPU scheduling-priority hardening) but forms no lasting alias to
|
||||||
|
/// `adapter`; the returned device/context are the sole owners of the new COM objects.
|
||||||
|
pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
||||||
|
let mut device: Option<ID3D11Device> = None;
|
||||||
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
|
D3D11CreateDevice(
|
||||||
|
adapter,
|
||||||
|
D3D_DRIVER_TYPE_UNKNOWN,
|
||||||
|
HMODULE::default(),
|
||||||
|
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||||
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
|
D3D11_SDK_VERSION,
|
||||||
|
Some(&mut device),
|
||||||
|
None,
|
||||||
|
Some(&mut context),
|
||||||
|
)
|
||||||
|
.context("D3D11CreateDevice")?;
|
||||||
|
let device = device.context("null D3D11 device")?;
|
||||||
|
let context = context.context("null D3D11 context")?;
|
||||||
|
|
||||||
|
// GPU scheduling hardening — the same approach Sunshine/Apollo use, reimplemented here via the
|
||||||
|
// documented D3DKMT/DXGI APIs (no GPL source copied). Our capture+encode
|
||||||
|
// shares the GPU with the streamed game; when the game saturates the GPU our process is starved of
|
||||||
|
// GPU time slices, so NVENC sits near-idle yet `lock_bitstream` waits ~20 ms for our context to be
|
||||||
|
// scheduled — capping the stream (~47 fps measured at 5K@240) and stuttering. Per-frame copy/convert
|
||||||
|
// is NOT the cause (zero-copy + thread-priority alone didn't move it); the PROCESS-level GPU
|
||||||
|
// scheduling priority class is the decisive cross-process lever. Secondary: the absolute per-device
|
||||||
|
// GPU thread priority and a 1-frame latency cap.
|
||||||
|
elevate_process_gpu_priority();
|
||||||
|
if let Ok(dxgi_dev) = device.cast::<IDXGIDevice>() {
|
||||||
|
// The absolute max GPU thread priority (0x4000001E; the same value Sunshine/Apollo use); fall back to relative +7.
|
||||||
|
if dxgi_dev.SetGPUThreadPriority(0x4000_001E).is_err()
|
||||||
|
&& dxgi_dev.SetGPUThreadPriority(7).is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("SetGPUThreadPriority failed (run as admin/SYSTEM for GPU priority)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(dxgi1) = device.cast::<IDXGIDevice1>() {
|
||||||
|
let _ = dxgi1.SetMaximumFrameLatency(1);
|
||||||
|
}
|
||||||
|
Ok((device, context))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the configured GPU scheduling-priority class from `PUNKTFUNK_GPU_PRIORITY_CLASS`
|
||||||
|
/// (`off|normal|high|realtime`, default high). `None` = leave it at the OS default (the `off` opt-out).
|
||||||
|
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, REALTIME 5.
|
||||||
|
fn configured_gpu_priority_class() -> Option<i32> {
|
||||||
|
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("off") => None,
|
||||||
|
Some("normal") => Some(2),
|
||||||
|
Some("realtime") => Some(5),
|
||||||
|
_ => Some(4), // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable SE_INC_BASE_PRIORITY on the CURRENT process token (best-effort) — the kernel gates the
|
||||||
|
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
|
||||||
|
/// token does NOT have it, which is why `elevate_process_gpu_priority` may silently no-op in a
|
||||||
|
/// restricted service context.
|
||||||
|
unsafe fn enable_inc_base_priority() {
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||||
|
use windows::Win32::Security::{
|
||||||
|
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES,
|
||||||
|
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES,
|
||||||
|
TOKEN_QUERY,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||||
|
let mut token = HANDLE::default();
|
||||||
|
if OpenProcessToken(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
|
||||||
|
&mut token,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let mut luid = LUID::default();
|
||||||
|
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
|
||||||
|
let tp = TOKEN_PRIVILEGES {
|
||||||
|
PrivilegeCount: 1,
|
||||||
|
Privileges: [LUID_AND_ATTRIBUTES {
|
||||||
|
Luid: luid,
|
||||||
|
Attributes: SE_PRIVILEGE_ENABLED,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
if AdjustTokenPrivileges(
|
||||||
|
token,
|
||||||
|
false,
|
||||||
|
Some(&tp as *const TOKEN_PRIVILEGES),
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = CloseHandle(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `gdi32!D3DKMTSetProcessSchedulingPriorityClass(process, prio)` (no stable windows-rs binding —
|
||||||
|
/// loaded by name). Returns the NTSTATUS (0 = success) or `None` if the export can't be resolved. The
|
||||||
|
/// CALLING process must hold SE_INC_BASE_PRIORITY ([`enable_inc_base_priority`]) for HIGH/REALTIME; the
|
||||||
|
/// kernel checks the caller's privilege whether the target is self or a child we created.
|
||||||
|
unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||||
|
process: windows::Win32::Foundation::HANDLE,
|
||||||
|
prio: i32,
|
||||||
|
) -> Option<i32> {
|
||||||
|
use windows::core::s;
|
||||||
|
use windows::Win32::Foundation::HANDLE;
|
||||||
|
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||||
|
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
|
||||||
|
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
|
||||||
|
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
|
||||||
|
let f: SetPrio = std::mem::transmute(p);
|
||||||
|
Some(f(process, prio))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GPU scheduling-priority hardening — the same approach as Sunshine/Apollo, independently
|
||||||
|
/// implemented via the documented D3DKMT APIs (no GPL source copied). On a
|
||||||
|
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
||||||
|
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
||||||
|
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
||||||
|
/// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT
|
||||||
|
/// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this).
|
||||||
|
/// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime`
|
||||||
|
/// (default high). Best-effort: silently no-ops under a UAC-filtered token (the process will not
|
||||||
|
/// hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a no-op).
|
||||||
|
fn elevate_process_gpu_priority() {
|
||||||
|
use std::sync::Once;
|
||||||
|
static ONCE: Once = Once::new();
|
||||||
|
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
|
||||||
|
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
|
||||||
|
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
|
||||||
|
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
|
||||||
|
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
|
||||||
|
// `Once::call_once`; no raw pointers are dereferenced here.
|
||||||
|
ONCE.call_once(|| unsafe {
|
||||||
|
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||||
|
let Some(prio) = configured_gpu_priority_class() else {
|
||||||
|
tracing::info!("GPU process scheduling priority class left at default (off)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
enable_inc_base_priority();
|
||||||
|
match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
|
||||||
|
Some(0) => tracing::info!(
|
||||||
|
priority_class = prio,
|
||||||
|
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
|
||||||
|
),
|
||||||
|
Some(st) => tracing::warn!(
|
||||||
|
status = format!("0x{st:08X}"),
|
||||||
|
"D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
|
||||||
|
),
|
||||||
|
None => tracing::warn!("D3DKMTSetProcessSchedulingPriorityClass export not found"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! The shared media-pipeline vocabulary (plan §W6): the frame + pixel-format types that capture
|
||||||
|
//! (producer) and encode (consumer) both speak, extracted into a leaf crate so `pf-capture` and
|
||||||
|
//! `pf-encode` depend on the vocabulary WITHOUT depending on each other. The GPU payloads pull
|
||||||
|
//! their heavy backends in from below: `FramePayload::Cuda` owns a [`pf_zerocopy::DeviceBuffer`],
|
||||||
|
//! `FramePayload::D3d11` a [`dxgi::D3d11Frame`].
|
||||||
|
//!
|
||||||
|
//! Alongside the vocabulary live the small pure helpers that ride the same capture-encode seam:
|
||||||
|
//! [`hdr`] (HDR static metadata / in-band SEI), [`metronome`] (the metronomic-stall detector),
|
||||||
|
//! [`thread_qos`] (per-thread scheduling QoS), [`session_tuning`] (Windows process session
|
||||||
|
//! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation).
|
||||||
|
|
||||||
|
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
pub mod hdr;
|
||||||
|
pub mod metronome;
|
||||||
|
pub mod session_tuning;
|
||||||
|
pub mod thread_qos;
|
||||||
|
|
||||||
|
// The Windows DXGI capture identity + shared D3D11 device creation (plan §W6). Consumed by the
|
||||||
|
// capture IDD-push path, the encode D3D11 backends, and pf-vdisplay's `WinCaptureTarget`.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub mod dxgi;
|
||||||
|
|
||||||
|
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||||||
|
/// format; on wlroots it is commonly packed `RGB` (3 bytes/pixel). The encoder maps these
|
||||||
|
/// to an NVENC-accepted input format (`rgb0`/`bgr0`/`rgba`/`bgra`), expanding 3→4 bytes
|
||||||
|
/// where needed — no host-side colour conversion.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PixelFormat {
|
||||||
|
/// `[B,G,R,x]`, 4 bpp.
|
||||||
|
Bgrx,
|
||||||
|
/// `[R,G,B,x]`, 4 bpp.
|
||||||
|
Rgbx,
|
||||||
|
/// `[B,G,R,A]`, 4 bpp.
|
||||||
|
Bgra,
|
||||||
|
/// `[R,G,B,A]`, 4 bpp.
|
||||||
|
Rgba,
|
||||||
|
/// `[R,G,B]`, 3 bpp.
|
||||||
|
Rgb,
|
||||||
|
/// `[B,G,R]`, 3 bpp.
|
||||||
|
Bgr,
|
||||||
|
/// 10-bit RGB packed as `R10G10B10A2` (DXGI `R10G10B10A2_UNORM`), 4 bpp. The HDR capture path
|
||||||
|
/// produces this: scRGB FP16 desktop pixels are converted to BT.2020 PQ and written here, then
|
||||||
|
/// handed to NVENC as `ABGR10` for an HEVC Main10 / HDR10 encode.
|
||||||
|
Rgb10a2,
|
||||||
|
/// `NV12` (DXGI `NV12`): 8-bit BT.709 limited-range YUV 4:2:0. Produced by the D3D11 **video
|
||||||
|
/// processor** (video engine, not the 3D engine) so the per-frame colour conversion doesn't fight a
|
||||||
|
/// GPU-saturating game; handed to NVENC as `NV12` (it encodes YUV natively — no internal RGB→YUV).
|
||||||
|
Nv12,
|
||||||
|
/// `P010` (DXGI `P010`): 10-bit BT.2020 PQ limited-range YUV 4:2:0. HDR analogue of [`Nv12`]:
|
||||||
|
/// video-processor output for HEVC Main10 / HDR10, handed to NVENC as `YUV420_10BIT`.
|
||||||
|
P010,
|
||||||
|
/// Planar 8-bit YUV **4:4:4** (BT.709; range per `PUNKTFUNK_444_FULLRANGE`). Produced by the
|
||||||
|
/// Linux zero-copy worker's GPU convert for a 4:4:4 session ([`FramePayload::Cuda`] with
|
||||||
|
/// `DeviceBuffer::yuv444` — three full-res planes stacked in one allocation); NVENC encodes
|
||||||
|
/// it natively under the Range-Extensions profile. Never a CPU payload.
|
||||||
|
Yuv444,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PixelFormat {
|
||||||
|
pub fn bytes_per_pixel(self) -> usize {
|
||||||
|
match self {
|
||||||
|
PixelFormat::Rgb | PixelFormat::Bgr => 3,
|
||||||
|
// Three full-res 1-byte planes (GPU-resident only; no CPU payload carries this).
|
||||||
|
PixelFormat::Yuv444 => 3,
|
||||||
|
_ => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const fn drm_fourcc_code(c: &[u8; 4]) -> u32 {
|
||||||
|
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a SPA/our [`PixelFormat`] to the DRM FourCC EGL expects for import. SPA byte order `BGRx`
|
||||||
|
/// ⇒ DRM `XRGB8888` (memory B,G,R,X), etc. Lives with the frame vocabulary (not in
|
||||||
|
/// `pf-zerocopy`) because it consumes [`PixelFormat`], which sits above that crate.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
|
||||||
|
use PixelFormat::*;
|
||||||
|
Some(match format {
|
||||||
|
Bgrx => drm_fourcc_code(b"XR24"), // DRM_FORMAT_XRGB8888
|
||||||
|
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||||||
|
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||||||
|
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
||||||
|
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
|
||||||
|
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||||||
|
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||||||
|
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
|
||||||
|
/// `capture_virtual_output` (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
|
||||||
|
/// capturer stop re-deriving the encode backend itself — it kills the
|
||||||
|
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
|
||||||
|
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
|
||||||
|
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct OutputFormat {
|
||||||
|
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
|
||||||
|
/// staging. `false` **only** for the GPU-less software encoder.
|
||||||
|
pub gpu: bool,
|
||||||
|
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
|
||||||
|
/// `false` = 8-bit SDR.
|
||||||
|
pub hdr: bool,
|
||||||
|
/// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
|
||||||
|
/// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
|
||||||
|
/// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
|
||||||
|
/// on-glass (RTX 5070 Ti): ARGB + `chromaFormatIDC=3` yields TRUE 4:4:4 and the conversion
|
||||||
|
/// follows the configured VUI matrix (BT.709 limited since the VUI is always written). On
|
||||||
|
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||||||
|
/// 4:2:0 session.
|
||||||
|
pub chroma_444: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||||
|
/// (`crate::session_plan`) — the GameStream + spike paths. `gpu` is the encoder's GPU-residency,
|
||||||
|
/// resolved by the caller via `pf_encode::resolved_backend_is_gpu` and passed **in** (capture
|
||||||
|
/// never re-derives the backend — the one-way capture→encode edge, plan §2.4 / §W4); `hdr` as given.
|
||||||
|
/// The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already resolved the
|
||||||
|
/// encoder), so neither path makes a capturer re-derive it.
|
||||||
|
pub fn resolve(hdr: bool, gpu: bool) -> Self {
|
||||||
|
OutputFormat {
|
||||||
|
gpu,
|
||||||
|
hdr,
|
||||||
|
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||||
|
chroma_444: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
|
||||||
|
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
|
||||||
|
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
|
||||||
|
/// devbuf / VA surface). The CPU de-pad path composites the cursor inline instead, so it leaves
|
||||||
|
/// this `None`. `rgba` is `Arc` so attaching the (unchanged) bitmap to every frame is a refcount
|
||||||
|
/// bump, not a copy; `serial` bumps only when the bitmap image changes, so the encoder re-uploads
|
||||||
|
/// its small GPU texture on change and just moves a push-constant otherwise.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CursorOverlay {
|
||||||
|
/// Top-left in frame pixels where the bitmap is drawn (already = reported position − hotspot).
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub w: u32,
|
||||||
|
pub h: u32,
|
||||||
|
/// Straight-alpha RGBA pixels, `w*h*4` (bytes R,G,B,A).
|
||||||
|
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||||
|
/// Bumps whenever `rgba`/`w`/`h` change; stable across position-only moves.
|
||||||
|
pub serial: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||||
|
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||||
|
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||||
|
pub struct CapturedFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub pts_ns: u64,
|
||||||
|
/// Pixel layout of the payload.
|
||||||
|
pub format: PixelFormat,
|
||||||
|
pub payload: FramePayload,
|
||||||
|
/// Cursor overlay to blend at encode time (GPU zero-copy payloads only); `None` when there's no
|
||||||
|
/// visible cursor or the pixels were already composited on the CPU de-pad path. See
|
||||||
|
/// [`CursorOverlay`].
|
||||||
|
pub cursor: Option<CursorOverlay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||||||
|
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||||
|
/// imported into a VA surface there without the compositor's buffer being closed underneath it.
|
||||||
|
/// (Content stability across the brief import window relies on the compositor's buffer pool depth,
|
||||||
|
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub struct DmabufFrame {
|
||||||
|
pub fd: std::os::fd::OwnedFd,
|
||||||
|
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
|
||||||
|
pub fourcc: u32,
|
||||||
|
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
||||||
|
pub modifier: u64,
|
||||||
|
pub offset: u32,
|
||||||
|
pub stride: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a captured frame's pixels live.
|
||||||
|
pub enum FramePayload {
|
||||||
|
/// Tightly-packed CPU pixels in `format`, `width*height*bytes_per_pixel` (no row padding).
|
||||||
|
Cpu(Vec<u8>),
|
||||||
|
/// A pitched GPU buffer (BGRA-order, on the shared CUDA context) — the NVIDIA zero-copy path.
|
||||||
|
/// The dmabuf has already been imported + copied into this owned device buffer.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Cuda(pf_zerocopy::DeviceBuffer),
|
||||||
|
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
|
||||||
|
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Dmabuf(DmabufFrame),
|
||||||
|
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
D3d11(dxgi::D3d11Frame),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapturedFrame {
|
||||||
|
/// True if the frame's pixels are a GPU/CUDA buffer (the NVIDIA zero-copy path).
|
||||||
|
pub fn is_cuda(&self) -> bool {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
matches!(self.payload, FramePayload::Cuda(_))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the frame is a raw dmabuf (the VAAPI zero-copy path).
|
||||||
|
pub fn is_dmabuf(&self) -> bool {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
matches!(self.payload, FramePayload::Dmabuf(_))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ use std::time::{Duration, Instant};
|
|||||||
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
|
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
|
||||||
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
||||||
/// [`Self::REWARN`] while the cycle persists.
|
/// [`Self::REWARN`] while the cycle persists.
|
||||||
pub(crate) struct Metronome {
|
#[derive(Default)]
|
||||||
|
pub struct Metronome {
|
||||||
events: VecDeque<Instant>,
|
events: VecDeque<Instant>,
|
||||||
last_warn: Option<Instant>,
|
last_warn: Option<Instant>,
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ impl Metronome {
|
|||||||
/// Once warned, re-warn at most this often while the cycle persists.
|
/// Once warned, re-warn at most this often while the cycle persists.
|
||||||
const REWARN: Duration = Duration::from_secs(30);
|
const REWARN: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
pub(crate) fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: VecDeque::new(),
|
events: VecDeque::new(),
|
||||||
last_warn: None,
|
last_warn: None,
|
||||||
@@ -41,7 +42,7 @@ impl Metronome {
|
|||||||
|
|
||||||
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
|
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
|
||||||
/// warning should fire.
|
/// warning should fire.
|
||||||
pub(crate) fn note(&mut self, now: Instant) -> Option<Duration> {
|
pub fn note(&mut self, now: Instant) -> Option<Duration> {
|
||||||
if self
|
if self
|
||||||
.events
|
.events
|
||||||
.back()
|
.back()
|
||||||
+71
-1
@@ -32,6 +32,21 @@ mod imp {
|
|||||||
fn GetCurrentProcess() -> Handle;
|
fn GetCurrentProcess() -> Handle;
|
||||||
fn SetPriorityClass(hProcess: Handle, dwPriorityClass: u32) -> Bool;
|
fn SetPriorityClass(hProcess: Handle, dwPriorityClass: u32) -> Bool;
|
||||||
fn SetThreadExecutionState(esFlags: u32) -> u32;
|
fn SetThreadExecutionState(esFlags: u32) -> u32;
|
||||||
|
fn PowerCreateRequest(Context: *const ReasonContext) -> Handle;
|
||||||
|
fn PowerSetRequest(PowerRequest: Handle, RequestType: i32) -> Bool;
|
||||||
|
fn PowerClearRequest(PowerRequest: Handle, RequestType: i32) -> Bool;
|
||||||
|
fn CloseHandle(hObject: Handle) -> Bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `REASON_CONTEXT` (minwinbase.h), simple-string flavour: `Version` (ULONG), `Flags` (DWORD),
|
||||||
|
/// then the union collapses to `SimpleReasonString` (LPWSTR) under
|
||||||
|
/// `POWER_REQUEST_CONTEXT_SIMPLE_STRING` — same size/alignment as the C layout (4+4, 8-aligned
|
||||||
|
/// pointer).
|
||||||
|
#[repr(C)]
|
||||||
|
struct ReasonContext {
|
||||||
|
version: u32,
|
||||||
|
flags: u32,
|
||||||
|
simple_reason: *const u16,
|
||||||
}
|
}
|
||||||
#[link(name = "dwmapi")]
|
#[link(name = "dwmapi")]
|
||||||
extern "system" {
|
extern "system" {
|
||||||
@@ -46,6 +61,61 @@ mod imp {
|
|||||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||||
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||||
|
const POWER_REQUEST_CONTEXT_VERSION: u32 = 0; // DIAGNOSTIC_REASON_VERSION
|
||||||
|
const POWER_REQUEST_CONTEXT_SIMPLE_STRING: u32 = 0x0000_0001;
|
||||||
|
const POWER_REQUEST_DISPLAY_REQUIRED: i32 = 0;
|
||||||
|
const POWER_REQUEST_SYSTEM_REQUIRED: i32 = 1;
|
||||||
|
const INVALID_HANDLE_VALUE: isize = -1;
|
||||||
|
|
||||||
|
/// RAII display+system availability request (`PowerRequestDisplayRequired`, visible in
|
||||||
|
/// `powercfg /requests`) — the service-grade "someone is watching this screen" assertion,
|
||||||
|
/// held for a capture session so the console cannot drop into display-off mid-stream. This is
|
||||||
|
/// object-lifetime (unlike the thread-bound `ES_*` flags in [`on_hot_thread`], which the OS
|
||||||
|
/// reverts at thread exit), so a capturer can hold it across whatever threads serve the
|
||||||
|
/// session. PREVENTION only: no power request turns an already-off display back ON — that
|
||||||
|
/// wake needs input, which is the virtual-mouse compose kick's job.
|
||||||
|
pub struct DisplayWakeRequest(Handle);
|
||||||
|
|
||||||
|
// SAFETY: the wrapped power-request HANDLE is a kernel object handle — a plain opaque value
|
||||||
|
// that any thread may use; this type never aliases it (set at new, cleared+closed at drop).
|
||||||
|
unsafe impl Send for DisplayWakeRequest {}
|
||||||
|
|
||||||
|
impl DisplayWakeRequest {
|
||||||
|
/// Create + set the request. `None` when the kernel refuses (best-effort — the caller
|
||||||
|
/// streams without the assertion, exactly the pre-existing behavior).
|
||||||
|
pub fn new() -> Option<DisplayWakeRequest> {
|
||||||
|
let reason: Vec<u16> = "punktfunk streaming session\0".encode_utf16().collect();
|
||||||
|
let ctx = ReasonContext {
|
||||||
|
version: POWER_REQUEST_CONTEXT_VERSION,
|
||||||
|
flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING,
|
||||||
|
simple_reason: reason.as_ptr(),
|
||||||
|
};
|
||||||
|
// SAFETY: `ctx` (and the reason buffer it points into) outlives the call, which copies
|
||||||
|
// the string into the kernel object; the returned handle is owned here and released in
|
||||||
|
// Drop. PowerSetRequest takes the just-created handle + a plain enum value.
|
||||||
|
unsafe {
|
||||||
|
let h = PowerCreateRequest(&ctx);
|
||||||
|
if h.is_null() || h as isize == INVALID_HANDLE_VALUE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
PowerSetRequest(h, POWER_REQUEST_DISPLAY_REQUIRED);
|
||||||
|
PowerSetRequest(h, POWER_REQUEST_SYSTEM_REQUIRED);
|
||||||
|
Some(DisplayWakeRequest(h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DisplayWakeRequest {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.0` is the owned, still-open power-request handle (created in `new`,
|
||||||
|
// dropped exactly once); clear + close are plain handle calls.
|
||||||
|
unsafe {
|
||||||
|
PowerClearRequest(self.0, POWER_REQUEST_DISPLAY_REQUIRED);
|
||||||
|
PowerClearRequest(self.0, POWER_REQUEST_SYSTEM_REQUIRED);
|
||||||
|
CloseHandle(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
||||||
|
|
||||||
@@ -94,7 +164,7 @@ mod imp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub use imp::on_hot_thread;
|
pub use imp::{on_hot_thread, DisplayWakeRequest};
|
||||||
|
|
||||||
/// No-op on non-Windows (Linux uses `setpriority` nice + CUDA stream priority instead — see
|
/// No-op on non-Windows (Linux uses `setpriority` nice + CUDA stream priority instead — see
|
||||||
/// `native::boost_thread_priority` and `zerocopy::cuda`).
|
/// `native::boost_thread_priority` and `zerocopy::cuda`).
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Per-thread OS scheduling QoS for the native data plane (plan §W1 — carved out of the [`super`]
|
//! Per-thread OS scheduling QoS for the data plane (plan §W1/§W6 — now in the shared `pf-frame`
|
||||||
//! module). The capture/encode and send threads raise their own priority so a CPU-saturating game
|
//! leaf). The capture/encode and send threads raise their own priority so a CPU-saturating game
|
||||||
//! can't deschedule them; the GameStream path and the direct-NVENC send thread reach this the same
|
//! can't deschedule them; the native, GameStream, and direct-NVENC send threads all reach this the
|
||||||
//! way (`crate::native::boost_thread_priority`).
|
//! same way (`pf_frame::thread_qos::boost_thread_priority`).
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
/// uncapped GPU-saturating title (e.g. CS2 direct on a virtual output, not capped by gamescope) is
|
/// uncapped GPU-saturating title (e.g. CS2 direct on a virtual output, not capped by gamescope) is
|
||||||
/// also a CPU hog and can deschedule our submit threads. `critical` → highest non-realtime class
|
/// also a CPU hog and can deschedule our submit threads. `critical` → highest non-realtime class
|
||||||
/// (the capture+encode loop); otherwise above-normal (the send/relay thread).
|
/// (the capture+encode loop); otherwise above-normal (the send/relay thread).
|
||||||
pub(crate) fn boost_thread_priority(critical: bool) {
|
pub fn boost_thread_priority(critical: bool) {
|
||||||
// Windows host-process/thread session tuning (timer 1ms, DWM MMCSS, HIGH class once; MMCSS +
|
// Windows host-process/thread session tuning (timer 1ms, DWM MMCSS, HIGH class once; MMCSS +
|
||||||
// keep-display-awake per thread). No-op off Windows. Both stream threads call us, so this covers
|
// keep-display-awake per thread). No-op off Windows. Both stream threads call us, so this covers
|
||||||
// capture/encode (critical) and send (non-critical).
|
// capture/encode (critical) and send (non-critical).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# GPU vendor/adapter detection + selection + the live-session record, extracted into a leaf crate so
|
||||||
|
# every subsystem crate (pf-encode, pf-capture, pf-vdisplay) can consult the selected GPU WITHOUT
|
||||||
|
# depending on the orchestrator (plan §W6). Self-contained: reads config + writes the pref store.
|
||||||
|
[package]
|
||||||
|
name = "pf-gpu"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk host GPU vendor/adapter enumeration, selection preference, and active-session accounting."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pf-host-config = { path = "../pf-host-config" }
|
||||||
|
pf-paths = { path = "../pf-paths" }
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Dxgi",
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -28,14 +28,14 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
/// PCI vendor ids of the GPU vendors the encode backends know (NVENC / AMF / QSV, VAAPI on Linux).
|
/// PCI vendor ids of the GPU vendors the encode backends know (NVENC / AMF / QSV, VAAPI on Linux).
|
||||||
pub(crate) const VENDOR_NVIDIA: u32 = 0x10DE;
|
pub const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||||
pub(crate) const VENDOR_AMD: u32 = 0x1002;
|
pub const VENDOR_AMD: u32 = 0x1002;
|
||||||
pub(crate) const VENDOR_INTEL: u32 = 0x8086;
|
pub const VENDOR_INTEL: u32 = 0x8086;
|
||||||
|
|
||||||
/// Platform handle of an enumerated GPU — how the pipeline actually addresses it. Not part of the
|
/// Platform handle of an enumerated GPU — how the pipeline actually addresses it. Not part of the
|
||||||
/// stable identity (Windows LUIDs are per-boot; a render node can renumber across kernel updates).
|
/// stable identity (Windows LUIDs are per-boot; a render node can renumber across kernel updates).
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct GpuHandle {
|
pub struct GpuHandle {
|
||||||
/// DXGI `AdapterLuid` of this adapter (this boot only).
|
/// DXGI `AdapterLuid` of this adapter (this boot only).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub luid_low: u32,
|
pub luid_low: u32,
|
||||||
@@ -48,7 +48,7 @@ pub(crate) struct GpuHandle {
|
|||||||
|
|
||||||
/// One hardware GPU as enumerated on this host.
|
/// One hardware GPU as enumerated on this host.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct GpuInfo {
|
pub struct GpuInfo {
|
||||||
/// Stable identifier for the API/UI: `"{vendor:04x}-{device:04x}-{occurrence}"`. Occurrence
|
/// Stable identifier for the API/UI: `"{vendor:04x}-{device:04x}-{occurrence}"`. Occurrence
|
||||||
/// disambiguates identical cards (two of the same model) by enumeration order among their
|
/// disambiguates identical cards (two of the same model) by enumeration order among their
|
||||||
/// twins — the best available tiebreaker (PCI order), imperfect but honest.
|
/// twins — the best available tiebreaker (PCI order), imperfect but honest.
|
||||||
@@ -65,7 +65,7 @@ pub(crate) struct GpuInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lowercase vendor tag for the API (`nvidia` / `amd` / `intel` / `other`).
|
/// Lowercase vendor tag for the API (`nvidia` / `amd` / `intel` / `other`).
|
||||||
pub(crate) fn vendor_tag(vendor_id: u32) -> &'static str {
|
pub fn vendor_tag(vendor_id: u32) -> &'static str {
|
||||||
match vendor_id {
|
match vendor_id {
|
||||||
VENDOR_NVIDIA => "nvidia",
|
VENDOR_NVIDIA => "nvidia",
|
||||||
VENDOR_AMD => "amd",
|
VENDOR_AMD => "amd",
|
||||||
@@ -93,6 +93,9 @@ impl GpuInfo {
|
|||||||
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
|
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
|
||||||
/// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so
|
/// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so
|
||||||
/// twin numbering is stable for the boot, see [`enumerate`]).
|
/// twin numbering is stable for the boot, see [`enumerate`]).
|
||||||
|
// Called only by the Linux/Windows `enumerate()` arms; the stub `enumerate()` on other targets
|
||||||
|
// (macOS dev host) doesn't, so it's dead there.
|
||||||
|
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||||
fn assign_ids(gpus: &mut [GpuInfo]) {
|
fn assign_ids(gpus: &mut [GpuInfo]) {
|
||||||
for i in 0..gpus.len() {
|
for i in 0..gpus.len() {
|
||||||
let occ = gpus[..i]
|
let occ = gpus[..i]
|
||||||
@@ -127,7 +130,7 @@ mod adapter_type {
|
|||||||
|
|
||||||
/// True when these bits describe an adapter that can never be the render/encode GPU:
|
/// True when these bits describe an adapter that can never be the render/encode GPU:
|
||||||
/// indirect-display, software, or anything without render support.
|
/// indirect-display, software, or anything without render support.
|
||||||
pub(crate) fn hidden(bits: u32) -> bool {
|
pub fn hidden(bits: u32) -> bool {
|
||||||
bits & INDIRECT_DISPLAY_DEVICE != 0
|
bits & INDIRECT_DISPLAY_DEVICE != 0
|
||||||
|| bits & SOFTWARE_DEVICE != 0
|
|| bits & SOFTWARE_DEVICE != 0
|
||||||
|| bits & RENDER_SUPPORTED == 0
|
|| bits & RENDER_SUPPORTED == 0
|
||||||
@@ -172,7 +175,7 @@ mod kmt {
|
|||||||
|
|
||||||
/// The `D3DKMT_ADAPTERTYPE` bits for the adapter with this LUID, `None` when the kernel
|
/// The `D3DKMT_ADAPTERTYPE` bits for the adapter with this LUID, `None` when the kernel
|
||||||
/// query fails (callers fail open — better a listed twin than a hidden real GPU).
|
/// query fails (callers fail open — better a listed twin than a hidden real GPU).
|
||||||
pub(crate) fn adapter_type_bits(luid_low: u32, luid_high: i32) -> Option<u32> {
|
pub fn adapter_type_bits(luid_low: u32, luid_high: i32) -> Option<u32> {
|
||||||
// SAFETY: every pointer handed to the three D3DKMT calls addresses a stack local that
|
// SAFETY: every pointer handed to the three D3DKMT calls addresses a stack local that
|
||||||
// outlives the call; NTSTATUS >= 0 is success. The kernel handle is closed on every
|
// outlives the call; NTSTATUS >= 0 is success. The kernel handle is closed on every
|
||||||
// path that opened it, including a failed query.
|
// path that opened it, including a failed query.
|
||||||
@@ -208,7 +211,7 @@ mod kmt {
|
|||||||
/// Other platforms (the macOS dev/test host build): empty — the endpoints still exist, they just
|
/// Other platforms (the macOS dev/test host build): empty — the endpoints still exist, they just
|
||||||
/// report no GPUs.
|
/// report no GPUs.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
pub fn enumerate() -> Vec<GpuInfo> {
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||||
};
|
};
|
||||||
@@ -281,7 +284,7 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
pub fn enumerate() -> Vec<GpuInfo> {
|
||||||
let mut nodes: Vec<String> = std::fs::read_dir("/dev/dri")
|
let mut nodes: Vec<String> = std::fs::read_dir("/dev/dri")
|
||||||
.map(|rd| {
|
.map(|rd| {
|
||||||
rd.filter_map(|e| e.ok())
|
rd.filter_map(|e| e.ok())
|
||||||
@@ -331,7 +334,7 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
pub fn enumerate() -> Vec<GpuInfo> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +346,7 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
|||||||
/// `Manual` (an explicit GPU chosen in the web console).
|
/// `Manual` (an explicit GPU chosen in the web console).
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub(crate) enum GpuMode {
|
pub enum GpuMode {
|
||||||
#[default]
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Manual,
|
Manual,
|
||||||
@@ -351,7 +354,7 @@ pub(crate) enum GpuMode {
|
|||||||
|
|
||||||
/// Stable identity of the manually preferred GPU (see [`GpuInfo::id`] for why not LUID/index).
|
/// Stable identity of the manually preferred GPU (see [`GpuInfo::id`] for why not LUID/index).
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) struct PreferredGpu {
|
pub struct PreferredGpu {
|
||||||
pub vendor_id: u32,
|
pub vendor_id: u32,
|
||||||
pub device_id: u32,
|
pub device_id: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -364,7 +367,7 @@ pub(crate) struct PreferredGpu {
|
|||||||
|
|
||||||
/// The persisted GPU preference (`<config>/gpu-settings.json`).
|
/// The persisted GPU preference (`<config>/gpu-settings.json`).
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) struct GpuPreference {
|
pub struct GpuPreference {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GpuMode,
|
pub mode: GpuMode,
|
||||||
/// `Some` when `mode == Manual` (kept when switching back to Auto so the console can offer
|
/// `Some` when `mode == Manual` (kept when switching back to Auto so the console can offer
|
||||||
@@ -376,7 +379,7 @@ pub(crate) struct GpuPreference {
|
|||||||
/// The preference store: in-memory current value + its JSON file. Mirrors `native_pairing`'s
|
/// The preference store: in-memory current value + its JSON file. Mirrors `native_pairing`'s
|
||||||
/// persistence discipline (private dir, secret-file temp write + atomic rename, in-memory
|
/// persistence discipline (private dir, secret-file temp write + atomic rename, in-memory
|
||||||
/// rollback if the disk write fails).
|
/// rollback if the disk write fails).
|
||||||
pub(crate) struct GpuPrefStore {
|
pub struct GpuPrefStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
cur: Mutex<GpuPreference>,
|
cur: Mutex<GpuPreference>,
|
||||||
}
|
}
|
||||||
@@ -409,10 +412,10 @@ impl GpuPrefStore {
|
|||||||
/// succeeds, so a full disk can't leave memory and file disagreeing.
|
/// succeeds, so a full disk can't leave memory and file disagreeing.
|
||||||
pub fn set(&self, pref: GpuPreference) -> Result<()> {
|
pub fn set(&self, pref: GpuPreference) -> Result<()> {
|
||||||
if let Some(dir) = self.path.parent() {
|
if let Some(dir) = self.path.parent() {
|
||||||
crate::gamestream::create_private_dir(dir)?;
|
pf_paths::create_private_dir(dir)?;
|
||||||
}
|
}
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&pref)?)?;
|
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&pref)?)?;
|
||||||
std::fs::rename(&tmp, &self.path)?;
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
*self.cur.lock().unwrap() = pref;
|
*self.cur.lock().unwrap() = pref;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -420,13 +423,11 @@ impl GpuPrefStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The process-wide preference store (config-dir file), loaded once on first access — the same
|
/// The process-wide preference store (config-dir file), loaded once on first access — the same
|
||||||
/// global-accessor shape as [`crate::config::config`], because selection happens deep inside
|
/// global-accessor shape as [`pf_host_config::config`], because selection happens deep inside
|
||||||
/// capture/encode setup where no app state is threaded.
|
/// capture/encode setup where no app state is threaded.
|
||||||
pub(crate) fn prefs() -> &'static GpuPrefStore {
|
pub fn prefs() -> &'static GpuPrefStore {
|
||||||
static STORE: OnceLock<GpuPrefStore> = OnceLock::new();
|
static STORE: OnceLock<GpuPrefStore> = OnceLock::new();
|
||||||
STORE.get_or_init(|| {
|
STORE.get_or_init(|| GpuPrefStore::load_from(pf_paths::config_dir().join("gpu-settings.json")))
|
||||||
GpuPrefStore::load_from(crate::gamestream::config_dir().join("gpu-settings.json"))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -435,7 +436,7 @@ pub(crate) fn prefs() -> &'static GpuPrefStore {
|
|||||||
|
|
||||||
/// Why a GPU was selected — surfaced by the mgmt API so the console can explain the decision.
|
/// Why a GPU was selected — surfaced by the mgmt API so the console can explain the decision.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum PickSource {
|
pub enum PickSource {
|
||||||
/// The operator's manual preference matched a present GPU.
|
/// The operator's manual preference matched a present GPU.
|
||||||
Preference,
|
Preference,
|
||||||
/// `PUNKTFUNK_RENDER_ADAPTER` substring matched.
|
/// `PUNKTFUNK_RENDER_ADAPTER` substring matched.
|
||||||
@@ -460,7 +461,7 @@ impl PickSource {
|
|||||||
|
|
||||||
/// A resolved selection: the GPU the next session's pipeline will be created on, and why.
|
/// A resolved selection: the GPU the next session's pipeline will be created on, and why.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct SelectedGpu {
|
pub struct SelectedGpu {
|
||||||
pub info: GpuInfo,
|
pub info: GpuInfo,
|
||||||
pub source: PickSource,
|
pub source: PickSource,
|
||||||
}
|
}
|
||||||
@@ -468,7 +469,7 @@ pub(crate) struct SelectedGpu {
|
|||||||
/// Find the manually preferred GPU in the inventory. Match order: exact stable identity
|
/// Find the manually preferred GPU in the inventory. Match order: exact stable identity
|
||||||
/// (vendor, device, occurrence) → same model (vendor, device; a twin renumbered) → exact name
|
/// (vendor, device, occurrence) → same model (vendor, device; a twin renumbered) → exact name
|
||||||
/// (ids changed across a driver/firmware quirk but the marketing name survived).
|
/// (ids changed across a driver/firmware quirk but the marketing name survived).
|
||||||
pub(crate) fn find_preferred(gpus: &[GpuInfo], want: &PreferredGpu) -> Option<usize> {
|
pub fn find_preferred(gpus: &[GpuInfo], want: &PreferredGpu) -> Option<usize> {
|
||||||
gpus.iter()
|
gpus.iter()
|
||||||
.position(|g| {
|
.position(|g| {
|
||||||
g.vendor_id == want.vendor_id
|
g.vendor_id == want.vendor_id
|
||||||
@@ -491,7 +492,7 @@ pub(crate) fn find_preferred(gpus: &[GpuInfo], want: &PreferredGpu) -> Option<us
|
|||||||
/// the index into `gpus` plus the reason. `None` only when `gpus` is empty. A set-but-unmatched
|
/// the index into `gpus` plus the reason. `None` only when `gpus` is empty. A set-but-unmatched
|
||||||
/// env substring falls through to max-VRAM (same outcome as env unset — deliberately more robust
|
/// env substring falls through to max-VRAM (same outcome as env unset — deliberately more robust
|
||||||
/// than the old `resolve_render_adapter_luid`, which returned *no* adapter on a stale substring).
|
/// than the old `resolve_render_adapter_luid`, which returned *no* adapter on a stale substring).
|
||||||
pub(crate) fn pick(
|
pub fn pick(
|
||||||
gpus: &[GpuInfo],
|
gpus: &[GpuInfo],
|
||||||
pref: &GpuPreference,
|
pref: &GpuPreference,
|
||||||
env_substr: Option<&str>,
|
env_substr: Option<&str>,
|
||||||
@@ -534,10 +535,10 @@ pub(crate) fn pick(
|
|||||||
/// the encoder-vendor dispatch both consume, so capture, encode, and the advertisement agree by
|
/// the encoder-vendor dispatch both consume, so capture, encode, and the advertisement agree by
|
||||||
/// construction. Pure query — callers log (this runs per serverinfo poll).
|
/// construction. Pure query — callers log (this runs per serverinfo poll).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn selected_gpu() -> Option<SelectedGpu> {
|
pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||||
let gpus = enumerate();
|
let gpus = enumerate();
|
||||||
let pref = prefs().get();
|
let pref = prefs().get();
|
||||||
let env = crate::config::config()
|
let env = pf_host_config::config()
|
||||||
.render_adapter
|
.render_adapter
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
@@ -553,7 +554,7 @@ pub(crate) fn selected_gpu() -> Option<SelectedGpu> {
|
|||||||
/// owns the VAAPI render node. (The *authoritative* Linux switches stay in `encode::open_video` /
|
/// owns the VAAPI render node. (The *authoritative* Linux switches stay in `encode::open_video` /
|
||||||
/// [`linux_render_node`] — this is the console's view of them.)
|
/// [`linux_render_node`] — this is the console's view of them.)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn selected_gpu() -> Option<SelectedGpu> {
|
pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||||
let gpus = enumerate();
|
let gpus = enumerate();
|
||||||
let pref = prefs().get();
|
let pref = prefs().get();
|
||||||
let mut preference_missing = false;
|
let mut preference_missing = false;
|
||||||
@@ -595,14 +596,14 @@ pub(crate) fn selected_gpu() -> Option<SelectedGpu> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
pub(crate) fn selected_gpu() -> Option<SelectedGpu> {
|
pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The manually preferred GPU, only when `mode == Manual` **and** it is currently present.
|
/// The manually preferred GPU, only when `mode == Manual` **and** it is currently present.
|
||||||
/// The Linux encode dispatch consults this (auto mode keeps today's NVIDIA-presence behavior
|
/// The Linux encode dispatch consults this (auto mode keeps today's NVIDIA-presence behavior
|
||||||
/// exactly).
|
/// exactly).
|
||||||
pub(crate) fn manual_selection() -> Option<GpuInfo> {
|
pub fn manual_selection() -> Option<GpuInfo> {
|
||||||
let pref = prefs().get();
|
let pref = prefs().get();
|
||||||
if pref.mode != GpuMode::Manual {
|
if pref.mode != GpuMode::Manual {
|
||||||
return None;
|
return None;
|
||||||
@@ -616,7 +617,7 @@ pub(crate) fn manual_selection() -> Option<GpuInfo> {
|
|||||||
/// The VAAPI/DRM render node for this host: matched manual preference > `PUNKTFUNK_RENDER_NODE`
|
/// The VAAPI/DRM render node for this host: matched manual preference > `PUNKTFUNK_RENDER_NODE`
|
||||||
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
|
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn linux_render_node() -> PathBuf {
|
pub fn linux_render_node() -> PathBuf {
|
||||||
if let Some(g) = manual_selection() {
|
if let Some(g) = manual_selection() {
|
||||||
if let Some(node) = g.handle.render_node {
|
if let Some(node) = g.handle.render_node {
|
||||||
return node;
|
return node;
|
||||||
@@ -639,7 +640,7 @@ fn linux_nvidia_present() -> bool {
|
|||||||
/// A cache key that changes whenever the *selection* changes (preference edits included), for the
|
/// A cache key that changes whenever the *selection* changes (preference edits included), for the
|
||||||
/// per-GPU probe caches (`can_encode_444`, `windows_codec_support`) that were process-lifetime
|
/// per-GPU probe caches (`can_encode_444`, `windows_codec_support`) that were process-lifetime
|
||||||
/// `OnceLock`s back when selection was env-only.
|
/// `OnceLock`s back when selection was env-only.
|
||||||
pub(crate) fn selection_key() -> String {
|
pub fn selection_key() -> String {
|
||||||
match selected_gpu() {
|
match selected_gpu() {
|
||||||
Some(sel) => {
|
Some(sel) => {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -664,7 +665,7 @@ pub(crate) fn selection_key() -> String {
|
|||||||
|
|
||||||
/// What a live session encodes on — the console's "currently used GPU".
|
/// What a live session encodes on — the console's "currently used GPU".
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ActiveGpu {
|
pub struct ActiveGpu {
|
||||||
/// Stable id of the GPU ([`GpuInfo::id`]; empty for the CPU/software path) so a UI can match
|
/// Stable id of the GPU ([`GpuInfo::id`]; empty for the CPU/software path) so a UI can match
|
||||||
/// it against the inventory.
|
/// it against the inventory.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -684,7 +685,7 @@ static ACTIVE: Mutex<Option<ActiveState>> = Mutex::new(None);
|
|||||||
/// RAII marker for one live encode session; dropping it decrements the session count. Held by the
|
/// RAII marker for one live encode session; dropping it decrements the session count. Held by the
|
||||||
/// encoder wrapper `open_video` returns, so the count is correct by construction (every successful
|
/// encoder wrapper `open_video` returns, so the count is correct by construction (every successful
|
||||||
/// open is paired with a drop).
|
/// open is paired with a drop).
|
||||||
pub(crate) struct ActiveSession(());
|
pub struct ActiveSession(());
|
||||||
|
|
||||||
impl Drop for ActiveSession {
|
impl Drop for ActiveSession {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -698,7 +699,7 @@ impl Drop for ActiveSession {
|
|||||||
/// Record a session opening on `gpu`. Concurrent sessions share one GPU (the Windows pipeline is
|
/// Record a session opening on `gpu`. Concurrent sessions share one GPU (the Windows pipeline is
|
||||||
/// single-GPU by construction; Linux sessions share the selection), so the latest record wins and
|
/// single-GPU by construction; Linux sessions share the selection), so the latest record wins and
|
||||||
/// a counter tracks liveness.
|
/// a counter tracks liveness.
|
||||||
pub(crate) fn session_begin(gpu: ActiveGpu) -> ActiveSession {
|
pub fn session_begin(gpu: ActiveGpu) -> ActiveSession {
|
||||||
let mut st = ACTIVE.lock().unwrap_or_else(|e| e.into_inner());
|
let mut st = ACTIVE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let sessions = st.as_ref().map(|s| s.sessions).unwrap_or(0) + 1;
|
let sessions = st.as_ref().map(|s| s.sessions).unwrap_or(0) + 1;
|
||||||
*st = Some(ActiveState { gpu, sessions });
|
*st = Some(ActiveState { gpu, sessions });
|
||||||
@@ -707,7 +708,7 @@ pub(crate) fn session_begin(gpu: ActiveGpu) -> ActiveSession {
|
|||||||
|
|
||||||
/// The GPU live sessions encode on + how many sessions hold it. `Some` with `sessions == 0` means
|
/// The GPU live sessions encode on + how many sessions hold it. `Some` with `sessions == 0` means
|
||||||
/// "last used, idle now" — the mgmt API distinguishes the two.
|
/// "last used, idle now" — the mgmt API distinguishes the two.
|
||||||
pub(crate) fn active() -> Option<(ActiveGpu, u32)> {
|
pub fn active() -> Option<(ActiveGpu, u32)> {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
@@ -913,3 +914,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pick the render GPU LUID the Windows pipeline is created on: the IDD-push capturer's
|
||||||
|
/// shared-texture ring, the IddCx `SET_RENDER_ADAPTER` pin, and (via the captured frame's device)
|
||||||
|
/// NVENC/AMF/QSV all follow this one decision — see [`selected_gpu`] for the precedence (operator
|
||||||
|
/// preference > `PUNKTFUNK_RENDER_ADAPTER` substring > max `DedicatedVideoMemory`). A configured
|
||||||
|
/// preference that doesn't match a present GPU falls back to auto selection (with a warning) rather
|
||||||
|
/// than returning `None`, so a stale preference never stops the host from streaming.
|
||||||
|
///
|
||||||
|
/// Lives here (not in a host module) so BOTH the capture and encode subsystem crates depend on it
|
||||||
|
/// as a peer of GPU selection instead of the orchestrator — the plan's `windows/adapter.rs`, folded
|
||||||
|
/// into `pf-gpu` (plan §W6). It was historically the SudoVDA backend's, then the host's
|
||||||
|
/// `win_adapter.rs`; the LUID-shaped view of [`selected_gpu`] plus the per-decision logging.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn resolve_render_adapter_luid() -> Option<windows::Win32::Foundation::LUID> {
|
||||||
|
match selected_gpu() {
|
||||||
|
Some(sel) => {
|
||||||
|
tracing::info!(
|
||||||
|
adapter = sel.info.name,
|
||||||
|
vram_mb = sel.info.vram_bytes / (1024 * 1024),
|
||||||
|
source = sel.source.tag(),
|
||||||
|
"render adapter selected"
|
||||||
|
);
|
||||||
|
if sel.source == PickSource::PreferenceMissing {
|
||||||
|
tracing::warn!(
|
||||||
|
"the preferred GPU is not present — auto-selected the adapter above \
|
||||||
|
(fix or clear the preference in the web console)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(sel.info.luid())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# The process-wide host configuration global (HostConfig + the config() OnceLock), extracted into a
|
||||||
|
# leaf crate so every subsystem crate (pf-encode, pf-capture, pf-vdisplay, pf-gpu) can read config
|
||||||
|
# WITHOUT depending on the orchestrator (plan §W6 — config parked above its consumers). Pure std.
|
||||||
|
[package]
|
||||||
|
name = "pf-host-config"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "Process-wide punktfunk host configuration (env-parsed HostConfig behind a OnceLock)."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! capture/topology/encoder decision.
|
//! capture/topology/encoder decision.
|
||||||
//!
|
//!
|
||||||
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
|
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
|
||||||
//! - **Runtime-mutated session vars.** On Linux, [`crate::vdisplay::apply_session_env`] rewrites the process
|
//! - **Runtime-mutated session vars.** On Linux, `crate::vdisplay::apply_session_env` rewrites the process
|
||||||
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
|
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
|
||||||
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
|
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
|
||||||
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Input injection (plan §W6): the per-OS injector backends (wlroots virtual-input, KWin fake_input,
|
||||||
|
# libei/reis, gamescope-EI on Linux; SendInput on Windows) + the virtual-gamepad HID stack (DualSense/
|
||||||
|
# DualShock4/Switch Pro/Steam Controller/Deck over uhid/usbip and the Windows UMDF drivers), extracted
|
||||||
|
# into a subsystem crate. Consumes punktfunk_core::input (the neutral GamepadEvent/InputEvent vocabulary,
|
||||||
|
# moved to core in W5) and the pf-driver-proto wire contract; NEVER reaches the orchestrator (the one
|
||||||
|
# gamescope-EI socket path is the shared pf-paths contract, not a vdisplay reach-in).
|
||||||
|
[package]
|
||||||
|
name = "pf-inject"
|
||||||
|
version = "0.12.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version.workspace = true
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk host input injection: per-OS keyboard/mouse injectors + the virtual-gamepad HID backends behind one InputInjector trait."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||||
|
pf-host-config = { path = "../pf-host-config" }
|
||||||
|
pf-paths = { path = "../pf-paths" }
|
||||||
|
# The Windows gamepad-channel bootstrap reuses the IDD-push WUDFHost verification + the resident-mouse
|
||||||
|
# compose-kick hook (both live in pf-capture).
|
||||||
|
pf-capture = { path = "../pf-capture" }
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
# The RemoteDesktop portal for the libei injector on KWin/GNOME (headless grant via kde-authorized).
|
||||||
|
ashpd = { version = "0.13", features = ["remote_desktop"] }
|
||||||
|
# Input injection into headless Sway via the wlroots virtual-input Wayland protocols.
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
|
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||||
|
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||||
|
# Codegen for KDE's `org_kde_kwin_fake_input` (vendored in `protocols/fake-input.xml`); the generated
|
||||||
|
# interface tables reference `wayland-backend`.
|
||||||
|
wayland-scanner = "0.31"
|
||||||
|
wayland-backend = "0.3"
|
||||||
|
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal) + gamescope-EI.
|
||||||
|
reis = { version = "0.6.1", features = ["tokio"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||||
|
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||||
|
xkbcommon = "0.8"
|
||||||
|
# Vendored + trimmed usbip server core — presents a virtual Steam Deck over USB/IP for Steam Input.
|
||||||
|
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Devices_DeviceAndDriverInstallation",
|
||||||
|
"Win32_Devices_Enumeration_Pnp",
|
||||||
|
# SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties).
|
||||||
|
"Win32_Devices_Properties",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_IO",
|
||||||
|
"Win32_System_StationsAndDesktops",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto`
|
//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto`
|
||||||
//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via
|
//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via
|
||||||
//! [`crate::inject::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar +
|
//! [`crate::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar +
|
||||||
//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged
|
//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged
|
||||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||||
|
|
||||||
+20
-10
@@ -17,7 +17,7 @@ use super::dualsense_proto::{
|
|||||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use punktfunk_core::quic::RichInput;
|
use punktfunk_core::quic::RichInput;
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
@@ -232,13 +232,13 @@ impl Drop for DualSensePad {
|
|||||||
pub struct DsLinuxProto {
|
pub struct DsLinuxProto {
|
||||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||||
remap: crate::inject::steam_remap::RemapConfig,
|
remap: crate::steam_remap::RemapConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DsLinuxProto {
|
impl Default for DsLinuxProto {
|
||||||
fn default() -> DsLinuxProto {
|
fn default() -> DsLinuxProto {
|
||||||
DsLinuxProto {
|
DsLinuxProto {
|
||||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ impl PadProto for DsLinuxProto {
|
|||||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||||
// configured policy (default drop) so they aren't silently lost.
|
// configured policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
let mut s = DsState::from_gamepad(
|
let mut s = DsState::from_gamepad(
|
||||||
buttons,
|
buttons,
|
||||||
f.ls_x,
|
f.ls_x,
|
||||||
@@ -303,9 +303,14 @@ impl PadProto for DsLinuxProto {
|
|||||||
PadFeedback {
|
PadFeedback {
|
||||||
rumble: fb.rumble,
|
rumble: fb.rumble,
|
||||||
hidout: fb.hidout,
|
hidout: fb.hidout,
|
||||||
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
|
||||||
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
// going through hid-playstation get their stops surfaced reliably, but Steam Input
|
||||||
game_drove: None,
|
// drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows
|
||||||
|
// game, so the same watchdog applies. SDL-class writers re-assert a held level every
|
||||||
|
// ~2 s (inside the idle window), and a writer that goes silent on a latched level is
|
||||||
|
// cut exactly as real firmware decay would cut it on a physical pad.
|
||||||
|
rumble_drove: Some(fb.rumble.is_some()),
|
||||||
|
resync: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,9 +397,14 @@ impl PadProto for DsEdgeLinuxProto {
|
|||||||
PadFeedback {
|
PadFeedback {
|
||||||
rumble: fb.rumble,
|
rumble: fb.rumble,
|
||||||
hidout: fb.hidout,
|
hidout: fb.hidout,
|
||||||
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
|
||||||
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
// going through hid-playstation get their stops surfaced reliably, but Steam Input
|
||||||
game_drove: None,
|
// drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows
|
||||||
|
// game, so the same watchdog applies. SDL-class writers re-assert a held level every
|
||||||
|
// ~2 s (inside the idle window), and a writer that goes silent on a latched level is
|
||||||
|
// cut exactly as real firmware decay would cut it on a physical pad.
|
||||||
|
rumble_drove: Some(fb.rumble.is_some()),
|
||||||
|
resync: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-5
@@ -18,7 +18,7 @@ use super::dualshock4_proto::{
|
|||||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||||
DS4_TOUCH_W, DS4_VENDOR,
|
DS4_TOUCH_W, DS4_VENDOR,
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
@@ -302,13 +302,13 @@ impl Drop for DualShock4Pad {
|
|||||||
pub struct Ds4LinuxProto {
|
pub struct Ds4LinuxProto {
|
||||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||||
remap: crate::inject::steam_remap::RemapConfig,
|
remap: crate::steam_remap::RemapConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Ds4LinuxProto {
|
impl Default for Ds4LinuxProto {
|
||||||
fn default() -> Ds4LinuxProto {
|
fn default() -> Ds4LinuxProto {
|
||||||
Ds4LinuxProto {
|
Ds4LinuxProto {
|
||||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +338,7 @@ impl PadProto for Ds4LinuxProto {
|
|||||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||||
// policy (default drop) so they aren't silently lost.
|
// policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
let mut s = DsState::from_gamepad(
|
let mut s = DsState::from_gamepad(
|
||||||
buttons,
|
buttons,
|
||||||
f.ls_x,
|
f.ls_x,
|
||||||
@@ -378,7 +378,11 @@ impl PadProto for Ds4LinuxProto {
|
|||||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.collect(),
|
||||||
game_drove: None,
|
// Rumble-plane liveness (arms the shared abandoned-rumble force-off) — see the Linux
|
||||||
|
// DualSense backend for the hidraw-writer rationale; `parse_ds4_output` gates rumble
|
||||||
|
// on flag0 bit0 the same way.
|
||||||
|
rumble_drove: Some(fb.rumble.is_some()),
|
||||||
|
resync: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+171
-37
@@ -18,13 +18,12 @@
|
|||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::gamestream::gamepad;
|
use crate::pad_slots::PadSlots;
|
||||||
use crate::inject::pad_slots::PadSlots;
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use punktfunk_core::input::{GamepadFrame, MAX_PADS};
|
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::os::fd::{AsRawFd, OwnedFd};
|
use std::os::fd::{AsRawFd, OwnedFd};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
// ioctls (x86_64).
|
// ioctls (x86_64).
|
||||||
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
|
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
|
||||||
@@ -264,14 +263,80 @@ struct Effect {
|
|||||||
replay_ms: u16,
|
replay_ms: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One virtual X-Box-360 pad backed by a uinput device.
|
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||||
pub struct VirtualPad {
|
/// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so
|
||||||
fd: OwnedFd,
|
/// the policy is pure and unit-testable without a live uinput fd.
|
||||||
|
struct FfState {
|
||||||
effects: HashMap<i16, Effect>,
|
effects: HashMap<i16, Effect>,
|
||||||
next_effect_id: i16,
|
next_effect_id: i16,
|
||||||
gain: u32,
|
gain: u32,
|
||||||
/// Last `(low, high)` reported, to dedup.
|
/// Last `(low, high)` reported, to dedup.
|
||||||
last_mix: (u16, u16),
|
last_mix: (u16, u16),
|
||||||
|
/// When a game last touched the FF plane (upload / erase / play / stop / gain). An
|
||||||
|
/// infinite-replay effect still playing past the shared idle window against this is a residual
|
||||||
|
/// the game abandoned (kernel auto-erase only covers a game whose fd CLOSED) — finite effects
|
||||||
|
/// are untouched: their declared replay deadline is the contract, exactly as a real pad honors
|
||||||
|
/// it. SDL-class writers re-play held rumble every ~2 s, refreshing this clock.
|
||||||
|
last_activity: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FfState {
|
||||||
|
fn new() -> FfState {
|
||||||
|
FfState {
|
||||||
|
effects: HashMap::new(),
|
||||||
|
next_effect_id: 0,
|
||||||
|
gain: 0xFFFF,
|
||||||
|
last_mix: (0, 0),
|
||||||
|
last_activity: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The game touched the FF plane — refresh the abandoned-effect clock.
|
||||||
|
fn note_activity(&mut self) {
|
||||||
|
self.last_activity = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||||
|
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||||
|
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||||
|
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||||
|
let (mut strong, mut weak) = (0u32, 0u32);
|
||||||
|
for e in self.effects.values_mut() {
|
||||||
|
let Some(deadline) = e.playing else { continue };
|
||||||
|
match deadline {
|
||||||
|
Some(d) if now >= d => e.playing = None,
|
||||||
|
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||||
|
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||||
|
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||||
|
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||||
|
None if stale => {
|
||||||
|
tracing::info!(
|
||||||
|
strong = e.strong,
|
||||||
|
weak = e.weak,
|
||||||
|
"rumble: stale infinite FF effect (game stopped driving the pad) — forcing off"
|
||||||
|
);
|
||||||
|
e.playing = None;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
strong = strong.saturating_add(e.strong as u32);
|
||||||
|
weak = weak.saturating_add(e.weak as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
|
||||||
|
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||||
|
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||||
|
(self.last_mix != (low, high)).then(|| {
|
||||||
|
self.last_mix = (low, high);
|
||||||
|
(low, high)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One virtual X-Box-360 pad backed by a uinput device.
|
||||||
|
pub struct VirtualPad {
|
||||||
|
fd: OwnedFd,
|
||||||
|
ff: FfState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualPad {
|
impl VirtualPad {
|
||||||
@@ -370,10 +435,7 @@ impl VirtualPad {
|
|||||||
|
|
||||||
Ok(VirtualPad {
|
Ok(VirtualPad {
|
||||||
fd,
|
fd,
|
||||||
effects: HashMap::new(),
|
ff: FfState::new(),
|
||||||
next_effect_id: 0,
|
|
||||||
gain: 0xFFFF,
|
|
||||||
last_mix: (0, 0),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +523,7 @@ impl VirtualPad {
|
|||||||
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
||||||
match (ev.type_, ev.code) {
|
match (ev.type_, ev.code) {
|
||||||
(EV_UINPUT, UI_FF_UPLOAD) => {
|
(EV_UINPUT, UI_FF_UPLOAD) => {
|
||||||
|
self.ff.note_activity();
|
||||||
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
||||||
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
||||||
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
||||||
@@ -470,13 +533,13 @@ impl VirtualPad {
|
|||||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||||
let mut e = up.effect;
|
let mut e = up.effect;
|
||||||
if e.id == -1 {
|
if e.id == -1 {
|
||||||
e.id = self.next_effect_id;
|
e.id = self.ff.next_effect_id;
|
||||||
self.next_effect_id = self.next_effect_id.wrapping_add(1);
|
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
||||||
}
|
}
|
||||||
if e.type_ == FF_RUMBLE {
|
if e.type_ == FF_RUMBLE {
|
||||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||||
let slot = self.effects.entry(e.id).or_insert(Effect {
|
let slot = self.ff.effects.entry(e.id).or_insert(Effect {
|
||||||
strong: 0,
|
strong: 0,
|
||||||
weak: 0,
|
weak: 0,
|
||||||
playing: None,
|
playing: None,
|
||||||
@@ -492,20 +555,25 @@ impl VirtualPad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(EV_UINPUT, UI_FF_ERASE) => {
|
(EV_UINPUT, UI_FF_ERASE) => {
|
||||||
|
self.ff.note_activity();
|
||||||
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
||||||
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
||||||
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
||||||
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
||||||
er.request_id = ev.value as u32;
|
er.request_id = ev.value as u32;
|
||||||
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
||||||
self.effects.remove(&(er.effect_id as i16));
|
self.ff.effects.remove(&(er.effect_id as i16));
|
||||||
er.retval = 0;
|
er.retval = 0;
|
||||||
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
|
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF),
|
(EV_FF, FF_GAIN) => {
|
||||||
|
self.ff.note_activity();
|
||||||
|
self.ff.gain = (ev.value as u32).min(0xFFFF);
|
||||||
|
}
|
||||||
(EV_FF, code) => {
|
(EV_FF, code) => {
|
||||||
if let Some(e) = self.effects.get_mut(&(code as i16)) {
|
self.ff.note_activity();
|
||||||
|
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||||
e.playing = if ev.value != 0 {
|
e.playing = if ev.value != 0 {
|
||||||
Some((e.replay_ms > 0).then(|| {
|
Some((e.replay_ms > 0).then(|| {
|
||||||
Instant::now()
|
Instant::now()
|
||||||
@@ -520,26 +588,8 @@ impl VirtualPad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mix: sum playing effects (expiring finished ones), scale by gain.
|
self.ff
|
||||||
let now = Instant::now();
|
.mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout())
|
||||||
let (mut strong, mut weak) = (0u32, 0u32);
|
|
||||||
for e in self.effects.values_mut() {
|
|
||||||
if let Some(deadline) = e.playing {
|
|
||||||
if deadline.is_some_and(|d| now >= d) {
|
|
||||||
e.playing = None;
|
|
||||||
} else {
|
|
||||||
strong = strong.saturating_add(e.strong as u32);
|
|
||||||
weak = weak.saturating_add(e.weak as u32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
|
|
||||||
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
|
|
||||||
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
|
|
||||||
(self.last_mix != (low, high)).then(|| {
|
|
||||||
self.last_mix = (low, high);
|
|
||||||
(low, high)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,3 +778,87 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ff_state_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The default idle window the shared hatch resolves to when the env is unset.
|
||||||
|
const IDLE: Option<Duration> = Some(Duration::from_millis(2500));
|
||||||
|
|
||||||
|
/// `gain` is 0xFFFF (not a true 1.0 multiplier), so a magnitude loses 1 LSB in the mixdown.
|
||||||
|
fn scaled(v: u16) -> u16 {
|
||||||
|
((v as u32 * 0xFFFF) >> 16) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ff_with(effect: Effect) -> FfState {
|
||||||
|
let mut ff = FfState::new();
|
||||||
|
ff.effects.insert(0, effect);
|
||||||
|
ff
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||||
|
let mut ff = ff_with(Effect {
|
||||||
|
strong: 0x8000,
|
||||||
|
weak: 0,
|
||||||
|
playing: Some(None),
|
||||||
|
replay_ms: 0,
|
||||||
|
});
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||||
|
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||||
|
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||||
|
ff.last_activity = now - Duration::from_millis(2600);
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||||
|
assert_eq!(ff.mix(now, IDLE), None); // already off — no repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finite_effect_honors_its_replay_deadline_not_the_idle_window() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut ff = ff_with(Effect {
|
||||||
|
strong: 0x4000,
|
||||||
|
weak: 0,
|
||||||
|
playing: Some(Some(now + Duration::from_secs(10))),
|
||||||
|
replay_ms: 10_000,
|
||||||
|
});
|
||||||
|
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||||
|
// the contract (a real pad honors it too), so it keeps playing…
|
||||||
|
ff.last_activity = now - Duration::from_secs(60);
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x4000), 0)));
|
||||||
|
// …and expires at its own deadline.
|
||||||
|
assert_eq!(ff.mix(now + Duration::from_secs(11), IDLE), Some((0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_after_cut_rearms_the_effect() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut ff = ff_with(Effect {
|
||||||
|
strong: 0x8000,
|
||||||
|
weak: 0,
|
||||||
|
playing: Some(None),
|
||||||
|
replay_ms: 0,
|
||||||
|
});
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||||
|
ff.last_activity = now - Duration::from_millis(3000);
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||||
|
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||||
|
ff.last_activity = now;
|
||||||
|
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||||
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_watchdog_never_cuts() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut ff = ff_with(Effect {
|
||||||
|
strong: 0x8000,
|
||||||
|
weak: 0,
|
||||||
|
playing: Some(None),
|
||||||
|
replay_ms: 0,
|
||||||
|
});
|
||||||
|
ff.last_activity = now - Duration::from_secs(600);
|
||||||
|
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user