Compare commits
199 Commits
e88c28c15c
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2e07e865 | |||
| 6e949b6748 | |||
| 8ae161fe61 | |||
| 3a89ee8cd7 | |||
| dac0fee4e3 | |||
| 125a51d81d | |||
| 7b99b41ede | |||
| 9ea2c17419 | |||
| a9cca82fb8 | |||
| 7ab0661ddc | |||
| 92e68024f1 | |||
| 64abce6daa | |||
| bdfab8e0d5 | |||
| 8e87e617df | |||
| 5bf787eb2b | |||
| 0a6c9d8852 | |||
| 0eedfb3c1f | |||
| f6490f4c28 | |||
| d01a8fd17a | |||
| 3e7c9bd059 | |||
| 7aa787a789 | |||
| 3514702d8c | |||
| 327a5fa828 | |||
| 9777ed7fb3 | |||
| ba68a98873 | |||
| 22359f5dc8 | |||
| 7e9023faad | |||
| 5acc12d9e9 | |||
| aed0bf0c2a | |||
| b65745284e | |||
| 8ca695eb4c | |||
| 61c02e695e | |||
| 203ad8069d | |||
| 5f8c6b6147 | |||
| cd3368fc71 | |||
| bd05bc8c30 | |||
| 658564353c | |||
| 6b3cbce120 | |||
| 739fa74e68 | |||
| c87ca577a3 | |||
| e68b7330ae | |||
| e5c2b4e7f5 | |||
| 7ad3a57e68 | |||
| 22bef1fd0a | |||
| bf577044f1 | |||
| 4c95ba72a3 | |||
| 011607ec10 | |||
| 803573b4ec | |||
| 00cf51d610 | |||
| 84a3b95f17 | |||
| 8cde8621ce | |||
| 0bf3984614 | |||
| 75ee53d1dd | |||
| 0255a8289c | |||
| 6bed5d9e8e | |||
| 48202a0f89 | |||
| bf57aa4000 | |||
| 0ccd0fe676 | |||
| e1ca2e4d3c | |||
| e119aa50e9 | |||
| 683c81be03 | |||
| fe61597d92 | |||
| d9b8b88a42 | |||
| 15202011c1 | |||
| 05e87e6ab0 | |||
| 38c68c33e5 | |||
| a0427cd2a3 | |||
| a4c85af155 | |||
| 9ba90d4b77 | |||
| 5358ef9fee | |||
| 0a63154293 | |||
| e5057f6cc1 | |||
| a3eefc2374 | |||
| cd591514ad | |||
| a2bd0cd77c | |||
| 48f980ebb1 | |||
| 1cd87066d7 | |||
| 789ad49bc4 | |||
| c87bfe0e7b | |||
| f98ab07dd6 | |||
| dbab1f98ba | |||
| 5d279f8886 | |||
| e60cda3939 | |||
| d638a93e04 | |||
| a755d6eab7 | |||
| b0d28380b5 | |||
| ed583650a6 | |||
| e5c9ee8327 | |||
| 0a7ae5ef09 | |||
| 95dcef3515 | |||
| 0badc17d87 | |||
| a11b0dd3c7 | |||
| 3b21d8ecf8 | |||
| 83d3d6384a | |||
| 6399d2817d | |||
| e2f004589c | |||
| 590ceaa850 | |||
| d8a453f6ca | |||
| 024e709191 | |||
| 94e82df9f3 | |||
| bbc891e50a | |||
| 3e535f1de4 | |||
| c94a81d523 | |||
| df32060655 | |||
| 55899bf73f | |||
| 725e596d2b | |||
| d17aeefd1c | |||
| 1b0a13c25e | |||
| 3d3dd3627c | |||
| ad27174027 | |||
| d0d31b1040 | |||
| 4f10f3439d | |||
| 788e4acbb5 | |||
| d7a9fbf0b6 | |||
| f652617f30 | |||
| ae803b24d5 | |||
| 3fbabc854c | |||
| 8c4e7b07bf | |||
| 6d8c7a5185 | |||
| 2f7847ce9b | |||
| c6a818e985 | |||
| f34e956818 | |||
| 04e52b0c22 | |||
| 2df3c0f2b4 | |||
| 60df3c9c52 | |||
| 9fd19b90a9 | |||
| 6975691f7d | |||
| f896f70bb8 | |||
| b24c10a723 | |||
| 1682b83b3f | |||
| 838cac4f69 | |||
| 4f62643c82 | |||
| c91e7a0e38 | |||
| bed4711096 | |||
| 5d3cb5e63f | |||
| d3e4ea0118 | |||
| 43144203fa | |||
| d8a7d6f3a2 | |||
| 8a04db9844 | |||
| 0b663cefb6 | |||
| e2c9bfd3d9 | |||
| c5dab484df | |||
| e27abc065e | |||
| d39da4bc06 | |||
| 095540efc2 | |||
| de232ec2f7 | |||
| e4e34fdb48 | |||
| 3ec462c2ea | |||
| 58f4dccc02 | |||
| 32879f45bf | |||
| b54f781524 | |||
| 5e106c51cf | |||
| d2746bd65a | |||
| 9b840151e4 | |||
| a12c6e0ba4 | |||
| b0c82333d2 | |||
| f208f3d92e | |||
| 51de8ccbdb | |||
| 118752c136 | |||
| 9af8e9a7d9 | |||
| e466814ef8 | |||
| 95c6ceb072 | |||
| e919fa6a2e | |||
| 6db3525e29 | |||
| 6a501f484a | |||
| 72eeedc4da | |||
| fde438a1ed | |||
| 01dc0b616c | |||
| 4a73102d48 | |||
| aa159df33f | |||
| 983adc5347 | |||
| 78c16e5136 | |||
| 0205c7b8d6 | |||
| 3e6c9f6060 | |||
| b3811ff72e | |||
| b6b0b6c29e | |||
| 527c2f677e | |||
| f3555d5eb5 | |||
| 75d5a6d7fb | |||
| 1fe4161d4d | |||
| 54b75c9be4 | |||
| 3c55ec37fa | |||
| 551012bb43 | |||
| 3526517eb1 | |||
| 22a9ce4229 | |||
| 450bcf1e7b | |||
| a2a6b858f7 | |||
| f85d51b9f9 | |||
| 516efcc3a3 | |||
| 4afdb18cc4 | |||
| 9f049f965f | |||
| f37a304fba | |||
| 76f4484ded | |||
| cba3ae48e2 | |||
| 2dc54bc651 | |||
| 480dee863d | |||
| 618602d802 | |||
| fdf388436a | |||
| 0f7f1be3c3 |
@@ -0,0 +1,20 @@
|
||||
# cargo-audit configuration — consumed by `.gitea/workflows/audit.yml` (`cargo audit`).
|
||||
#
|
||||
# Silence only advisories that are KNOWN-UNFIXABLE and either not applicable to how we use the crate
|
||||
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
|
||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||
#
|
||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
||||
# so we keep getting the maintenance signal — they do not fail CI.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
||||
"RUSTSEC-2023-0071",
|
||||
]
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||
# docs/api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# out of the context upload.
|
||||
*
|
||||
!web
|
||||
!docs/api/openapi.json
|
||||
!api/openapi.json
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -12,6 +12,10 @@ name: android
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -69,11 +73,24 @@ jobs:
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
run: ./gradlew :app:assembleDebug --stacktrace
|
||||
|
||||
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||
RELEASE_KEYSTORE_FILE: "../release.jks"
|
||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
@@ -85,33 +102,52 @@ jobs:
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
- name: Publish AAB + APK to Gitea generic registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-android
|
||||
VERSION: ${{ github.run_number }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
|
||||
APK=clients/android/app/build/outputs/apk/release/app-release.apk
|
||||
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG/$VERSION"
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/punktfunk-android-r$VERSION.aab"
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/punktfunk-android-r$VERSION.apk"
|
||||
echo "Published artifacts (versionCode=$VERSION):"
|
||||
echo " $base/punktfunk-android-r$VERSION.aab"
|
||||
echo " $base/punktfunk-android-r$VERSION.apk"
|
||||
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG"
|
||||
# 1) immutable, run-number-versioned store (sideload + provenance)
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab"
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk"
|
||||
echo "published store version $VERSION (versionCode)"
|
||||
# 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/
|
||||
case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true
|
||||
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk"
|
||||
echo "sideload alias: $base/$ALIAS/punktfunk-android.apk"
|
||||
# 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab"
|
||||
upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
- name: Upload to Google Play (Internal Testing)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track internal --status completed
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||
#
|
||||
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||
name: apple
|
||||
|
||||
on:
|
||||
@@ -37,3 +42,55 @@ jobs:
|
||||
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
||||
working-directory: clients/apple
|
||||
run: swift test
|
||||
|
||||
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
#
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
run: |
|
||||
# Independent invocations: one platform failing skips it, not the other.
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Upload screenshots (zip artifact)
|
||||
if: always()
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
|
||||
# (upload-artifact@v4) refuses. v3 uses the older API Gitea supports; download is still a zip.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-appstore-screenshots
|
||||
path: clients/apple/screenshots
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
+32
-14
@@ -13,16 +13,16 @@ name: deb
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a
|
||||
# host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that
|
||||
# outranked every rolling build (the version-shadow). Host releases use `host-v*`.
|
||||
tags: ['host-v*']
|
||||
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
|
||||
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
|
||||
# that outranked rolling builds) is now structurally impossible — main publishes to the
|
||||
# `canary` apt distribution, tags to `stable`, so the two never share a version line.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
DISTRIBUTION: stable
|
||||
COMPONENT: main
|
||||
|
||||
jobs:
|
||||
@@ -34,19 +34,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version
|
||||
# host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g<sha>: the '~'
|
||||
# sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits
|
||||
# ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the
|
||||
# build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;;
|
||||
*) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "package version $V"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "package version $V -> apt distribution '$DIST'"
|
||||
|
||||
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
|
||||
# deps are also baked into the rust-ci image, but this job runs against the image
|
||||
@@ -55,7 +58,8 @@ jobs:
|
||||
- name: dpkg-dev + client link deps
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev \
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
@@ -124,3 +128,17 @@ jobs:
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
# On a real release, also attach the .debs to the unified Gitea Release so they're on the
|
||||
# downloads page next to every other platform's artifact (canary builds live in the apt
|
||||
# `canary` distribution above — no release page for those).
|
||||
- name: Attach .debs to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
+20
-27
@@ -56,19 +56,20 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||
|
||||
- name: Version
|
||||
# Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g<sha>. Used only for the registry
|
||||
# version path + the zip name (the plugin.json version is the source of truth Decky
|
||||
# reads after install).
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
|
||||
# (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
|
||||
# version is the source of truth Decky reads after install — bump it in the release commit).
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "decky version $V"
|
||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||
echo "decky version $V -> alias '$ALIAS'"
|
||||
|
||||
- name: Assemble store-layout zip
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
@@ -102,29 +103,21 @@ jobs:
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/$VERSION/punktfunk.zip"
|
||||
echo "published $BASE/$VERSION/punktfunk.zip"
|
||||
# 2) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's
|
||||
# "install from URL". The generic registry rejects re-uploading an existing
|
||||
# version/file (409), so delete the prior `latest` first (ignore 404 on run #1).
|
||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
|
||||
# to paste into Decky's "install from URL". The generic registry rejects re-uploading
|
||||
# an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/latest/punktfunk.zip" || true
|
||||
"$BASE/$ALIAS/punktfunk.zip" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||
"$BASE/latest/punktfunk.zip"
|
||||
echo "install-from-URL link: $BASE/latest/punktfunk.zip"
|
||||
"$BASE/$ALIAS/punktfunk.zip"
|
||||
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
|
||||
|
||||
- name: Attach zip to the Gitea release (tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
- name: Attach zip to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null
|
||||
echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME"
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip"
|
||||
|
||||
@@ -58,16 +58,21 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
||||
$EXTRA \
|
||||
"${{ matrix.context }}"
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
@@ -71,19 +71,23 @@ jobs:
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
- name: Version
|
||||
# Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g<sha> (sorts before a real release,
|
||||
# increases by run number — newest main build always wins). The generic registry
|
||||
# version string allows letters/dots/hyphens.
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
echo "flatpak version $V"
|
||||
echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV"
|
||||
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
|
||||
echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'"
|
||||
|
||||
- name: Generate offline cargo sources
|
||||
# flatpak builds with no network; vendor every crate from Cargo.lock into
|
||||
@@ -108,19 +112,20 @@ jobs:
|
||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||
# container-safe path (no FUSE).
|
||||
# --default-branch=stable pins the ref to app/io.unom.Punktfunk/x86_64/stable so the
|
||||
# hosted .flatpakref (Branch=stable) matches deterministically (manifest sets no branch).
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch=stable \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Export single-file bundle
|
||||
run: |
|
||||
# Branch must be passed explicitly now that the repo ref is `stable` (--default-branch
|
||||
# above); build-bundle otherwise defaults to `master` and errors "Refspec … not found".
|
||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" stable
|
||||
# Branch must be passed explicitly (matches --default-branch above); build-bundle
|
||||
# otherwise defaults to `master` and errors "Refspec … not found".
|
||||
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH"
|
||||
ls -lh "$BUNDLE"
|
||||
|
||||
- name: Publish to the Gitea generic registry
|
||||
@@ -132,14 +137,14 @@ jobs:
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/$VERSION/$BUNDLE"
|
||||
echo "published $BASE/$VERSION/$BUNDLE"
|
||||
# 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts.
|
||||
# The generic registry rejects re-uploading an existing version/file (409), so
|
||||
# delete the prior `latest` file first (ignore 404 on the first ever run).
|
||||
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the
|
||||
# Decky fallback + scripts. The generic registry rejects re-uploading an existing
|
||||
# version/file (409), so delete the prior alias file first (ignore 404 on run #1).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"$BASE/latest/punktfunk-client.flatpak" || true
|
||||
"$BASE/$ALIAS/punktfunk-client.flatpak" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||
"$BASE/latest/punktfunk-client.flatpak"
|
||||
echo "published $BASE/latest/punktfunk-client.flatpak"
|
||||
"$BASE/$ALIAS/punktfunk-client.flatpak"
|
||||
echo "published $BASE/$ALIAS/punktfunk-client.flatpak"
|
||||
|
||||
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
|
||||
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
|
||||
@@ -165,7 +170,7 @@ jobs:
|
||||
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
|
||||
# required — clients with gpg-verify=true verify the commit, so summary-only signing
|
||||
# fails the pull with "GPG verification enabled, but no signatures found".
|
||||
flatpak build-sign "$PWD/repo" "$APP_ID" stable \
|
||||
flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-update-repo --generate-static-deltas \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||
@@ -180,23 +185,33 @@ jobs:
|
||||
Comment=unom Flatpak applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
cat > "site/${APP_ID}.flatpakref" <<EOF
|
||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||
# the server always offers both (the stable ref only resolves once a release has built the
|
||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||
write_ref() { # <filename> <branch> <title>
|
||||
cat > "site/$1" <<EOF
|
||||
[Flatpak Ref]
|
||||
Name=$APP_ID
|
||||
Branch=stable
|
||||
Branch=$2
|
||||
Url=$REPO_URL/repo/
|
||||
Title=Punktfunk
|
||||
Title=$3
|
||||
Homepage=https://punktfunk.unom.io
|
||||
IsRuntime=false
|
||||
GPGKey=$GPGKEY
|
||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
EOF
|
||||
}
|
||||
write_ref "${APP_ID}.flatpakref" stable "Punktfunk"
|
||||
write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)"
|
||||
cat > site/index.html <<EOF
|
||||
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
|
||||
<h1>unom Flatpak repository</h1>
|
||||
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates):</p>
|
||||
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).</p>
|
||||
<p><b>Stable</b> (recommended — only moves on releases):</p>
|
||||
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
|
||||
flatpak run $APP_ID</pre>
|
||||
<p><b>Canary</b> (latest main build, unstable):</p>
|
||||
<pre>flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref</pre>
|
||||
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
|
||||
EOF
|
||||
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
|
||||
@@ -207,24 +222,16 @@ jobs:
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
|
||||
- name: Attach bundle to the Gitea release (tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
- name: Attach bundle to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$BUNDLE" >/dev/null
|
||||
echo "attached $BUNDLE to release $GITHUB_REF_NAME"
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$BUNDLE"
|
||||
|
||||
@@ -46,6 +46,19 @@ name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/apple/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/release.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -87,8 +100,8 @@ jobs:
|
||||
- name: Version from tag
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="0.0.${GITHUB_RUN_NUMBER}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
@@ -106,6 +119,9 @@ jobs:
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
@@ -116,6 +132,9 @@ jobs:
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
@@ -154,23 +173,14 @@ jobs:
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG to Gitea release
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
- name: Attach DMG to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
# Create the release (409 -> already exists, fetch it instead).
|
||||
ID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|
||||
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$DMG" >/dev/null
|
||||
echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME"
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
@@ -278,6 +288,8 @@ jobs:
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
|
||||
+32
-13
@@ -13,9 +13,10 @@ name: rpm
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host
|
||||
# RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build).
|
||||
tags: ['host-v*']
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -66,20 +67,22 @@ jobs:
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version
|
||||
# host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g<sha>, whose
|
||||
# "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the
|
||||
# stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build
|
||||
# stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
run: |
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;;
|
||||
*) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R"
|
||||
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
||||
@@ -101,6 +104,22 @@ jobs:
|
||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||
echo "uploading $rpm"
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload"
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
- name: Attach .rpms to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for rpm in dist/*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
echo "published to $OWNER/rpm/${{ matrix.group }}"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
|
||||
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
|
||||
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
|
||||
#
|
||||
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
|
||||
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
|
||||
name: windows-drivers-provision
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/ci/provision-windows-wdk.ps1'
|
||||
- '.gitea/workflows/windows-drivers-provision.yml'
|
||||
|
||||
jobs:
|
||||
provision:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install WDK + cargo-wdk on the runner
|
||||
run: ./scripts/ci/provision-windows-wdk.ps1
|
||||
@@ -0,0 +1,150 @@
|
||||
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
|
||||
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
||||
#
|
||||
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
|
||||
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
|
||||
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
|
||||
# only live NVENC encode does, which defers to the RTX box.
|
||||
#
|
||||
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||
# first GITHUB_ENV line — see windows.yml).
|
||||
name: windows-drivers
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
|
||||
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||
|
||||
jobs:
|
||||
probe-and-proto:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Probe driver toolchain (informational — never fails the job)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function head($t) { Write-Host ""; Write-Host "===== $t =====" }
|
||||
|
||||
head "Windows Kits roots"
|
||||
$kits = @('C:\Program Files (x86)\Windows Kits\10', 'C:\Program Files\Windows Kits\10')
|
||||
foreach ($k in $kits) { if (Test-Path $k) { Write-Host "found: $k" } }
|
||||
|
||||
head "SDK Include versions (um vs km — km => WDK present)"
|
||||
foreach ($k in $kits) {
|
||||
$inc = Join-Path $k 'Include'
|
||||
if (Test-Path $inc) {
|
||||
Get-ChildItem $inc -Directory | ForEach-Object {
|
||||
$hasUm = Test-Path (Join-Path $_.FullName 'um')
|
||||
$hasKm = Test-Path (Join-Path $_.FullName 'km')
|
||||
$wdf = Test-Path (Join-Path $_.FullName 'km\wdf\umdf\2.31')
|
||||
$iddcx = (Get-ChildItem (Join-Path $_.FullName 'um\iddcx') -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) -join ','
|
||||
Write-Host ("{0,-16} um={1,-5} km={2,-5} wdf2.31={3,-5} iddcx=[{4}]" -f $_.Name, $hasUm, $hasKm, $wdf, $iddcx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
head "Driver tooling (inf2cat / stampinf / signtool / devgen / InfVerif)"
|
||||
foreach ($tool in 'inf2cat.exe','stampinf.exe','signtool.exe','devgen.exe','InfVerif.exe','makecat.exe') {
|
||||
$hits = @()
|
||||
foreach ($k in $kits) {
|
||||
$hits += Get-ChildItem -Path $k -Filter $tool -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
$hits = $hits | Where-Object { $_ } | Select-Object -First 1
|
||||
Write-Host ("{0,-14} -> {1}" -f $tool, ($(if ($hits) { $hits } else { 'NOT FOUND' })))
|
||||
}
|
||||
|
||||
head "EWDK"
|
||||
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
|
||||
|
||||
head "LLVM / clang (bindgen 0.72 builds on the runner default clang)"
|
||||
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
|
||||
$clang = Get-Command clang -ErrorAction SilentlyContinue
|
||||
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
|
||||
|
||||
head "cargo-make (the gamepad drivers' build driver)"
|
||||
$cm = & cargo make --version 2>&1; Write-Host $cm
|
||||
|
||||
head "Rust + targets"
|
||||
& rustc -V; & cargo -V
|
||||
Write-Host "installed targets:"; & rustup target list --installed
|
||||
|
||||
head "Env knobs the WDK build cares about"
|
||||
Write-Host ("Version_Number = " + ($env:Version_Number ?? '<unset>'))
|
||||
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
|
||||
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
|
||||
|
||||
- name: Build + test pf-driver-proto (MSVC)
|
||||
run: |
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||
cargo build -p pf-driver-proto
|
||||
cargo test -p pf-driver-proto
|
||||
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
|
||||
cargo fmt -p pf-driver-proto -- --check
|
||||
|
||||
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
|
||||
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
# In-tree target dir on purpose: wdk-build's find_top_level_cargo_manifest() walks UP from OUT_DIR
|
||||
# to the first ancestor with a Cargo.lock, so a relocated CARGO_TARGET_DIR (C:\t\…) hides the
|
||||
# workspace lock and it panics. The driver deps have no deep CMake-from-source crates, so the
|
||||
# default in-tree target stays well under MAX_PATH (unlike the SDL3/audiopus client build).
|
||||
working-directory: packaging/windows/drivers
|
||||
env:
|
||||
# wdk-build otherwise picks 10.0.28000.0 (no km/crt) and bindgen fails — pin the WDK SDK version.
|
||||
Version_Number: '10.0.26100.0'
|
||||
# No LIBCLANG_PATH pin: the vendored bindgen 0.72 builds clean on the runner's default clang 22
|
||||
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
|
||||
# retired that — see design/windows-build-and-packaging.md.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure WDK + cargo-wdk (idempotent self-provision)
|
||||
# Run the provisioning script here too so driver-build is self-sufficient and never races a
|
||||
# separate provision run on the single runner. Path is relative to the job working-directory
|
||||
# (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/provision-windows-wdk.ps1
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||
run: cargo build -v
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
$dll = "target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll"
|
||||
if (-not (Test-Path $dll)) { throw "pf_vdisplay.dll not produced at $dll" }
|
||||
$b = [IO.File]::ReadAllBytes($dll)
|
||||
$pe = [BitConverter]::ToInt32($b, 0x3c)
|
||||
$dllchar = [BitConverter]::ToUInt16($b, $pe + 0x5e) # OptionalHeader.DllCharacteristics
|
||||
Write-Host ("pf_vdisplay.dll built OK ({0:N0} bytes)" -f (Get-Item $dll).Length)
|
||||
Write-Host ("BEFORE: DllCharacteristics = 0x{0:X4}; FORCE_INTEGRITY = {1}" -f $dllchar, (($dllchar -band 0x0080) -ne 0))
|
||||
- name: Clear FORCE_INTEGRITY (self-signed-load fix) + verify
|
||||
# wdk-build sets /INTEGRITYCHECK unconditionally -> a self-signed driver won't load. Clear the PE
|
||||
# bit deterministically (the reusable packaging step; signing/.cat happen later for real drivers).
|
||||
run: ../clear-force-integrity.ps1 -Path target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll
|
||||
@@ -1,6 +1,7 @@
|
||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||
# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
||||
#
|
||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||
@@ -11,18 +12,21 @@
|
||||
#
|
||||
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
#
|
||||
# Versioning (free-form; not MSIX's 4-part rule):
|
||||
# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v*
|
||||
# to avoid the version-shadow bug class — see deb.yml).
|
||||
# main push / dispatch -> 0.2.<run_number> (rolling; climbs monotonically by run number).
|
||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||
# unified Gitea Release).
|
||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# NVENC: the host builds with --features nvenc; the only link need is nvencodeapi.lib, synthesised
|
||||
# from a 2-export .def with llvm-dlltool (no GPU/SDK at build time). The resulting exe is NVIDIA-only
|
||||
# by design — CI never launches it, so no GPU is needed here.
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||
name: windows-host
|
||||
|
||||
on:
|
||||
@@ -32,11 +36,12 @@ on:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/host.env.example'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-host.yml'
|
||||
tags: ['host-win-v*']
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -51,6 +56,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Locale-safety gate (installer-run scripts must be ASCII)
|
||||
shell: pwsh
|
||||
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
||||
# USER's box. PS 5.1 reads a BOM-less script in the active ANSI codepage, so on a non-UTF-8 locale
|
||||
# (e.g. German Windows-1252) a stray em-dash mis-decodes into a curly quote and the script aborts
|
||||
# with "unterminated string" - exactly how the pf-vdisplay driver install silently failed in the
|
||||
# field. Keep every installer-run script pure ASCII (matches install-gamepad-drivers.ps1).
|
||||
run: |
|
||||
$bad = Get-ChildItem packaging/windows/*.ps1, scripts/windows/*.ps1, scripts/windows/*.cmd -ErrorAction SilentlyContinue |
|
||||
Where-Object { [IO.File]::ReadAllText($_.FullName) -match '[^\x00-\x7F]' }
|
||||
if ($bad) {
|
||||
$bad.FullName | ForEach-Object { Write-Output "::error::non-ASCII in installer-run script: $_" }
|
||||
throw "installer-run scripts must be pure ASCII (PS 5.1 mis-parses them on non-UTF-8 locales)"
|
||||
}
|
||||
Write-Output "installer-run scripts are ASCII-clean"
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -59,10 +80,17 @@ jobs:
|
||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^host-win-v', ''
|
||||
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
} else {
|
||||
"0.2.$($env:GITHUB_RUN_NUMBER)"
|
||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||
}
|
||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
@@ -74,14 +102,27 @@ jobs:
|
||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build (release, nvenc)
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-host --features nvenc
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
|
||||
- name: Clippy (host, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: cargo clippy -p punktfunk-host --features nvenc -- -D warnings
|
||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||
# like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of
|
||||
# silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally).
|
||||
# Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI.
|
||||
run: |
|
||||
Push-Location packaging/windows/pf-vkhdr-layer
|
||||
cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" }
|
||||
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||
Pop-Location
|
||||
|
||||
- name: Ensure Inno Setup
|
||||
shell: pwsh
|
||||
@@ -91,6 +132,59 @@ jobs:
|
||||
choco install innosetup -y --no-progress
|
||||
}
|
||||
|
||||
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
|
||||
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
|
||||
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||
$ver = 'bun-v1.3.14'
|
||||
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||
if (-not $bun) { throw "bun.exe not found in $url" }
|
||||
"BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
& $bun --version
|
||||
|
||||
- name: Build + smoke-boot web console (bun)
|
||||
shell: pwsh
|
||||
env:
|
||||
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
|
||||
# output self-contained, so there's no .output/server install — the installer ships bun + the
|
||||
# ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
|
||||
# the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
|
||||
# registry mapping, and nothing copies it into .output).
|
||||
run: |
|
||||
$bun = $env:BUN_EXE
|
||||
if ($env:REGISTRY_TOKEN) {
|
||||
$rc = Join-Path $env:USERPROFILE '.npmrc'
|
||||
Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
|
||||
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||
}
|
||||
Push-Location web
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
||||
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
||||
}
|
||||
Pop-Location
|
||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||
$env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
|
||||
$server = (Resolve-Path 'web\.output\server\index.mjs').Path
|
||||
$p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 4
|
||||
try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -116,13 +210,25 @@ jobs:
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
|
||||
# On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like
|
||||
# flatpak.yml/decky.yml) so there's a predictable download URL.
|
||||
if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') {
|
||||
$aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
foreach ($f in $files) {
|
||||
$alias = $aliases[$f]; if (-not $alias) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null
|
||||
Publish-File $f "$base/latest/$alias"
|
||||
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Publish-File $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
|
||||
- name: Attach host installer to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
|
||||
# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace,
|
||||
# kept off the host's `host-v*` and the Apple `v*` to avoid the
|
||||
# version-shadow class of bug — see deb.yml).
|
||||
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
||||
@@ -34,7 +35,7 @@ on:
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
tags: ['win-v*']
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -72,10 +73,11 @@ jobs:
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') {
|
||||
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
@('0', '2', $env:GITHUB_RUN_NUMBER)
|
||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
@@ -101,11 +103,43 @@ jobs:
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
|
||||
Write-Output "published $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
$url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name"
|
||||
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
|
||||
Write-Output "published $name -> $url"
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ dist/
|
||||
clients/apple/.build/
|
||||
clients/apple/PunktfunkCore.xcframework/
|
||||
clients/apple/.swiftpm/
|
||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||
clients/apple/screenshots/
|
||||
# Xcode per-user state
|
||||
xcuserdata/
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||
[`docs/implementation-plan.md`](docs/implementation-plan.md). Status table: `README.md`.
|
||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||
|
||||
## Where the work stands
|
||||
|
||||
@@ -27,7 +27,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`).
|
||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||
boundary, and finished captures are saved as on-disk recordings
|
||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
@@ -47,7 +55,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve --native` and `punktfunk1-host` advertise the native service over
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
@@ -65,7 +73,55 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||
(UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one
|
||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed
|
||||
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`).
|
||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||
the remaining piece.)
|
||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
||||
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
|
||||
`--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback
|
||||
+ virtual mic (`audio/wasapi_*`). Ships as a **signed Inno Setup installer** that registers a
|
||||
`LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen)
|
||||
capture (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, and is published by
|
||||
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||
`PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
|
||||
direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||
(`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
|
||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||
|
||||
## What's left
|
||||
|
||||
@@ -88,9 +144,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
||||
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
||||
`tools/latency-probe` (scaffold), iOS variant.
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
||||
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
||||
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
||||
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
|
||||
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
@@ -98,7 +156,7 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||
against `serve --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||
@@ -152,24 +210,38 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
|
||||
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
|
||||
RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI.
|
||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait
|
||||
in the accept queue). **Done:** unified host (`serve --native` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API /
|
||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. Next (see roadmap): **delegated pairing approval** (an already-paired device
|
||||
approves a new one).
|
||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
||||
own app.
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
||||
5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`.
|
||||
|
||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||
@@ -188,15 +260,18 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip
|
||||
```
|
||||
|
||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `docs/api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > docs/api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
|
||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`).
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -207,15 +282,17 @@ crates/punktfunk-host/
|
||||
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
||||
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
|
||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||
clients/apple/ native macOS/iOS client (Swift · VideoToolbox · GameController)
|
||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||
clients/decky/ Steam Deck Decky plugin
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
||||
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
|
||||
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing · performance graphs)
|
||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||
include/punktfunk_core.h generated C header
|
||||
@@ -252,9 +329,9 @@ scanout → KWin `--drm` impossible; everything renders offscreen via `renderD12
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2):
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
@@ -268,7 +345,8 @@ or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test), `PUNKTFUNK_FEC_PCT=N`.
|
||||
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Generated
+446
-33
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
@@ -453,6 +459,20 @@ name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
@@ -721,6 +741,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
@@ -996,6 +1025,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.1"
|
||||
@@ -1074,6 +1115,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1097,6 +1148,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1572,7 +1629,16 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1580,6 +1646,18 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1698,12 +1776,115 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -1952,12 +2133,29 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2052,6 +2250,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -2404,6 +2612,13 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2477,6 +2692,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@@ -2547,6 +2771,7 @@ dependencies = [
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"ndk",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
@@ -2633,6 +2858,8 @@ dependencies = [
|
||||
"audiopus_sys",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"cbc",
|
||||
"ffmpeg-next",
|
||||
"futures-util",
|
||||
@@ -2647,13 +2874,16 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reis",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rusty_enet",
|
||||
@@ -2665,10 +2895,10 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
"vigem-client",
|
||||
"wasapi",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
@@ -2677,6 +2907,7 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
@@ -2979,6 +3210,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpkg-config"
|
||||
version = "0.1.2"
|
||||
@@ -3005,6 +3245,31 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3455,6 +3720,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3525,6 +3796,24 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -3677,6 +3966,16 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
@@ -3982,6 +4281,40 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@@ -4072,15 +4405,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vigem-client"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
@@ -4318,6 +4642,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
@@ -4334,22 +4676,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4359,12 +4685,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
@@ -4865,6 +5185,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
@@ -4959,6 +5289,12 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
@@ -5002,6 +5338,29 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
@@ -5078,12 +5437,66 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/windows",
|
||||
|
||||
@@ -1,98 +1,157 @@
|
||||
# punktfunk
|
||||
<p align="center">
|
||||
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||
</p>
|
||||
|
||||
*A ground-up low-latency desktop streaming stack, built Linux-first, with a shared Rust
|
||||
protocol core and native clients per platform.*
|
||||
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||
|
||||
`punktfunk` is a placeholder codename. The bet: ship a **Linux virtual-display streaming
|
||||
host** that speaks the existing Moonlight protocol (every Moonlight/Artemis client works
|
||||
day one), then break the ~1 Gbps FEC wall with a **GF(2¹⁶) Leopard-RS** transport as a
|
||||
negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-plan.md).
|
||||
Run the host on a Linux machine or a Windows PC, connect from a Mac, PC, phone, tablet, or TV, and
|
||||
stream your desktop or games — each device at its **own native resolution and refresh rate**, over
|
||||
your local network.
|
||||
|
||||
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
||||
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
||||
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
||||
|
||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
|
||||
|
||||
## What makes it different
|
||||
|
||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||
letterboxing, no scaling, no rearranging your real monitors.
|
||||
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||
indirect display driver (IDD)** the host pushes finished frames straight into, rather than scraping
|
||||
a screen — tight, push-based integration that's unusual for a Windows streaming host.
|
||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
||||
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
||||
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||
reconnect on a pinned identity. No accounts, no cloud. Hosts auto-advertise over mDNS, so clients
|
||||
find them on the network without typing an IP.
|
||||
|
||||
## Status
|
||||
|
||||
| Milestone | State |
|
||||
| Component | State |
|
||||
|-----------|-------|
|
||||
| **Core — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
|
||||
| **GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol — `punktfunk/1`** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
|
||||
| **Native clients — decode + present** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
|
||||
| **Web console + management API** | ✅ TanStack web console (`web/`) over the OpenAPI mgmt API: host status, paired devices, on-demand native pairing (arm → show PIN) |
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA
|
||||
(RTX 5070 Ti & RTX 4090, driver 595): trust-on-first-use pairing that persists, an app
|
||||
catalog, RTSP/ENet/audio, and **video at the client's exact resolution and refresh** via a
|
||||
per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded with GPU
|
||||
**zero-copy** (dmabuf → CUDA/Vulkan → NVENC) at up to 5120×1440@240. The native
|
||||
**`punktfunk/1`** protocol adds a QUIC control plane and a GF(2¹⁶) Leopard-FEC + AES-GCM data
|
||||
plane (p50 ~0.8 ms capture→reassembled at 720p120). Its trust model is **SPAKE2 PIN pairing by
|
||||
default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
|
||||
run from **one process** (`serve --native`), managed through a REST API + web console. Builds
|
||||
against FFmpeg 7 or 8; deployed live on Bazzite. Full status: [`CLAUDE.md`](CLAUDE.md);
|
||||
roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs;
|
||||
`bun run dev`), with the canonical [roadmap](docs-site/content/docs/roadmap.md) and
|
||||
[status](docs-site/content/docs/status.md) there. Design notes stay in [`docs/`](docs).
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
|
||||
## Install (host)
|
||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
|
||||
The package registries are the real distribution channel — pick your distro and run one command.
|
||||
Per-distro setup (add the repo, first-run, web console) lives in the linked READMEs.
|
||||
## Install the host
|
||||
|
||||
| Distro | One-command happy path | Details |
|
||||
|--------|------------------------|---------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [`packaging/debian/README.md`](packaging/debian/README.md) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(after adding the repo; or the bootc image)* | [`packaging/rpm/README.md`](packaging/rpm/README.md) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS/Deck)* | [`packaging/arch/README.md`](packaging/arch/README.md) |
|
||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
||||
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status);
|
||||
`punktfunk-client` is the GTK4 desktop client (also shipped via apt/RPM/Arch/Flatpak). After install,
|
||||
run `punktfunk-host serve --native` inside your desktop session, then pair from the web console.
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
Building from source (below) is a fallback.
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
## Layout
|
||||
## Connect a client
|
||||
|
||||
```
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · quic — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · punktfunk1 · mgmt · native_pairing
|
||||
clients/
|
||||
probe/ punktfunk/1 reference/probe client (headless test + latency measurement)
|
||||
linux/ windows/ native desktop clients (Rust: GTK4 / WinUI 3, link punktfunk-core directly)
|
||||
apple/ android/ Swift (macOS+iOS) · Kotlin app + native/ Rust JNI core
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ TanStack web console (host status · paired devices · pairing) over the mgmt API
|
||||
packaging/ Fedora/Bazzite RPM · bootc image · COPR (see packaging/bazzite/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/{latency-probe,loss-harness}/ measurement (plan §10)
|
||||
docs/{implementation-plan,roadmap,windows-host,dualsense-haptics}.md
|
||||
```
|
||||
| Streaming to… | Use |
|
||||
|---|---|
|
||||
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||
|
||||
Each client discovers hosts on the network automatically and does a one-time
|
||||
[PIN pairing](https://docs.punktfunk.unom.io/docs/pairing). Per-device install steps:
|
||||
**[/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
|
||||
## Build & test (from source)
|
||||
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
|
||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
The C header regenerates from `crates/punktfunk-core/src/abi.rs` on every build (cbindgen via
|
||||
`build.rs`) into `include/punktfunk_core.h`.
|
||||
`build.rs`) into `include/punktfunk_core.h`. The Apple, Android, and Windows clients have their own
|
||||
toolchains (Xcode/`swift build`, Gradle, and `cargo` on the MSVC target) — see each client's README
|
||||
and the [docs site](https://docs.punktfunk.unom.io).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
clients/
|
||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · Oboe)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
```
|
||||
|
||||
## Design invariants
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto/pacing live in `punktfunk-core` exactly
|
||||
once, exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig`
|
||||
carries its own `struct_size`).
|
||||
- **No async on the hot path.** The per-frame pipeline uses native threads only;
|
||||
`tokio`/`quinn` are gated behind the off-by-default `quic` feature (control plane only).
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compat;
|
||||
GF(2¹⁶) (≤65535 shards/block, SIMD, O(n log n)) to push past ~1 Gbps.
|
||||
- **One core, linked everywhere.** Protocol, FEC, and crypto live in `punktfunk-core` exactly once,
|
||||
exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig` carries its own
|
||||
`struct_size`). Every native client links the same core.
|
||||
- **No async on the hot path.** The per-frame pipeline uses native threads only; `tokio`/`quinn` are
|
||||
gated behind the off-by-default `quic` feature (control plane only).
|
||||
- **Native client resolution, no scaling.** Each session gets a virtual output at exactly the
|
||||
client's WxH@Hz; each compositor keeps its own backend behind a shared `VirtualDisplay` trait.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compatibility; GF(2¹⁶)
|
||||
(≤65535 shards/block, SIMD, O(n log n)) for `punktfunk/1` to push past ~1 Gbps.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+2229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="100%" height="100%" viewBox="0 0 579 298" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<style>
|
||||
/* Theme-adaptive so the logo stays readable on both light and dark README
|
||||
backgrounds: deep violet (the brand-mark palette) on light, the original
|
||||
light violet on dark. Evaluated by the viewer's color scheme. */
|
||||
.pf-wm { fill: #6c5bf3; }
|
||||
.pf-back { fill: #a79ff8; }
|
||||
.pf-deep { fill: #6c5bf3; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pf-wm { fill: #cec9fb; }
|
||||
.pf-back { fill: #f2f1fe; }
|
||||
.pf-deep { fill: #8c7ef5; }
|
||||
}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="pf-wm" style="fill-rule:nonzero;" d="M21.144,176.635l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z"/>
|
||||
<path class="pf-wm" style="fill-rule:nonzero;" d="M136.148,176.635l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z"/>
|
||||
<path class="pf-wm" style="fill-rule:nonzero;" d="M275.938,176.527l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z"/>
|
||||
<path class="pf-wm" style="fill-rule:nonzero;" d="M425.273,176.527l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z"/>
|
||||
</g>
|
||||
<path class="pf-back" style="fill-rule:nonzero;" d="M65.442,150.143c24.514,0 44.298,-19.784 44.298,-44.298c0,-24.514 -19.784,-44.298 -44.298,-44.298c-24.514,0 -44.298,19.784 -44.298,44.298c0,24.514 19.784,44.298 44.298,44.298Z"/>
|
||||
<path class="pf-deep" style="fill-rule:nonzero;" d="M141.063,92.871c17.334,-17.334 17.334,-45.312 0,-62.647c-17.334,-17.334 -45.312,-17.334 -62.647,-0c-17.334,17.334 -17.334,45.312 0,62.647c17.334,17.334 45.312,17.334 62.647,-0Z"/>
|
||||
<path style="fill:url(#_Linear1);" d="M121.228,104.359c-14.777,3.965 -31.187,0.136 -42.811,-11.488c-11.624,-11.624 -15.453,-28.034 -11.488,-42.811c14.777,-3.965 31.187,-0.136 42.811,11.488c11.624,11.624 15.453,28.034 11.488,42.811Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(31.323323,-31.323323,31.323323,31.323323,78.416832,92.870811)">
|
||||
<stop offset="0" style="stop-color:#cec9fb;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#fcfcff;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
+29
-17
@@ -11,23 +11,27 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
||||
|
||||
| Side | Owns |
|
||||
|------|------|
|
||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
|
||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
|
||||
|
||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
clients/android/native/ Rust cdylib (workspace member)
|
||||
src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof)
|
||||
src/session.rs session handle lifecycle (connect/close); plane pumps = TODO
|
||||
clients/android/native/ Rust cdylib (workspace member) — links punktfunk-core directly
|
||||
src/lib.rs JNI seam (connect/pair, input, plane getters, abi/core version)
|
||||
src/session.rs session lifecycle + plane pumps
|
||||
src/decode.rs AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
|
||||
src/audio.rs · src/mic.rs Opus + Oboe playback / mic uplink (jitter ring)
|
||||
src/feedback.rs rumble + HID output (lightbar / adaptive triggers)
|
||||
src/stats.rs live video stats
|
||||
|
||||
clients/android/ Gradle project (this dir)
|
||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
||||
app/ :app — Compose application (MainActivity)
|
||||
kit/ :kit — Android library: NativeBridge + the cargo-ndk build
|
||||
build.gradle.kts cargoNdk{Debug,Release} → src/main/jniLibs/<abi>/*.so
|
||||
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
||||
kit/ :kit — NativeBridge · discovery (native mdns-sd, polled) · Gamepad · Keymap ·
|
||||
security (Keystore identity + known-host store) · cargo-ndk build
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -57,15 +61,23 @@ cd clients/android
|
||||
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
|
||||
```
|
||||
|
||||
The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls
|
||||
`NativeBridge.abiVersion()` across JNI — a live ABI version proves the whole native stack is wired.
|
||||
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host from the list, pair,
|
||||
and stream.
|
||||
|
||||
## Status
|
||||
|
||||
- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable
|
||||
manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is
|
||||
aws-lc-free.
|
||||
- **Next (Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio
|
||||
(Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped),
|
||||
mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in
|
||||
`clients/android/native/src/session.rs` with port pointers to `clients/linux`.
|
||||
A working native client (phone + Android TV), at parity with the Linux and Apple apps for the core
|
||||
streaming experience:
|
||||
|
||||
- **Video** — `AMediaCodec` hardware HEVC decode → `SurfaceView`, including **HDR10** (Main10 /
|
||||
BT.2020 PQ), with low-latency decode tuning and a live stats HUD.
|
||||
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
||||
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
||||
game-controller focus navigation for the couch (TV + phone).
|
||||
- **Discovery & trust** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
|
||||
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
|
||||
Keystore-wrapped client identity and a known-host store.
|
||||
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
||||
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
||||
|
||||
`crates/punktfunk-core` uses the `ring` `rcgen` backend so the client `.so` is aws-lc-free.
|
||||
|
||||
@@ -26,7 +26,9 @@ android {
|
||||
targetSdk = 36
|
||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||
versionCode = vCode?.toInt() ?: 1
|
||||
versionName = "0.0.2" // bumped for first Play Store release
|
||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
|
||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
|
||||
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
|
||||
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
|
||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||
|
||||
@@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
@@ -83,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
var hostName by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
|
||||
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
|
||||
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
|
||||
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
|
||||
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
|
||||
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
|
||||
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
|
||||
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
|
||||
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
|
||||
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
|
||||
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
|
||||
// denial used to leave discovery dead forever.
|
||||
val discovery = remember { HostDiscovery(context) }
|
||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
|
||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> nearbyGranted = granted }
|
||||
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||
LaunchedEffect(Unit) {
|
||||
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
}
|
||||
DisposableEffect(nearbyGranted) {
|
||||
DisposableEffect(Unit) {
|
||||
discovery.onChange = { discovered = it }
|
||||
if (nearbyGranted) discovery.start()
|
||||
discovery.start()
|
||||
onDispose {
|
||||
discovery.onChange = null
|
||||
discovery.stop()
|
||||
@@ -126,6 +130,13 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
|
||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||
|
||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||
@@ -140,11 +151,19 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled,
|
||||
)
|
||||
}
|
||||
connecting = false
|
||||
@@ -167,10 +186,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
||||
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
|
||||
fun connect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
dh: DiscoveredHost? = null,
|
||||
manualName: String? = null,
|
||||
) {
|
||||
val known = knownHostStore.get(targetHost, targetPort)
|
||||
val adv = dh?.fingerprint?.lowercase()
|
||||
val name = dh?.name ?: targetHost
|
||||
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
|
||||
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
|
||||
when {
|
||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||
known != null && (adv == null || adv == known.fpHex) ->
|
||||
@@ -251,7 +277,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discovered.isEmpty()) {
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
@@ -272,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discovered.isNotEmpty()) {
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
@@ -293,9 +320,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
|
||||
// working rather than looking idle/empty.
|
||||
if (nearbyGranted && discovered.isEmpty()) {
|
||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||
// rather than looking idle/empty.
|
||||
if (!connecting && discovered.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
@@ -354,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = hostName,
|
||||
onValueChange = { hostName = it },
|
||||
label = { Text("Name (optional)") },
|
||||
placeholder = { Text("e.g. Living Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
@@ -361,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||
@@ -376,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
onClick = {
|
||||
val h = host.trim()
|
||||
val p = port.toIntOrNull() ?: 9777
|
||||
val n = hostName
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
showManualSheet = false
|
||||
connect(h, p)
|
||||
connect(h, p, manualName = n)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -498,10 +536,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
renameTarget?.let { kh ->
|
||||
var newName by remember(kh) { mutableStateOf(kh.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text("Rename host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(kh.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = {
|
||||
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
|
||||
/**
|
||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
* the native core + MulticastLock) does not depend on it.
|
||||
*/
|
||||
fun hasNearbyPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||
*/
|
||||
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
val advFp = dh.fingerprint?.lowercase()
|
||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||
return address == dh.host && port == dh.port
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
||||
@@ -76,6 +77,21 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
* rather than BT.2020 PQ the panel would mis-tone-map (the washed-out/dark failure). Mirrors the
|
||||
* capability gate the Apple/Windows clients apply.
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||
val caps = display.hdrCapabilities ?: return false
|
||||
return caps.supportedHdrTypes.any {
|
||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val native = nativeDisplayMode(context)
|
||||
@@ -126,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte. */
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
)
|
||||
|
||||
@@ -5,9 +5,7 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -16,14 +14,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -174,12 +171,8 @@ private fun ToggleRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
|
||||
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
|
||||
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
|
||||
* on a pick. A primary-colour border marks D-pad focus.
|
||||
*/
|
||||
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T> SettingDropdown(
|
||||
label: String,
|
||||
@@ -188,35 +181,20 @@ private fun <T> SettingDropdown(
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
onClick = { expanded = true },
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -44,6 +43,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
|
||||
// two-finger pan per wheel notch (smaller = faster scroll).
|
||||
private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
@Composable
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
@@ -139,41 +145,108 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
|
||||
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
|
||||
// capture comes in a later increment.)
|
||||
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
|
||||
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
|
||||
// so finger position maps straight onto the remote screen). Gestures: tap = left click;
|
||||
// two-finger tap = right click; two-finger drag = scroll; tap-then-press-and-drag = left-drag
|
||||
// (text selection / moving windows); three-finger tap = toggle the stats HUD.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
fun moveAbs(x: Float, y: Float) {
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) return
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
||||
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
||||
sw,
|
||||
sh,
|
||||
)
|
||||
}
|
||||
awaitEachGesture {
|
||||
val first = awaitFirstDown(requireUnconsumed = false)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startX = down.position.x
|
||||
val startY = down.position.y
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||
lastTapUp = 0L // consume the arming either way
|
||||
moveAbs(startX, startY) // cursor jumps to the finger immediately
|
||||
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
|
||||
var moved = false
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val fingers = ev.changes.count { it.pressed }
|
||||
if (fingers == 0) break
|
||||
if (fingers > maxFingers) maxFingers = fingers
|
||||
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
|
||||
val d = primary.positionChange()
|
||||
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
|
||||
moved = true
|
||||
if (fingers >= 2) {
|
||||
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
|
||||
val sy = (-d.y / 4f).toInt()
|
||||
val sx = (d.x / 4f).toInt()
|
||||
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
} else {
|
||||
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
|
||||
val pressed = ev.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) {
|
||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (!scrolling) {
|
||||
scrolling = true
|
||||
prevCx = cx
|
||||
prevCy = cy
|
||||
}
|
||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||
if (sy != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
prevCy = cy
|
||||
moved = true
|
||||
}
|
||||
if (sx != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (!scrolling) {
|
||||
// One finger → the cursor follows it (skipped once a gesture turned into
|
||||
// a scroll, so dropping back to one finger doesn't jerk the cursor).
|
||||
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||
abs(p.position.y - startY) > TAP_SLOP
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
moveAbs(p.position.x, p.position.y)
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
if (!moved && maxFingers == 1) {
|
||||
|
||||
if (isDrag) {
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
}
|
||||
else -> { // tap → left click, and arm tap-and-drag
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||
} else if (!moved && maxFingers >= 3) {
|
||||
showStats = !showStats // quick in-stream HUD toggle
|
||||
lastTapUp = upTime
|
||||
lastTapX = startX
|
||||
lastTapY = startY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Forget. Tapping the card connects.
|
||||
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||
*/
|
||||
@Composable
|
||||
fun HostCard(
|
||||
@@ -59,6 +59,7 @@ fun HostCard(
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -106,7 +107,7 @@ fun HostCard(
|
||||
StatusPill(status)
|
||||
}
|
||||
|
||||
if (onForget != null) {
|
||||
if (onForget != null || onRename != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -118,6 +119,16 @@ fun HostCard(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onRename != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onForget != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forget") },
|
||||
onClick = {
|
||||
@@ -131,6 +142,7 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
|
||||
@@ -44,6 +44,71 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
|
||||
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
|
||||
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
|
||||
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
|
||||
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
|
||||
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
|
||||
* physical pad we resolve it concretely, matching the other native clients.
|
||||
*/
|
||||
fun prefFor(dev: InputDevice?): Int {
|
||||
if (dev == null) return PREF_XBOX360
|
||||
val vid = dev.vendorId
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
* type from the first connected controller via [prefFor] (so the host gets the right pad even
|
||||
* though Android can't tell it the controller type any other way).
|
||||
*/
|
||||
fun resolvePref(setting: Int): Int =
|
||||
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
|
||||
|
||||
/**
|
||||
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
||||
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
||||
|
||||
@@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { rumbleThread?.join(200) }
|
||||
runCatching { hidoutThread?.join(200) }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
@@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ object NativeBridge {
|
||||
bitrateKbps: Int,
|
||||
compositorPref: Int,
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -66,6 +67,27 @@ object NativeBridge {
|
||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||
external fun nativeClose(handle: Long)
|
||||
|
||||
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
|
||||
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
|
||||
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
|
||||
|
||||
/**
|
||||
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
|
||||
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
|
||||
* daemon + a fold thread).
|
||||
*/
|
||||
external fun nativeDiscoveryStart(): Long
|
||||
|
||||
/**
|
||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* cheap (a lock + string build), safe to call on the main thread.
|
||||
*/
|
||||
external fun nativeDiscoveryPoll(handle: Long): String
|
||||
|
||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||
external fun nativeDiscoveryStop(handle: Long)
|
||||
|
||||
/**
|
||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||
@@ -107,6 +129,13 @@ object NativeBridge {
|
||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
||||
|
||||
/**
|
||||
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
|
||||
* pixel space (it normalizes against that size and maps into the output region). Touch
|
||||
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
|
||||
*/
|
||||
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
|
||||
|
||||
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
||||
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
||||
|
||||
|
||||
+84
-134
@@ -1,17 +1,13 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
private const val TAG = "PunktfunkNsd"
|
||||
|
||||
/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
|
||||
const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
|
||||
const val PUNKTFUNK_PROTO = "punktfunk/1"
|
||||
private const val TAG = "PunktfunkMdns"
|
||||
|
||||
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
||||
data class DiscoveredHost(
|
||||
@@ -23,165 +19,115 @@ data class DiscoveredHost(
|
||||
val pairingRequired: Boolean = false,
|
||||
)
|
||||
|
||||
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */
|
||||
data class TxtFields(
|
||||
val proto: String?,
|
||||
val fp: String?,
|
||||
val pair: String?,
|
||||
val id: String?,
|
||||
) {
|
||||
val pairingRequired: Boolean get() = pair == "required"
|
||||
val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
|
||||
}
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Pure TXT parser. NSD hands TXT as a `Map<String, ByteArray?>` (a null/empty value = present-but-
|
||||
* empty key). Decode UTF-8; missing keys are null, never an error.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseTxt(attrs: Map<String, ByteArray?>): TxtFields {
|
||||
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8)
|
||||
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id"))
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
if (f.size < 6) return null
|
||||
val addr = f[2]
|
||||
val port = f[3].toIntOrNull() ?: return null
|
||||
if (addr.isBlank() || port !in 1..65535) return null
|
||||
return DiscoveredHost(
|
||||
key = f[0].ifBlank { "$addr:$port" },
|
||||
name = f[1].ifBlank { addr },
|
||||
host = addr,
|
||||
port = port,
|
||||
fingerprint = f[4].ifBlank { null },
|
||||
pairingRequired = f[5] == "required",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
|
||||
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 31–33 where its TXT is
|
||||
* often empty), and pushes the live host set to [onChange] (invoked on the main thread).
|
||||
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
|
||||
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
|
||||
* made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
|
||||
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
|
||||
* [stop] tears it down.
|
||||
*
|
||||
* Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a
|
||||
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP
|
||||
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host.
|
||||
* We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
|
||||
* needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
|
||||
* but never finds a LAN host — same as before; that's the network, not the API.)
|
||||
*/
|
||||
class HostDiscovery(context: Context) {
|
||||
private val appCtx = context.applicationContext
|
||||
private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
|
||||
/** Invoked on the main thread whenever the resolved host set changes. */
|
||||
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
||||
|
||||
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
|
||||
private var nativeHandle = 0L
|
||||
private var running = false
|
||||
private var last: List<DiscoveredHost> = emptyList()
|
||||
|
||||
private val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!running) return
|
||||
val hosts = snapshot()
|
||||
if (hosts != last) {
|
||||
last = hosts
|
||||
onChange?.invoke(hosts)
|
||||
}
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
acquireMulticastLock()
|
||||
val listener = makeDiscoveryListener()
|
||||
discoveryListener = listener
|
||||
runCatching {
|
||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "discoverServices failed", it)
|
||||
stop()
|
||||
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||
.getOrDefault(0L)
|
||||
if (h == 0L) {
|
||||
Log.e(TAG, "native mDNS discovery failed to start")
|
||||
releaseMulticastLock()
|
||||
return
|
||||
}
|
||||
nativeHandle = h
|
||||
running = true
|
||||
last = emptyList()
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
||||
discoveryListener = null
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
||||
}
|
||||
infoCallbacks.clear()
|
||||
handler.removeCallbacks(poll)
|
||||
val h = nativeHandle
|
||||
nativeHandle = 0L
|
||||
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||
releaseMulticastLock()
|
||||
resolved.clear()
|
||||
last = emptyList()
|
||||
onChange?.invoke(emptyList())
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(type: String) {
|
||||
Log.d(TAG, "discovery started: $type")
|
||||
}
|
||||
override fun onDiscoveryStopped(type: String) {
|
||||
Log.d(TAG, "discovery stopped: $type")
|
||||
}
|
||||
override fun onStartDiscoveryFailed(type: String, code: Int) {
|
||||
Log.e(TAG, "start discovery failed: $code")
|
||||
runCatching { nsd.stopServiceDiscovery(this) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(type: String, code: Int) {
|
||||
Log.e(TAG, "stop discovery failed: $code")
|
||||
}
|
||||
|
||||
override fun onServiceFound(info: NsdServiceInfo) {
|
||||
Log.d(TAG, "found: ${info.serviceName}")
|
||||
resolve(info)
|
||||
}
|
||||
override fun onServiceLost(info: NsdServiceInfo) {
|
||||
Log.d(TAG, "lost: ${info.serviceName}")
|
||||
// onServiceLost carries no TXT, so drop by the instance-name fallback key only.
|
||||
if (resolved.remove(info.serviceName) != null) publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(found: NsdServiceInfo) {
|
||||
if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
|
||||
}
|
||||
|
||||
private fun resolveViaCallback(found: NsdServiceInfo) {
|
||||
val cb = object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
|
||||
override fun onServiceLost() {}
|
||||
override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
|
||||
Log.e(TAG, "ServiceInfoCallback reg failed: $code")
|
||||
}
|
||||
override fun onServiceInfoCallbackUnregistered() {}
|
||||
}
|
||||
runCatching {
|
||||
nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
|
||||
infoCallbacks.add(cb)
|
||||
}.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
|
||||
}
|
||||
|
||||
private fun resolveViaLegacy(found: NsdServiceInfo) {
|
||||
// A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
|
||||
val listener = object : NsdManager.ResolveListener {
|
||||
override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
|
||||
override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
|
||||
Log.e(TAG, "resolve failed: $code")
|
||||
}
|
||||
}
|
||||
runCatching { nsd.resolveService(found, listener) }
|
||||
.onFailure { Log.e(TAG, "resolveService failed", it) }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
|
||||
private fun ingest(info: NsdServiceInfo) {
|
||||
val txt = parseTxt(info.attributes)
|
||||
// Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
|
||||
if (txt.proto != null && !txt.isPunktfunk) {
|
||||
Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
|
||||
return
|
||||
}
|
||||
val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
|
||||
?.hostAddress ?: return
|
||||
val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
|
||||
resolved[key] = DiscoveredHost(
|
||||
key = key,
|
||||
name = info.serviceName.removeSuffix("."),
|
||||
host = ip,
|
||||
port = info.port,
|
||||
fingerprint = txt.fp,
|
||||
pairingRequired = txt.pairingRequired,
|
||||
)
|
||||
Log.d(TAG, "resolved: ${resolved[key]}")
|
||||
publish()
|
||||
private fun snapshot(): List<DiscoveredHost> {
|
||||
val h = nativeHandle
|
||||
if (h == 0L) return emptyList()
|
||||
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
|
||||
// native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
|
||||
val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
|
||||
.onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
|
||||
.getOrNull() ?: ""
|
||||
if (blob.isEmpty()) return emptyList()
|
||||
return blob.split('\n')
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { parseHostRecord(it) }
|
||||
.associateBy { it.key } // dedup by stable key (id, or addr:port)
|
||||
.values
|
||||
.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
|
||||
multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
|
||||
setReferenceCounted(true)
|
||||
runCatching { acquire() }
|
||||
}
|
||||
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
|
||||
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
||||
multicastLock = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_MS = 1000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
}
|
||||
|
||||
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||
fun rename(address: String, port: Int, newName: String) {
|
||||
val h = get(address, port) ?: return
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
|
||||
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class ParseRecordTest {
|
||||
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
|
||||
|
||||
private fun rec(vararg f: String) = f.joinToString(s.toString())
|
||||
|
||||
@Test
|
||||
fun parsesFullRecord() {
|
||||
val fp = "a".repeat(64)
|
||||
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
|
||||
assertEquals("host-123", h.key)
|
||||
assertEquals("home-worker-2", h.name)
|
||||
assertEquals("192.168.1.70", h.host)
|
||||
assertEquals(9777, h.port)
|
||||
assertEquals(fp, h.fingerprint)
|
||||
assertTrue(h.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalPairingAndEmptyFingerprint() {
|
||||
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
|
||||
assertNull(h.fingerprint)
|
||||
assertEquals(false, h.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyKeyFallsBackToAddrPort() {
|
||||
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
|
||||
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
|
||||
assertEquals("10.0.0.5:9777", h.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyNameFallsBackToAddr() {
|
||||
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
|
||||
assertEquals("10.0.0.5", h.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTooFewFields() {
|
||||
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
|
||||
assertNull(parseHostRecord(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBadPortOrAddress() {
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
|
||||
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package io.unom.punktfunk.kit.discovery
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
|
||||
class ParseTxtTest {
|
||||
private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
|
||||
|
||||
@Test
|
||||
fun parsesFullRecord() {
|
||||
val fp = "a".repeat(64)
|
||||
val t = parseTxt(
|
||||
mapOf(
|
||||
"proto" to b("punktfunk/1"),
|
||||
"fp" to b(fp),
|
||||
"pair" to b("required"),
|
||||
"id" to b("host-123"),
|
||||
),
|
||||
)
|
||||
assertEquals("punktfunk/1", t.proto)
|
||||
assertEquals(fp, t.fp)
|
||||
assertEquals("host-123", t.id)
|
||||
assertTrue(t.isPunktfunk)
|
||||
assertTrue(t.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalPairingAndMissingKeys() {
|
||||
val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
|
||||
assertFalse(t.pairingRequired)
|
||||
assertNull(t.fp)
|
||||
assertNull(t.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMapYieldsAllNull() {
|
||||
val t = parseTxt(emptyMap())
|
||||
assertNull(t.proto)
|
||||
assertNull(t.fp)
|
||||
assertNull(t.pair)
|
||||
assertNull(t.id)
|
||||
assertFalse(t.isPunktfunk)
|
||||
assertFalse(t.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullAndEmptyValuesTreatedAsAbsent() {
|
||||
// NSD delivers present-but-empty TXT keys as null / empty ByteArray.
|
||||
val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
|
||||
assertNull(t.fp)
|
||||
assertNull(t.id)
|
||||
assertTrue(t.isPunktfunk)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonPunktfunkProtoIsNotAccepted() {
|
||||
assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
mdns-sd = "0.20"
|
||||
|
||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
||||
|
||||
@@ -52,6 +52,24 @@ pub fn run(
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
format.set_i32("operating-rate", mode.refresh_hz as i32);
|
||||
|
||||
// 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;
|
||||
@@ -258,3 +276,35 @@ fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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,303 @@
|
||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//!
|
||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||
//! core — the crate is already linked for the whole protocol — gives one tested code path across
|
||||
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
|
||||
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
|
||||
//! permission UX; this module owns the socket + resolve.
|
||||
//!
|
||||
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
|
||||
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
|
||||
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
|
||||
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
|
||||
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
|
||||
//! wrong, and 1 Hz is plenty for a host picker.
|
||||
|
||||
use crate::session::jni_guard;
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::jlong;
|
||||
use jni::JNIEnv;
|
||||
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
|
||||
const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Host {
|
||||
key: String,
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn encode(&self) -> String {
|
||||
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
|
||||
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
|
||||
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
|
||||
// list's integrity.)
|
||||
fn clean(s: &str) -> String {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
|
||||
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
|
||||
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
|
||||
struct Discovery {
|
||||
daemon: ServiceDaemon,
|
||||
hosts: Arc<Mutex<HashMap<String, Host>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn start() -> Option<Discovery> {
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("mDNS daemon failed — discovery disabled: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let rx = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("mDNS browse failed — discovery disabled: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let map = hosts.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-mdns".into())
|
||||
.spawn(move || {
|
||||
// Exits when the daemon is shut down (the browse channel closes → recv errors).
|
||||
while let Ok(event) = rx.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
if let Some(host) = resolve(&info) {
|
||||
map.lock()
|
||||
.unwrap()
|
||||
.insert(info.get_fullname().to_string(), host);
|
||||
}
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
map.lock().unwrap().remove(&fullname);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let thread = match spawned {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
|
||||
// shut it down explicitly — same cleanup as the browse-failure path above.
|
||||
log::error!("mDNS fold thread spawn failed: {e}");
|
||||
let _ = daemon.shutdown();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
|
||||
Some(Discovery {
|
||||
daemon,
|
||||
hosts,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
|
||||
/// across polls; Kotlin re-sorts by display name.
|
||||
fn snapshot(&self) -> String {
|
||||
let mut records: Vec<String> = self
|
||||
.hosts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(Host::encode)
|
||||
.collect();
|
||||
records.sort();
|
||||
records.join("\n")
|
||||
}
|
||||
|
||||
fn stop(mut self) {
|
||||
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
|
||||
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
|
||||
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
|
||||
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
|
||||
/// fails on every tap. Dropping it shows the honest "not found" instead.
|
||||
fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let proto = val("proto");
|
||||
if !proto.is_empty() && proto != PROTO {
|
||||
return None; // some other DNS-SD service sharing the type — ignore
|
||||
}
|
||||
let addr = info
|
||||
.get_addresses_v4()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())?;
|
||||
let id = val("id");
|
||||
let fullname = info.get_fullname();
|
||||
Some(Host {
|
||||
key: if id.is_empty() {
|
||||
fullname.to_string()
|
||||
} else {
|
||||
id
|
||||
},
|
||||
name: fullname.split('.').next().unwrap_or("?").to_string(),
|
||||
addr,
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
|
||||
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jlong {
|
||||
jni_guard(0, || match Discovery::start() {
|
||||
Some(d) => Box::into_raw(Box::new(d)) as jlong,
|
||||
None => 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jni::sys::jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
let out = if handle == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
|
||||
// polls after stop (it nulls the handle first).
|
||||
let d = unsafe { &*(handle as *const Discovery) };
|
||||
d.snapshot()
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||
/// thread. No-op on `0`.
|
||||
///
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
|
||||
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
|
||||
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
|
||||
d.stop();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_all_fields_with_unit_separator() {
|
||||
let h = Host {
|
||||
key: "host-123".into(),
|
||||
name: "home-worker-2".into(),
|
||||
addr: "192.168.1.70".into(),
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
addr: "10.0.0.5".into(),
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
assert_eq!(fields[1], "evilhost");
|
||||
assert_eq!(fields[4], "abcd");
|
||||
assert_eq!(fields[5], "required");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||
|
||||
use crate::session::SessionHandle;
|
||||
use crate::session::{jni_guard, SessionHandle};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
@@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them) before nativeClose frees the handle.
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
@@ -58,6 +61,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
handle: jlong,
|
||||
buf: JByteBuffer,
|
||||
) -> jint {
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
@@ -111,4 +116,5 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
||||
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
||||
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
|
||||
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
|
||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
|
||||
//! languages meet.
|
||||
//!
|
||||
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
||||
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
||||
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
||||
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
|
||||
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
|
||||
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
|
||||
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
|
||||
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
||||
//!
|
||||
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
||||
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
||||
@@ -25,6 +29,9 @@ use jni::JNIEnv;
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
mod decode;
|
||||
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
|
||||
mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
|
||||
@@ -19,11 +19,28 @@ use jni::JNIEnv;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
|
||||
///
|
||||
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
|
||||
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
|
||||
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
|
||||
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
|
||||
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
|
||||
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
|
||||
/// no-op rather than kill the app.
|
||||
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
|
||||
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
|
||||
default
|
||||
})
|
||||
}
|
||||
|
||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||
pub(crate) struct SessionHandle {
|
||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||
@@ -144,6 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
bitrate_kbps: jint,
|
||||
compositor_pref: jint,
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -184,10 +202,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
||||
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||
// Advertise 10-bit + HDR: the host (e.g. Windows) only upgrades to a Main10 / BT.2020 PQ
|
||||
// encode when the client sets these. AMediaCodec decodes Main10 from the SPS and the decode
|
||||
// loop signals the Surface's HDR dataspace from the reported colour (see crate::decode).
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
|
||||
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
|
||||
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
|
||||
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
|
||||
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||
// metadata (see crate::decode).
|
||||
if hdr_enabled != 0 {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
None, // launch: default app
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
@@ -223,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
@@ -359,11 +386,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_video();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||
@@ -378,6 +407,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
@@ -408,6 +438,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||
@@ -443,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||
@@ -484,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
||||
@@ -522,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
||||
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
x: jint,
|
||||
y: jint,
|
||||
surface_width: jint,
|
||||
surface_height: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||
let _ = h.client.send_input(&InputEvent {
|
||||
kind: InputKind::MouseMoveAbs,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x,
|
||||
y,
|
||||
flags: (w << 16) | ht,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
|
||||
+60
-3
@@ -6,9 +6,14 @@ input datagrams, Opus audio, cert pinning — lives in the shared Rust core (sta
|
||||
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
|
||||
(VideoToolbox), present (SwiftUI), input capture.
|
||||
|
||||
## Status — first light achieved (2026-06-10)
|
||||
## Status — working client (macOS, with iOS / tvOS in the shared build)
|
||||
|
||||
Validated live, Mac ↔ Linux box over the LAN: gamescope virtual output → NVENC HEVC →
|
||||
A full streaming client: VideoToolbox HEVC decode, controllers incl. DualSense feedback, host
|
||||
discovery, PIN pairing, and a network speed test. The lower-latency **stage-2 presenter**
|
||||
(`VTDecompressionSession` → `CAMetalLayer`) is built and opt-in (Settings → Presenter); see below.
|
||||
|
||||
First light was achieved 2026-06-10 — validated live, Mac ↔ a Linux host over the LAN: gamescope
|
||||
virtual output → NVENC HEVC →
|
||||
`punktfunk/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer` on glass at 1280×720@60, with mouse/keyboard flowing back as
|
||||
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
|
||||
@@ -169,6 +174,58 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||
passes the dev autoconnect env through).
|
||||
|
||||
## App Store screenshots
|
||||
|
||||
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
|
||||
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
|
||||
|
||||
```sh
|
||||
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
|
||||
tools/screenshots.sh macos # just macOS
|
||||
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
|
||||
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
|
||||
```
|
||||
|
||||
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
|
||||
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
|
||||
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
|
||||
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
|
||||
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
|
||||
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
|
||||
is in `ShotMock`; nothing touches a host.
|
||||
|
||||
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
|
||||
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
|
||||
`appletv` 1920×1080.
|
||||
|
||||
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
|
||||
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
|
||||
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
|
||||
|
||||
Requirements / gotchas:
|
||||
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
|
||||
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
|
||||
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
|
||||
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
|
||||
read `ScrollView` content, so it's for quick checks, not submission.)
|
||||
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
|
||||
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
|
||||
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
|
||||
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
|
||||
frame for a production-quality lead screenshot.
|
||||
|
||||
**CI**: the `apple` workflow's **`screenshots`** job runs on the `macos-arm64` runner on every main
|
||||
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
|
||||
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts; `upload-artifact@v3` —
|
||||
Gitea's backend rejects v4). It captures the two **required iOS sizes — iPhone 6.9" + iPad 13"** —
|
||||
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
|
||||
build/test job so a capture hiccup never reds the build.
|
||||
|
||||
**macOS and tvOS are NOT in CI**, by design: the self-hosted runner is **headless** (no
|
||||
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
|
||||
build-std slice. Generate those on a GUI Mac: `tools/screenshots.sh macos tvos`. (If the runner is
|
||||
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
|
||||
|
||||
## Notes for whoever picks this up next
|
||||
|
||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||||
@@ -304,4 +361,4 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
||||
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
||||
implemented (the Welcome is one-shot today).
|
||||
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
||||
`docs/linux-setup.md`).
|
||||
`design/linux-setup.md`).
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
||||
// It shows the live input of the active controller and lets you fire the host→client feedback
|
||||
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
#if DEBUG && !os(tvOS)
|
||||
import GameController
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@State private var triggerTarget = TriggerTarget.both
|
||||
@State private var playerLED = -1
|
||||
|
||||
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
||||
case left = "L2", right = "R2", both = "Both"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct TriggerDemo: Identifiable {
|
||||
let label: String
|
||||
let effect: DualSenseTriggerEffect
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
private static let triggerDemos: [TriggerDemo] = [
|
||||
.init(label: "Off", effect: .off),
|
||||
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
||||
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
||||
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
||||
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
||||
]
|
||||
|
||||
// (display name, hardware colour, swatch colour)
|
||||
private static let lightSwatches: [(String, GCColor, Color)] = [
|
||||
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
||||
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
||||
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
||||
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
systemImage: "gamecontroller",
|
||||
description: Text("Connect a controller and pick it under "
|
||||
+ "Settings → Controllers → Use controller."))
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
tester.target(gamepads.active?.controller)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerBar(_ label: String, value: Float) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.15))
|
||||
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
||||
}
|
||||
}
|
||||
.frame(height: 10)
|
||||
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
Text(items[i].0)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, minHeight: 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
||||
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
fingerDot(tp.secondary, color: .orange)
|
||||
}
|
||||
.frame(width: 150, height: 74)
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
Text("25%").tag(0.25)
|
||||
Text("50%").tag(0.5)
|
||||
Text("75%").tag(0.75)
|
||||
Text("100%").tag(1.0)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
.onChange(of: intensity) { _, _ in applyRumble() }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRumble() {
|
||||
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
||||
}
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
||||
) {
|
||||
ForEach(Self.triggerDemos) { demo in
|
||||
Button(demo.label) { applyTrigger(demo.effect) }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
||||
switch triggerTarget {
|
||||
case .left: tester.applyTrigger(e, right: false)
|
||||
case .right: tester.applyTrigger(e, right: true)
|
||||
case .both:
|
||||
tester.applyTrigger(e, right: false)
|
||||
tester.applyTrigger(e, right: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
||||
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
||||
Circle().fill(Self.lightSwatches[i].2)
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
||||
}
|
||||
Picker("Player LED", selection: $playerLED) {
|
||||
Text("Off").tag(-1)
|
||||
Text("1").tag(0)
|
||||
Text("2").tag(1)
|
||||
Text("3").tag(2)
|
||||
Text("4").tag(3)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: playerLED) { _, v in
|
||||
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func card<Content: View>(
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
||||
}
|
||||
|
||||
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
||||
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
||||
|
||||
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
||||
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
||||
private static func touchpad(
|
||||
_ g: GCExtendedGamepad
|
||||
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)? {
|
||||
if let ds = g as? GCDualSenseGamepad {
|
||||
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||
}
|
||||
if let ds4 = g as? GCDualShockGamepad {
|
||||
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
||||
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
||||
if m.hasGravityAndUserAcceleration {
|
||||
return (m.gravity.x + m.userAcceleration.x,
|
||||
m.gravity.y + m.userAcceleration.y,
|
||||
m.gravity.z + m.userAcceleration.z)
|
||||
}
|
||||
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -14,8 +14,19 @@ struct PunktfunkClientApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// App Store screenshot harness — device catalog.
|
||||
//
|
||||
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
|
||||
// rasterize NavigationStack / Form / Liquid-Glass — they come out black). The app is launched in
|
||||
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
|
||||
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
|
||||
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
|
||||
// tools/screenshots.sh drives it. DEBUG-only — none of this ships in Release.
|
||||
//
|
||||
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
|
||||
// at runtime (to size the capture window) — the simulator IS the device, so iOS/tvOS pixels are
|
||||
// whatever the booted device is.
|
||||
|
||||
#if DEBUG
|
||||
import CoreGraphics
|
||||
|
||||
enum ShotOrientation { case natural, portrait, landscape }
|
||||
|
||||
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
|
||||
struct ShotDevice {
|
||||
let id: String
|
||||
let naturalWidth: Int
|
||||
let naturalHeight: Int
|
||||
let scale: CGFloat
|
||||
|
||||
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
|
||||
let long = max(naturalWidth, naturalHeight)
|
||||
let short = min(naturalWidth, naturalHeight)
|
||||
switch o {
|
||||
case .natural: return (naturalWidth, naturalHeight)
|
||||
case .portrait: return (short, long)
|
||||
case .landscape: return (long, short)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical point size (pixels / scale) — used to size the mac capture window so that a
|
||||
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
|
||||
func points(_ o: ShotOrientation) -> CGSize {
|
||||
let (w, h) = pixels(o)
|
||||
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
|
||||
}
|
||||
|
||||
/// Mac: 2880×1800 (16:10 Retina) — an accepted size; on a 1× display the window capture is
|
||||
/// 1440×900, also accepted.
|
||||
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
|
||||
|
||||
/// iPhone 6.9" (required) — for reference / the driver script's simulator choice.
|
||||
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
|
||||
scale: 3)
|
||||
/// iPad 13" (required).
|
||||
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
|
||||
scale: 2)
|
||||
/// Apple TV (always landscape).
|
||||
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
|
||||
scale: 1)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
// App Store screenshot harness — the in-app "shot mode" root.
|
||||
//
|
||||
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
|
||||
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
|
||||
// fully-rendered UI (materials, NavigationStack, glass — all the things ImageRenderer can't
|
||||
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
|
||||
//
|
||||
// Capture per platform:
|
||||
// • iOS / tvOS simulator → `xcrun simctl io booted screenshot` (native pixels = exact size).
|
||||
// • macOS → `screencapture -l<windowID>` of the borderless capture window (the configurator
|
||||
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
|
||||
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> → cacheDisplay; renders the real hierarchy but, like all
|
||||
// non-window-server capture, omits material blur).
|
||||
//
|
||||
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import ImageIO
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
return ShotScenes.all.first { $0.name == name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
var body: some View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
.background(IOSOrientationConfigurator(orientation: scene.orientation))
|
||||
#endif
|
||||
.task {
|
||||
// Let layout + materials settle, then signal the driver.
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
announceReady()
|
||||
}
|
||||
}
|
||||
|
||||
private func announceReady() {
|
||||
print("PF_SHOT_READY scene=\(scene.name)")
|
||||
fflush(stdout)
|
||||
#if os(macOS)
|
||||
MacSelfCapture.captureIfRequested(scene: scene)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
|
||||
/// surface, and prints the CGWindowID for `screencapture -l`.
|
||||
private struct MacShotWindowConfigurator: NSViewRepresentable {
|
||||
let scene: ShotScene
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window, !context.coordinator.configured else { return }
|
||||
context.coordinator.configured = true
|
||||
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
|
||||
// SwiftUI colorScheme — without this the dark scenes render on a light window (white
|
||||
// background, washed-out materials).
|
||||
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
|
||||
let size = ShotDevice.mac.points(scene.orientation)
|
||||
window.styleMask = [.titled, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovable = false
|
||||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||
window.standardWindowButton(button)?.isHidden = true
|
||||
}
|
||||
window.setContentSize(size)
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
|
||||
+ "size=\(Int(size.width))x\(Int(size.height))pt")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
final class Coordinator { var configured = false }
|
||||
}
|
||||
|
||||
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
|
||||
/// hierarchy (NavigationStack/Form/cards — unlike ImageRenderer) but omits material blur, which
|
||||
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
|
||||
enum MacSelfCapture {
|
||||
static func captureIfRequested(scene: ShotScene) {
|
||||
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
|
||||
!dir.isEmpty,
|
||||
let window = NSApp.windows.first(where: { $0.isVisible }),
|
||||
let content = window.contentView else { return }
|
||||
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
|
||||
content.cacheDisplay(in: content.bounds, to: rep)
|
||||
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
|
||||
if let dest = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
|
||||
CGImageDestinationAddImage(dest, cg, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
|
||||
}
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,284 @@
|
||||
// App Store screenshot scenes — the actual screens we render, each wired with mock data so it
|
||||
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
|
||||
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
|
||||
// live stream is faked (StreamView needs a real punktfunk/1 connection — see ShotStreamHero).
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One screen to capture: a name (→ file suffix), the canvas orientation, a color scheme, and a
|
||||
/// factory that builds the populated view on the main actor.
|
||||
struct ShotScene {
|
||||
let name: String
|
||||
let orientation: ShotOrientation
|
||||
let colorScheme: ColorScheme
|
||||
let make: @MainActor () -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ShotScenes {
|
||||
static let all: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Mock data
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
static func hostStore() -> HostStore {
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private struct ShotSettings: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The mac Settings window is a fixed-size tabbed panel — float it over a dimmed host
|
||||
// grid so the shot reads as the preferences window over the running app.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
SettingsView()
|
||||
.fixedSize()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trust (TOFU card over the blurred live stream)
|
||||
|
||||
private struct ShotTrust: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotDesktopFrame()
|
||||
.blur(radius: 32)
|
||||
.overlay(Color.black.opacity(0.45))
|
||||
TrustCardView(
|
||||
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
|
||||
onCancel: {}, onTrust: {}, onPairInstead: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream hero
|
||||
|
||||
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
|
||||
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
|
||||
/// synthetic — set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
|
||||
private struct ShotStreamHero: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ShotDesktopFrame()
|
||||
ShotHUD()
|
||||
}
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
|
||||
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
|
||||
private struct ShotHUD: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
|
||||
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic "streamed frame" — a synthwave scene that reads as game content without shipping
|
||||
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
|
||||
private struct ShotDesktopFrame: View {
|
||||
var body: some View {
|
||||
if let image = Self.overrideImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
synthetic
|
||||
}
|
||||
}
|
||||
|
||||
private var synthetic: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.05, green: 0.02, blue: 0.16),
|
||||
Color(red: 0.35, green: 0.05, blue: 0.42),
|
||||
Color(red: 0.95, green: 0.30, blue: 0.35),
|
||||
Color(red: 0.99, green: 0.62, blue: 0.32),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
Canvas { ctx, size in
|
||||
let horizon = size.height * 0.52
|
||||
// Sun.
|
||||
let sunR = size.height * 0.20
|
||||
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
|
||||
width: sunR * 2, height: sunR * 2)
|
||||
ctx.fill(Path(ellipseIn: sun),
|
||||
with: .linearGradient(
|
||||
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
|
||||
Color(red: 1, green: 0.35, blue: 0.45)]),
|
||||
startPoint: CGPoint(x: sun.midX, y: sun.minY),
|
||||
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
|
||||
// Sun scanlines — clip a copy so the base context stays unclipped (GraphicsContext
|
||||
// is a value type; there is no resetClip).
|
||||
var sunCtx = ctx
|
||||
sunCtx.clip(to: Path(ellipseIn: sun))
|
||||
for i in 0..<7 {
|
||||
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
|
||||
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
|
||||
height: sun.height * (0.012 + Double(i) * 0.006))
|
||||
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
|
||||
}
|
||||
// Perspective grid below the horizon.
|
||||
ctx.opacity = 0.55
|
||||
let cx = size.width / 2
|
||||
for col in -10...10 {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: cx, y: horizon))
|
||||
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
|
||||
y: size.height))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
}
|
||||
var row = horizon
|
||||
var step = size.height * 0.012
|
||||
while row < size.height {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: 0, y: row))
|
||||
p.addLine(to: CGPoint(x: size.width, y: row))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
step *= 1.32
|
||||
row += step
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
.padding(18)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` → use a real captured frame as the hero background.
|
||||
static var overrideImage: Image? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
|
||||
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
|
||||
#if os(macOS)
|
||||
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||
return Image(nsImage: ns)
|
||||
#else
|
||||
guard let ui = UIImage(contentsOfFile: path) else { return nil }
|
||||
return Image(uiImage: ui)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,12 @@ import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||
final class FrameMeter: @unchecked Sendable {
|
||||
@@ -93,6 +99,7 @@ final class SessionModel: ObservableObject {
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
hdrEnabled: Bool = true,
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false) {
|
||||
@@ -101,17 +108,36 @@ final class SessionModel: ObservableObject {
|
||||
activeHost = host
|
||||
errorMessage = nil
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
|
||||
// metadata we apply (Step 2) when it IS HDR.
|
||||
let displayHDR: Bool = {
|
||||
#if os(macOS)
|
||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
#else
|
||||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||
#endif
|
||||
}()
|
||||
let hdrCapable = hdrEnabled && displayHDR
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
||||
// --require-pairing; Keychain/generation failure must not block connecting).
|
||||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||||
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||||
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||
let videoCaps: UInt8 = hdrCapable
|
||||
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||
: 0
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) }
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||
launchID: launchID) }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
// The user may have abandoned this attempt (window closed, another host
|
||||
|
||||
@@ -28,6 +28,9 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -411,6 +414,11 @@ struct SettingsView: View {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||
#endif
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
@@ -511,15 +519,18 @@ struct SettingsView: View {
|
||||
private static let padTypes: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("Xbox 360", 1),
|
||||
("Xbox One", 3),
|
||||
("DualSense", 2),
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
private static let controllersFooter =
|
||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||||
+ "and motion), and changes apply from the next session. Two identical controllers "
|
||||
+ "may swap a manual selection after reconnecting."
|
||||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||
+ "after reconnecting."
|
||||
|
||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||||
@@ -537,7 +548,7 @@ struct SettingsView: View {
|
||||
|
||||
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.name)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Raw-HID DualSense rumble for macOS.
|
||||
//
|
||||
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
|
||||
// macOS — a documented platform gap: adaptive triggers, lightbar and player LEDs all work
|
||||
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
|
||||
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
|
||||
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
|
||||
//
|
||||
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
|
||||
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
|
||||
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
|
||||
// (non-seized open). Output-only, so no run-loop scheduling is needed.
|
||||
//
|
||||
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
|
||||
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import IOKit
|
||||
import IOKit.hid
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
private var bluetooth = false
|
||||
private var closed = false
|
||||
|
||||
private static let vendorSony = 0x054C
|
||||
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
|
||||
// layout and is intentionally not handled here.
|
||||
private static let productIDs = [0x0CE6, 0x0DF2]
|
||||
|
||||
/// "USB" or "Bluetooth" — for logs / the debug panel. Valid after a successful `open()`.
|
||||
var transport: String { bluetooth ? "Bluetooth" : "USB" }
|
||||
|
||||
init() {
|
||||
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
|
||||
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
let rc = report.withUnsafeBufferPointer { buf in
|
||||
IOHIDDeviceSetReport(
|
||||
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
guard !closed else { return }
|
||||
closed = true
|
||||
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
|
||||
device = nil
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
// MARK: - Report builders
|
||||
|
||||
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) — offsets relative
|
||||
// to the payload start:
|
||||
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
|
||||
// 1 flag1 38 flag2 (enhanced enable)
|
||||
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
|
||||
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
|
||||
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
|
||||
// GameController) untouched.
|
||||
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
|
||||
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
|
||||
data[base + 2] = high // motor_right
|
||||
data[base + 3] = low // motor_left
|
||||
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware ≥ 0x0224)
|
||||
}
|
||||
|
||||
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
|
||||
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
|
||||
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 48)
|
||||
d[0] = 0x02 // report id
|
||||
fillEffects(&d, at: 1, low: low, high: high)
|
||||
return d
|
||||
}
|
||||
|
||||
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 78)
|
||||
d[0] = 0x31 // report id
|
||||
d[1] = 0x00 // seq/tag (static, as SDL)
|
||||
d[2] = 0x10 // magic
|
||||
fillEffects(&d, at: 3, low: low, high: high)
|
||||
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
|
||||
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
|
||||
d[74] = UInt8(crc & 0xFF)
|
||||
d[75] = UInt8((crc >> 8) & 0xFF)
|
||||
d[76] = UInt8((crc >> 16) & 0xFF)
|
||||
d[77] = UInt8((crc >> 24) & 0xFF)
|
||||
return d
|
||||
}
|
||||
|
||||
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
|
||||
/// followed by `bytes` — the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
|
||||
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
|
||||
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
|
||||
where S.Element == UInt8 {
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
func step(_ b: UInt8) {
|
||||
crc ^= UInt32(b)
|
||||
for _ in 0..<8 {
|
||||
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
|
||||
}
|
||||
}
|
||||
step(seed)
|
||||
for b in bytes { step(b) }
|
||||
return ~crc
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,12 +6,14 @@
|
||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||
//
|
||||
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||
// unless the session's virtual pad is a DualSense.
|
||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
//
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||
@@ -154,8 +156,9 @@ public final class GamepadCapture {
|
||||
releaseAll()
|
||||
if let ext = bound?.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
@@ -186,11 +189,11 @@ public final class GamepadCapture {
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||
sync(ext)
|
||||
|
||||
if let ds = ext as? GCDualSenseGamepad {
|
||||
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in
|
||||
if let tp = Self.touchpad(ext) {
|
||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||
}
|
||||
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||
}
|
||||
}
|
||||
@@ -257,12 +260,29 @@ public final class GamepadCapture {
|
||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||
if g.buttonY.isPressed { b |= GamepadWire.y }
|
||||
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true {
|
||||
if Self.touchpad(g)?.button.isPressed == true {
|
||||
b |= GamepadWire.touchpadClick
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
/// The touchpad surface of a PlayStation pad — present on both `GCDualSenseGamepad` and
|
||||
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
|
||||
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
|
||||
/// controller (Xbox, MFi).
|
||||
private static func touchpad(
|
||||
_ g: GCExtendedGamepad
|
||||
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)? {
|
||||
if let ds = g as? GCDualSenseGamepad {
|
||||
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||
}
|
||||
if let ds4 = g as? GCDualShockGamepad {
|
||||
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||
//
|
||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||
// only on DualSense sessions — the drain always polls both planes with short timeouts and
|
||||
// never spins, so an Xbox session just renders rumble. GameController profile mutation
|
||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||
// just renders rumble. GameController profile mutation
|
||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||
@@ -49,10 +50,12 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
||||
private final class RumbleRenderer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
|
||||
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||
private struct Motor {
|
||||
let engine: CHHapticEngine
|
||||
let player: CHHapticAdvancedPatternPlayer
|
||||
var playing = false
|
||||
var player: CHHapticAdvancedPatternPlayer?
|
||||
}
|
||||
|
||||
private var controller: GCController?
|
||||
@@ -66,11 +69,30 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
|
||||
func retarget(_ c: GCController?) {
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||
private static let sharpness: Float = 0.5
|
||||
|
||||
#if os(macOS)
|
||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||
/// every other controller, which keeps the CoreHaptics path.
|
||||
private var dualSenseHID: DualSenseHID?
|
||||
#endif
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
/// rumble backend now in use — for the debug controller-test panel.
|
||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||
queue.async {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,22 +104,36 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
if self.high != nil {
|
||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
self.drive(&self.high, Float(highAmp) / 65535)
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
// Combined engine: whichever motor is stronger wins.
|
||||
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to.
|
||||
if !ok { self.teardown() }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.sync { self.teardown() }
|
||||
queue.sync {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
}
|
||||
}
|
||||
|
||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||
@@ -143,44 +179,51 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
// Start the engine now; the player that actually moves the motor is built per level
|
||||
// change in `drive` (a fresh event baked at the target intensity).
|
||||
try engine.start()
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
|
||||
return Motor(engine: engine, player: player)
|
||||
return Motor(engine: engine, player: nil)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
||||
guard var m = motor else { return }
|
||||
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||
guard var m = motor else { return true }
|
||||
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.player = nil
|
||||
guard amplitude > 0 else { motor = m; return true }
|
||||
do {
|
||||
if amplitude > 0 {
|
||||
if !m.playing {
|
||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
||||
m.playing = true
|
||||
}
|
||||
try m.player.sendParameters(
|
||||
[CHHapticDynamicParameter(
|
||||
parameterID: .hapticIntensityControl,
|
||||
value: amplitude, relativeTime: 0)],
|
||||
atTime: CHHapticTimeImmediate)
|
||||
} else if m.playing {
|
||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
m.playing = false
|
||||
}
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try m.engine.makeAdvancedPlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
m.player = player
|
||||
motor = m
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
||||
// session (that was the old "spotty" behaviour).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
teardown()
|
||||
motor = m
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,12 +233,56 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
m.engine.resetHandler = {}
|
||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.engine.stop()
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
}
|
||||
|
||||
// MARK: - DualSense raw-HID rumble (macOS)
|
||||
//
|
||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||
|
||||
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let hid = dualSenseHID else { return false }
|
||||
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func closeHID() {
|
||||
#if os(macOS)
|
||||
dualSenseHID?.close()
|
||||
dualSenseHID = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func backendNote(for c: GCController?) -> String {
|
||||
#if os(macOS)
|
||||
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
|
||||
#endif
|
||||
return c == nil ? "—" : "CoreHaptics"
|
||||
}
|
||||
}
|
||||
|
||||
public final class GamepadFeedback {
|
||||
@@ -248,9 +335,12 @@ public final class GamepadFeedback {
|
||||
public func start() {
|
||||
guard !drainStarted else { return }
|
||||
drainStarted = true
|
||||
// No hidout traffic can exist on a non-DualSense session — poll that plane
|
||||
// nonblocking there and let rumble own the wait.
|
||||
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
|
||||
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||
let hasHidout = connection.resolvedGamepad == .dualSense
|
||||
|| connection.resolvedGamepad == .dualShock4
|
||||
let hidTimeout: UInt32 = hasHidout ? 10 : 0
|
||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||
while !flag.isStopped {
|
||||
do {
|
||||
@@ -365,3 +455,74 @@ public final class GamepadFeedback {
|
||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
||||
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
||||
/// live session uses — just aimed at the physically-connected controller instead of the
|
||||
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
||||
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
||||
/// a passing test exercises the exact code a session runs.
|
||||
@MainActor
|
||||
public final class ControllerTester: ObservableObject {
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||
@Published public private(set) var rumbleBackend = "—"
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||
/// active-controller change.
|
||||
public func target(_ c: GCController?) {
|
||||
guard c !== controller else { return }
|
||||
controller = c
|
||||
renderer.retarget(c) { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
||||
public func rumble(low: Float, high: Float) {
|
||||
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
||||
renderer.apply(low: u16(low), high: u16(high))
|
||||
}
|
||||
|
||||
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
||||
|
||||
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
||||
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
||||
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
||||
}
|
||||
|
||||
public func resetTriggers() {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
}
|
||||
|
||||
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
||||
public func setLight(_ color: GCColor?) {
|
||||
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
||||
}
|
||||
|
||||
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
||||
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
||||
controller?.playerIndex = index
|
||||
}
|
||||
|
||||
/// Silence every channel and release the controller — call on the panel's disappear.
|
||||
public func stop() {
|
||||
resetTriggers()
|
||||
setPlayerIndex(.indexUnset)
|
||||
setLight(nil)
|
||||
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
||||
controller = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
|
||||
public let productCategory: String
|
||||
/// The full extended profile exists — only these are forwardable.
|
||||
public let isExtended: Bool
|
||||
public let isDualSense: Bool
|
||||
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense →
|
||||
/// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything
|
||||
/// else → `.xbox360`. (`.auto` is never stored here.)
|
||||
public let kind: PunktfunkConnection.GamepadType
|
||||
public let hasLight: Bool
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||
public var hasTouchpadAndMotion: Bool {
|
||||
kind == .dualSense || kind == .dualShock4
|
||||
}
|
||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||
public let batteryLevel: Float?
|
||||
public let isCharging: Bool
|
||||
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||
/// DualSense, anything else → Xbox 360); no controller at all defers to the host.
|
||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||
/// 360); no controller at all defers to the host.
|
||||
public func resolveType(
|
||||
setting: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
|
||||
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
||||
rebuild()
|
||||
guard let active else { return .auto }
|
||||
return active.isDualSense ? .dualSense : .xbox360
|
||||
return active.kind
|
||||
}
|
||||
|
||||
private func noteConnected(_ c: GCController) {
|
||||
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let ds = extended as? GCDualSenseGamepad
|
||||
let kind = padKind(extended)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
productCategory: c.productCategory,
|
||||
isExtended: extended != nil,
|
||||
isDualSense: ds != nil,
|
||||
kind: kind,
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
|
||||
hasAdaptiveTriggers: ds != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
private static func padKind(
|
||||
_ extended: GCExtendedGamepad?
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||
if extended is GCDualSenseGamepad { return .dualSense }
|
||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||
if extended is GCXboxGamepad { return .xboxOne }
|
||||
return .xbox360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +170,18 @@ public final class PunktfunkConnection {
|
||||
|
||||
/// Which virtual gamepad the host creates for this session's pads (the
|
||||
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
||||
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see
|
||||
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the
|
||||
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`.
|
||||
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) —
|
||||
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
|
||||
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
|
||||
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
|
||||
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
|
||||
/// choice is `resolvedGamepad`.
|
||||
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
||||
case auto = 0
|
||||
case xbox360 = 1
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -184,7 +189,9 @@ public final class PunktfunkConnection {
|
||||
switch name.lowercased() {
|
||||
case "auto", "default": self = .auto
|
||||
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
||||
case "dualsense", "ds", "ps5": self = .dualSense
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -214,6 +221,20 @@ public final class PunktfunkConnection {
|
||||
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
|
||||
public private(set) var resolvedBitrateKbps: UInt32 = 0
|
||||
|
||||
/// The colour signalling the host actually encodes with (CICP code points): `colorPrimaries`
|
||||
/// (1=BT.709, 9=BT.2020), `colorTransfer` (1=BT.709, 16=PQ, 18=HLG), `colorMatrix`
|
||||
/// (1=BT.709, 9=BT.2020-NCL), `colorFullRange`. BT.709 limited SDR for an older host. Configure
|
||||
/// the decoder/presenter from these; mastering metadata arrives via `nextHdrMeta`.
|
||||
public private(set) var colorPrimaries: UInt8 = 1
|
||||
public private(set) var colorTransfer: UInt8 = 1
|
||||
public private(set) var colorMatrix: UInt8 = 1
|
||||
public private(set) var colorFullRange: Bool = false
|
||||
/// Encoded bit depth (8 or 10).
|
||||
public private(set) var bitDepth: UInt8 = 8
|
||||
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||
/// drain `nextHdrMeta`.
|
||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||
|
||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||
///
|
||||
@@ -242,11 +263,14 @@ public final class PunktfunkConnection {
|
||||
compositor: Compositor = .auto,
|
||||
gamepad: GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
videoCaps: UInt8 = 0,
|
||||
launchID: String? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
handle = host.withCString { cs in
|
||||
@@ -255,16 +279,16 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex4(
|
||||
punktfunk_connect_ex5(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex4(
|
||||
return punktfunk_connect_ex5(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, launch,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -289,6 +313,13 @@ public final class PunktfunkConnection {
|
||||
var br: UInt32 = 0
|
||||
_ = punktfunk_connection_bitrate(handle, &br)
|
||||
resolvedBitrateKbps = br
|
||||
var prim: UInt8 = 1, trc: UInt8 = 1, mtx: UInt8 = 1, fullRange: UInt8 = 0, depth: UInt8 = 8
|
||||
_ = punktfunk_connection_color_info(handle, &prim, &trc, &mtx, &fullRange, &depth)
|
||||
colorPrimaries = prim
|
||||
colorTransfer = trc
|
||||
colorMatrix = mtx
|
||||
colorFullRange = fullRange != 0
|
||||
bitDepth = depth
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -473,10 +504,11 @@ public final class PunktfunkConnection {
|
||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||
}
|
||||
|
||||
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers);
|
||||
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single)
|
||||
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless
|
||||
/// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin.
|
||||
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||
/// short timeout, never spin.
|
||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
@@ -508,6 +540,83 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||
public struct HdrMeta: Sendable, Equatable {
|
||||
public let primariesX: [UInt16] // [green, blue, red]
|
||||
public let primariesY: [UInt16]
|
||||
public let whitePointX: UInt16
|
||||
public let whitePointY: UInt16
|
||||
public let maxMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let minMasteringLuminance: UInt32 // 0.0001 cd/m²
|
||||
public let maxCLL: UInt16
|
||||
public let maxFALL: UInt16
|
||||
|
||||
/// The 24-byte `mastering_display_colour_volume` payload (big-endian, ST.2086 G,B,R) — pass
|
||||
/// directly to `kCVImageBufferMasteringDisplayColorVolumeKey` or `CAEDRMetadata`'s displayInfo.
|
||||
public func masteringDisplayColorVolume() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
func be32(_ v: UInt32) {
|
||||
d.append(UInt8((v >> 24) & 0xFF)); d.append(UInt8((v >> 16) & 0xFF))
|
||||
d.append(UInt8((v >> 8) & 0xFF)); d.append(UInt8(v & 0xFF))
|
||||
}
|
||||
for i in 0..<3 { be16(primariesX[i]); be16(primariesY[i]) } // G, B, R
|
||||
be16(whitePointX); be16(whitePointY)
|
||||
be32(maxMasteringLuminance); be32(minMasteringLuminance)
|
||||
return d
|
||||
}
|
||||
|
||||
/// The 4-byte `content_light_level_info` payload (big-endian: MaxCLL, MaxFALL) — for
|
||||
/// `kCVImageBufferContentLightLevelInfoKey` or `CAEDRMetadata`'s contentInfo.
|
||||
public func contentLightLevelInfo() -> Data {
|
||||
var d = Data()
|
||||
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
|
||||
be16(maxCLL); be16(maxFALL)
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update; nil on timeout, throws `.closed` once the session
|
||||
/// ended. Drain from the feedback thread alongside `nextRumble`/`nextHidOutput`. Nothing arrives
|
||||
/// unless `isHDR` — poll with a short timeout, never spin.
|
||||
public func nextHdrMeta(timeoutMs: UInt32 = 0) throws -> HdrMeta? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var out = PunktfunkHdrMeta()
|
||||
let rc = punktfunk_connection_next_hdr_meta(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
// The fixed C `uint16_t[3]` arrays import as tuples — copy them out.
|
||||
let px = withUnsafeBytes(of: out.display_primaries_x) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
let py = withUnsafeBytes(of: out.display_primaries_y) {
|
||||
Array($0.bindMemory(to: UInt16.self))
|
||||
}
|
||||
return HdrMeta(
|
||||
primariesX: px, primariesY: py,
|
||||
whitePointX: out.white_point_x, whitePointY: out.white_point_y,
|
||||
maxMasteringLuminance: out.max_display_mastering_luminance,
|
||||
minMasteringLuminance: out.min_display_mastering_luminance,
|
||||
maxCLL: out.max_cll, maxFALL: out.max_fall)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
|
||||
@@ -128,6 +128,11 @@ public final class Stage2Pipeline {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
}
|
||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
decoder.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
|
||||
@@ -49,6 +49,12 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
/// pump can re-gate on the next IDR.
|
||||
private let onDecodeError: @Sendable (OSStatus) -> Void
|
||||
|
||||
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
|
||||
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
|
||||
/// own lock — written by the pump thread, read on the VT decode callback.
|
||||
private let metaLock = NSLock()
|
||||
private var hdrMeta: PunktfunkConnection.HdrMeta?
|
||||
|
||||
public init(
|
||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
||||
@@ -59,6 +65,14 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
|
||||
deinit { teardown() }
|
||||
|
||||
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
|
||||
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
|
||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||
metaLock.lock()
|
||||
hdrMeta = meta
|
||||
metaLock.unlock()
|
||||
}
|
||||
|
||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
||||
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
||||
@@ -185,6 +199,22 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
let isHDR =
|
||||
CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
|
||||
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
|
||||
// alone. The SEI byte payloads map 1:1 to these CVImageBuffer attachment keys.
|
||||
if isHDR {
|
||||
metaLock.lock()
|
||||
let meta = hdrMeta
|
||||
metaLock.unlock()
|
||||
if let meta {
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferMasteringDisplayColorVolumeKey,
|
||||
meta.masteringDisplayColorVolume() as CFData, .shouldPropagate)
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferContentLightLevelInfoKey,
|
||||
meta.contentLightLevelInfo() as CFData, .shouldPropagate)
|
||||
}
|
||||
}
|
||||
onDecoded(
|
||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Locks the DualSense raw-HID rumble report layout to the SDL / Linux hid-playstation spec.
|
||||
// The motors can only be confirmed on a physical pad, but these guard against a silent byte
|
||||
// error in the offsets, enable flags, lengths, and the Bluetooth CRC32 — the parts most likely
|
||||
// to regress unnoticed. macOS-only (DualSenseHID isn't compiled elsewhere).
|
||||
|
||||
#if os(macOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DualSenseHIDTests: XCTestCase {
|
||||
func testUSBReportLayout() {
|
||||
let r = DualSenseHID.usbReport(low: 0xAA, high: 0xBB)
|
||||
XCTAssertEqual(r.count, 48)
|
||||
XCTAssertEqual(r[0], 0x02) // report id
|
||||
XCTAssertEqual(r[1], 0x03) // flag0: COMPATIBLE_VIBRATION | HAPTICS_SELECT
|
||||
XCTAssertEqual(r[2], 0x00) // flag1 (untouched — leaves lightbar/LEDs alone)
|
||||
XCTAssertEqual(r[3], 0xBB) // motor_right = high
|
||||
XCTAssertEqual(r[4], 0xAA) // motor_left = low
|
||||
XCTAssertEqual(r[39], 0x04) // flag2: COMPATIBLE_VIBRATION2 (payload offset 38 + report id)
|
||||
}
|
||||
|
||||
func testBluetoothReportLayoutAndCRC() {
|
||||
let r = DualSenseHID.bluetoothReport(low: 0xAA, high: 0xBB)
|
||||
XCTAssertEqual(r.count, 78)
|
||||
XCTAssertEqual(r[0], 0x31) // report id
|
||||
XCTAssertEqual(r[1], 0x00) // seq/tag
|
||||
XCTAssertEqual(r[2], 0x10) // magic
|
||||
XCTAssertEqual(r[3], 0x03) // flag0
|
||||
XCTAssertEqual(r[5], 0xBB) // motor_right = high (payload offset 2 + 3-byte BT header)
|
||||
XCTAssertEqual(r[6], 0xAA) // motor_left = low
|
||||
XCTAssertEqual(r[41], 0x04) // flag2 (payload offset 38 + 3)
|
||||
|
||||
// Trailing CRC32 = standard CRC32 over (0xA2 seed + report[0..<74]), little-endian.
|
||||
let expected = DualSenseHID.crc32(seed: 0xA2, r[0..<74])
|
||||
let stored = UInt32(r[74]) | (UInt32(r[75]) << 8) | (UInt32(r[76]) << 16) | (UInt32(r[77]) << 24)
|
||||
XCTAssertEqual(stored, expected)
|
||||
}
|
||||
|
||||
func testCRC32MatchesStandardCheckVector() {
|
||||
// The canonical CRC32 check value: CRC32("123456789") == 0xCBF43926. Our helper folds a
|
||||
// seed byte in first, so feed seed='1' and the rest — proving poly/reflection/init/final.
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# App Store screenshot driver for the Punktfunk Apple client.
|
||||
#
|
||||
# Launches the app in "shot mode" (PUNKTFUNK_SHOT_SCENE=<name> → one mock-populated screen,
|
||||
# full-bleed; see Sources/PunktfunkClient/Screenshots/) once per scene per device, and lets the OS
|
||||
# capture the REAL rendered UI:
|
||||
# • macOS → `screencapture` of the app's borderless window.
|
||||
# • iOS/iPadOS/tvOS → a booted Simulator + `xcrun simctl io booted screenshot` (native pixels =
|
||||
# the exact App Store size for that device).
|
||||
#
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
# • iOS/iPadOS/tvOS targets: full Xcode (xcodebuild + Simulators), not just Command Line Tools.
|
||||
#
|
||||
# Usage:
|
||||
# tools/screenshots.sh all # every platform this machine can build
|
||||
# tools/screenshots.sh macos # just macOS
|
||||
# tools/screenshots.sh ios ipad tvos # specific platforms
|
||||
# OUT=~/Desktop/shots tools/screenshots.sh all
|
||||
# PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame for the hero
|
||||
#
|
||||
# Keep SCENES in sync with ShotScenes.all.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
log() { printf '\033[1;36m[shots]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[shots]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[shots]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_xcode() {
|
||||
xcrun --find simctl >/dev/null 2>&1 \
|
||||
|| die "Full Xcode required for simulator capture (have Command Line Tools only).
|
||||
Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
local logf; logf="$(mktemp)"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" "$bin" >"$logf" 2>&1 &
|
||||
local pid=$!
|
||||
# Wait for the window to exist and the scene to settle.
|
||||
local win=""
|
||||
for _ in $(seq 1 50); do
|
||||
win="$(grep -o 'PF_SHOT_WINDOW=[0-9]*' "$logf" | head -1 | cut -d= -f2 || true)"
|
||||
[ -n "$win" ] && grep -q PF_SHOT_READY "$logf" && break
|
||||
sleep 0.2
|
||||
done
|
||||
if [ -z "$win" ]; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
warn "macOS/$scene: app never reported a window — skipping"; cat "$logf" >&2; continue
|
||||
fi
|
||||
local dest="$OUT/mac-$scene.png"
|
||||
if screencapture -x -o -l"$win" "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
log "macOS/$scene → $dest ($(pixels "$dest"))"
|
||||
else
|
||||
warn "macOS/$scene: screencapture failed — grant your terminal Screen Recording permission
|
||||
(System Settings → Privacy & Security → Screen Recording), then re-run."
|
||||
fi
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$logf"
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ iOS / iPadOS / tvOS
|
||||
|
||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||
|
||||
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
local devtype rt
|
||||
devtype="$(xcrun simctl list devicetypes | grep -E "$match" \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimDeviceType\.[A-Za-z0-9.-]+' | head -1 || true)"
|
||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||
(needs a $platform runtime + a matching device type — check 'xcrun simctl list')."
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
local dd; dd="$(mktemp -d)"
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|| die "$prefix: xcodebuild failed"
|
||||
local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -type d | head -1)"
|
||||
[ -n "$app" ] || die "$prefix: no .app built"
|
||||
xcrun simctl install "$udid" "$app"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
SIMCTL_CHILD_PUNKTFUNK_SHOT_SCENE="$scene" \
|
||||
${PUNKTFUNK_SHOT_HERO:+SIMCTL_CHILD_PUNKTFUNK_SHOT_HERO="$PUNKTFUNK_SHOT_HERO"} \
|
||||
xcrun simctl launch "$udid" "$BUNDLE_ID" >/dev/null
|
||||
sleep "$SETTLE"
|
||||
local dest="$OUT/$prefix-$scene.png"
|
||||
xcrun simctl io "$udid" screenshot "$dest" >/dev/null
|
||||
log "$prefix/$scene → $dest ($(pixels "$dest"))"
|
||||
done
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
rm -rf "$dd"
|
||||
}
|
||||
|
||||
pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{print $2}' | paste -sd× -; }
|
||||
|
||||
# ---------------------------------------------------------------------------- dispatch
|
||||
|
||||
[ $# -gt 0 ] || set -- all
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
;;
|
||||
*) die "unknown target '$target' (use: all macos ios ipad tvos)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "Done. Screenshots in $OUT"
|
||||
ls -1 "$OUT" 2>/dev/null || true
|
||||
@@ -81,7 +81,7 @@ argv and a clear `client-not-found` error surface to the UI. The child PID is tr
|
||||
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
|
||||
`~/.local/bin`.
|
||||
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `punktfunk1-host`).
|
||||
- A punktfunk/1 host on the LAN (`punktfunk-host serve` or `punktfunk1-host`).
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -39,7 +39,39 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub is_dualsense: bool,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
|
||||
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
|
||||
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
|
||||
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
@@ -120,8 +152,7 @@ impl GamepadService {
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||
Some(_) => GamepadPref::Xbox360,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -247,10 +278,9 @@ impl Worker {
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
is_dualsense: matches!(
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
sdl3::gamepad::GamepadType::PS5
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -552,7 +582,7 @@ fn run(
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the monitor's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
|
||||
pub fn show(
|
||||
@@ -85,10 +85,11 @@ pub fn show(
|
||||
let pads = gamepads.pads();
|
||||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||
pad_names.extend(pads.iter().map(|p| {
|
||||
if p.is_dualsense {
|
||||
format!("{} · DualSense", p.name)
|
||||
} else {
|
||||
let kind = p.kind_label();
|
||||
if kind.is_empty() {
|
||||
p.name.clone()
|
||||
} else {
|
||||
format!("{} · {kind}", p.name)
|
||||
}
|
||||
}));
|
||||
let forward_row = adw::ComboRow::builder()
|
||||
@@ -126,6 +127,8 @@ pub fn show(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
]))
|
||||
.build();
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
|
||||
@@ -164,8 +164,27 @@ impl SoftwareDecoder {
|
||||
let rebuild =
|
||||
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
|
||||
if rebuild {
|
||||
let ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
|
||||
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, but our SDR HEVC stream is BT.709 limited
|
||||
// range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601
|
||||
// and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination =
|
||||
// full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs).
|
||||
const SWS_CS_ITU709: i32 = 1;
|
||||
unsafe {
|
||||
let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffmpeg::ffi::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
cs709, // inv_table: source (YUV) coefficients — BT.709
|
||||
0, // srcRange: 0 = limited/studio (MPEG)
|
||||
cs709, // 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));
|
||||
}
|
||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||
|
||||
+121
-55
@@ -27,9 +27,10 @@
|
||||
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
|
||||
//! choice in its Welcome (logged as `session offer … compositor=…`).
|
||||
//!
|
||||
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the
|
||||
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360,
|
||||
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`).
|
||||
//! `--gamepad NAME` requests a host virtual-pad backend
|
||||
//! (`auto`|`xbox360`|`dualsense`|`xboxone`|`dualshock4`); the host honors it where available (the
|
||||
//! UHID pads — DualSense, DualShock 4 — need Linux), else falls back to X-Box 360, and reports the
|
||||
//! resolved choice in its Welcome (logged as `session offer … gamepad=…`).
|
||||
//!
|
||||
//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises
|
||||
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
|
||||
@@ -45,7 +46,8 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, Hello, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||
Reconfigured, Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -177,7 +179,9 @@ fn parse_args() -> Args {
|
||||
Some(s) => match GamepadPref::from_name(s) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
eprintln!("--gamepad must be one of: auto, xbox360, dualsense");
|
||||
eprintln!(
|
||||
"--gamepad must be one of: auto, xbox360, dualsense, xboxone, dualshock4"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
@@ -401,6 +405,9 @@ async fn session(args: Args) -> Result<()> {
|
||||
frames = welcome.frames,
|
||||
compositor = welcome.compositor.as_str(),
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
bit_depth = welcome.bit_depth,
|
||||
color = ?welcome.color,
|
||||
hdr = welcome.color.is_hdr(),
|
||||
"session offer"
|
||||
);
|
||||
|
||||
@@ -433,13 +440,15 @@ async fn session(args: Args) -> Result<()> {
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Speed-test accumulators: the data-plane loop folds each FLAG_PROBE filler AU in here; the
|
||||
// --speed-test reporter below reads them once the host's ProbeResult lands. first/last hold
|
||||
// now_ns timestamps of the receive window (0 = unset).
|
||||
let probe_recv_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let probe_recv_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let probe_first_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let probe_last_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
||||
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
||||
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
||||
let rx_wire_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let rx_wire_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
// Adaptive-FEC loss feedback: the data loop publishes a windowed loss estimate here; in normal
|
||||
// stream mode (no speed test / remode) a control-stream task relays it to the host as a
|
||||
// LossReport so it can size FEC to the link. u32::MAX = "no fresh sample this window".
|
||||
let loss_ppm = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(u32::MAX));
|
||||
|
||||
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
|
||||
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
|
||||
@@ -470,19 +479,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
}
|
||||
});
|
||||
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
|
||||
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler;
|
||||
// measure what arrives vs. what it reports sending.
|
||||
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
|
||||
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
|
||||
let mut ss = send;
|
||||
let mut sr = recv;
|
||||
let (pb, pp, pf, pl) = (
|
||||
probe_recv_bytes.clone(),
|
||||
probe_recv_packets.clone(),
|
||||
probe_first_ns.clone(),
|
||||
probe_last_ns.clone(),
|
||||
);
|
||||
let (rxp, rxb) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
|
||||
// Per-packet wire size to express delivered bytes as link bytes (header + shard + crypto);
|
||||
// every shard is zero-padded to shard_payload so all data packets are this exact size.
|
||||
let crypto_overhead = if welcome.encrypt {
|
||||
punktfunk_core::packet::CRYPTO_OVERHEAD as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||
let base_pkts = rxp.load(Relaxed);
|
||||
let base_bytes = rxb.load(Relaxed);
|
||||
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||
if io::write_msg(
|
||||
&mut ss,
|
||||
@@ -505,37 +520,65 @@ async fn session(args: Args) -> Result<()> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The reliable result can beat the last UDP shards — let them reassemble.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
let recv_bytes = pb.load(Relaxed);
|
||||
let recv_packets = pp.load(Relaxed);
|
||||
let (first, last) = (pf.load(Relaxed), pl.load(Relaxed));
|
||||
let window_ms = if first > 0 && last > first {
|
||||
(last - first) / 1_000_000
|
||||
// The reliable result can beat the last UDP shards — let the tail arrive before reading.
|
||||
// Keep this short: video resumes the instant the burst ends, so a long settle counts
|
||||
// resumed-video packets against the probe (inflating recv past the host's wire count).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(60)).await;
|
||||
let recv_packets = rxp.load(Relaxed).saturating_sub(base_pkts);
|
||||
// bytes_received counts plaintext (header + shard); add per-packet crypto back for the
|
||||
// true on-wire byte count.
|
||||
let recv_wire_bytes =
|
||||
rxb.load(Relaxed).saturating_sub(base_bytes) + recv_packets * crypto_overhead;
|
||||
// The host's burst duration is the rate denominator (it sent for this long).
|
||||
let window_ms = res.duration_ms.max(1) as u64;
|
||||
let throughput_kbps = recv_wire_bytes.saturating_mul(8) / window_ms;
|
||||
// Link loss: wire packets the host put out that didn't arrive. host_drop: wire packets
|
||||
// the host couldn't even hand to the kernel (send buffer too small / can't keep up).
|
||||
let link_loss = if res.wire_packets_sent > 0 {
|
||||
(res.wire_packets_sent as i64 - recv_packets as i64).max(0) as f64
|
||||
/ res.wire_packets_sent as f64
|
||||
* 100.0
|
||||
} else {
|
||||
0
|
||||
0.0
|
||||
};
|
||||
let throughput_kbps = recv_bytes
|
||||
.saturating_mul(8)
|
||||
.checked_div(window_ms)
|
||||
.unwrap_or(0);
|
||||
let loss_pct = if res.bytes_sent > 0 {
|
||||
res.bytes_sent.saturating_sub(recv_bytes) as f64 / res.bytes_sent as f64 * 100.0
|
||||
let offered_wire = res.wire_packets_sent + res.send_dropped;
|
||||
let host_drop = if offered_wire > 0 {
|
||||
res.send_dropped as f64 / offered_wire as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tracing::info!(
|
||||
target_kbps,
|
||||
host_sent_bytes = res.bytes_sent,
|
||||
host_sent_packets = res.packets_sent,
|
||||
recv_bytes,
|
||||
recv_packets,
|
||||
window_ms,
|
||||
throughput_kbps,
|
||||
loss_pct = format!("{loss_pct:.1}%"),
|
||||
target_mbps = target_kbps / 1000,
|
||||
delivered_mbps = throughput_kbps / 1000,
|
||||
link_loss_pct = format!("{link_loss:.1}%"),
|
||||
host_drop_pct = format!("{host_drop:.1}%"),
|
||||
wire_pkts_sent = res.wire_packets_sent,
|
||||
wire_pkts_recv = recv_packets,
|
||||
send_dropped = res.send_dropped,
|
||||
"SPEED TEST complete",
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Normal stream mode: relay the data loop's windowed loss estimate to the host as periodic
|
||||
// LossReports, so it can size FEC to the link (adaptive FEC). The control stream is otherwise
|
||||
// idle here (remode/speed-test own it in their modes).
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
||||
@@ -789,12 +832,20 @@ async fn session(args: Args) -> Result<()> {
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut hdr_logged = false;
|
||||
while let Ok(d) = conn2.read_datagram().await {
|
||||
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
|
||||
a.fetch_add(1, Relaxed);
|
||||
ab.fetch_add(opus.len() as u64, Relaxed);
|
||||
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
||||
r.fetch_add(1, Relaxed);
|
||||
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
||||
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
|
||||
// assert the host sent it for an HDR session.
|
||||
if !hdr_logged {
|
||||
hdr_logged = true;
|
||||
tracing::info!(?meta, "HDR static metadata (0xCE)");
|
||||
}
|
||||
} else if let Some(hid) = punktfunk_core::quic::HidOutput::decode(&d) {
|
||||
// The DualSense feedback plane (lightbar / player LEDs / adaptive triggers).
|
||||
// Log the first few so a playtest can see triggers/LEDs arrive without spam.
|
||||
@@ -810,12 +861,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
let cfg = welcome.session_config(Role::Client);
|
||||
let expected = welcome.frames;
|
||||
let out_path = args.out.clone();
|
||||
let (pb, pp, pf, pl) = (
|
||||
probe_recv_bytes.clone(),
|
||||
probe_recv_packets.clone(),
|
||||
probe_first_ns.clone(),
|
||||
probe_last_ns.clone(),
|
||||
);
|
||||
let (rxp_dt, rxb_dt) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
|
||||
let lp_dt = loss_ppm.clone();
|
||||
|
||||
// Express our receive time in the host clock before differencing against the host-stamped
|
||||
// capture pts. 0 ⇒ same-host or an old host that didn't answer the skew handshake (the latency
|
||||
@@ -850,7 +897,32 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut latencies_us: Vec<u64> = Vec::new();
|
||||
let mut last_rx = std::time::Instant::now();
|
||||
let started = std::time::Instant::now();
|
||||
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
||||
let mut last_loss_report = std::time::Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
loop {
|
||||
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
|
||||
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
|
||||
{
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let s = session.stats();
|
||||
rxp_dt.store(s.packets_received, Relaxed);
|
||||
rxb_dt.store(s.bytes_received, Relaxed);
|
||||
if last_loss_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
lp_dt.store(
|
||||
window_loss_ppm(
|
||||
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
s.packets_received.wrapping_sub(last_received),
|
||||
s.frames_dropped.wrapping_sub(last_dropped),
|
||||
),
|
||||
Relaxed,
|
||||
);
|
||||
last_loss_report = std::time::Instant::now();
|
||||
last_recovered = s.fec_recovered_shards;
|
||||
last_received = s.packets_received;
|
||||
last_dropped = s.frames_dropped;
|
||||
}
|
||||
}
|
||||
if expected > 0 && ok + mismatched >= expected {
|
||||
break;
|
||||
}
|
||||
@@ -867,15 +939,9 @@ async fn session(args: Args) -> Result<()> {
|
||||
match session.poll_frame() {
|
||||
Ok(frame) => {
|
||||
last_rx = std::time::Instant::now();
|
||||
// Speed-test filler isn't video: fold it into the probe accumulators and skip
|
||||
// verification / the --out sink.
|
||||
// Speed-test filler isn't video: it's measured via the packet-level counters
|
||||
// mirrored at the loop head — skip verification / the --out sink.
|
||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let n = now_ns();
|
||||
let _ = pf.compare_exchange(0, n, Relaxed, Relaxed);
|
||||
pl.store(n, Relaxed);
|
||||
pb.fetch_add(frame.data.len() as u64, Relaxed);
|
||||
pp.fetch_add(1, Relaxed);
|
||||
continue;
|
||||
}
|
||||
bytes += frame.data.len() as u64;
|
||||
|
||||
@@ -4,7 +4,8 @@ The Windows client ships as **signed MSIX** packages so Windows boxes get a real
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client and on `win-v*` release tags.
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
@@ -39,9 +40,9 @@ because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3.
|
||||
## Versioning
|
||||
|
||||
MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off
|
||||
the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug).
|
||||
- `main` push / `workflow_dispatch` → `0.2.<run_number>.0` (rolling, climbs by run number).
|
||||
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
|
||||
the stable `latest/` alias and attached to the unified Gitea Release.
|
||||
- `main` push / `workflow_dispatch` → `0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
|
||||
|
||||
## Signing & install
|
||||
|
||||
|
||||
@@ -951,6 +951,11 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
||||
// --- stream page --------------------------------------------------------------------------
|
||||
|
||||
fn present_newest(ctx: &mut PresentCtx) {
|
||||
// Apply the latest source HDR mastering metadata (from the session pump's 0xCE drain) before
|
||||
// presenting — a cheap no-op in the presenter when unchanged.
|
||||
if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() {
|
||||
ctx.presenter.set_hdr_metadata(meta);
|
||||
}
|
||||
// Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value —
|
||||
// the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters.
|
||||
let mut newest = None;
|
||||
|
||||
@@ -32,12 +32,33 @@ const G: f32 = 9.80665;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
// `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only
|
||||
// reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now.
|
||||
// reads `pref` (via `auto_pref`), so they're unused in reachable code for now.
|
||||
#[allow(dead_code)]
|
||||
pub id: u32,
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub is_dualsense: bool,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
@@ -112,8 +133,7 @@ impl GamepadService {
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||
Some(_) => GamepadPref::Xbox360,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -235,10 +255,9 @@ impl Worker {
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
is_dualsense: matches!(
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
sdl3::gamepad::GamepadType::PS5
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -515,7 +534,7 @@ fn run(
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
+103
-14
@@ -119,8 +119,18 @@ pub struct Presenter {
|
||||
panel_h: u32,
|
||||
/// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode.
|
||||
hdr: bool,
|
||||
/// The source's static HDR mastering metadata received over the protocol (`0xCE`), applied via
|
||||
/// `SetHDRMetaData` so the display tone-maps from the real grade instead of a generic 1000-nit
|
||||
/// guess. `None` until the first update arrives (then the generic baseline is used).
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
}
|
||||
|
||||
/// Latest source HDR mastering metadata, written by the session pump (`session.rs`, the sole
|
||||
/// `next_hdr_meta` consumer) and read by `present_newest` on the UI thread — decoupled so the
|
||||
/// presenter doesn't need the connector. One session at a time on the client, so a single slot.
|
||||
pub static LATEST_HDR_META: std::sync::Mutex<Option<punktfunk_core::quic::HdrMeta>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
impl Presenter {
|
||||
/// Create the presenter on the process-wide shared D3D11 device (the one the decoder uses), plus
|
||||
/// the composition swapchain + shaders, sized to the panel.
|
||||
@@ -148,9 +158,23 @@ impl Presenter {
|
||||
panel_w: width.max(1),
|
||||
panel_h: height.max(1),
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the source HDR mastering metadata (from the `0xCE` plane). Stored for the next HDR
|
||||
/// swapchain switch, and applied immediately if already presenting HDR. A no-op when unchanged
|
||||
/// (so it's cheap to call every frame from the present loop).
|
||||
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
|
||||
if self.hdr_meta == Some(meta) {
|
||||
return;
|
||||
}
|
||||
self.hdr_meta = Some(meta);
|
||||
if self.hdr {
|
||||
unsafe { self.apply_hdr_metadata() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
|
||||
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
|
||||
&self.swap
|
||||
@@ -350,25 +374,42 @@ impl Presenter {
|
||||
// DWM still tone-maps HDR10 → SDR, so leaving the default there is fine).
|
||||
if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) {
|
||||
if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 {
|
||||
let _ = sc3.SetColorSpace1(colorspace);
|
||||
if let Err(e) = sc3.SetColorSpace1(colorspace) {
|
||||
// A silent failure here presents PQ content as SDR gamma (crushed/dark) —
|
||||
// surface it instead of swallowing it.
|
||||
tracing::warn!(error = %e, ?colorspace, "SetColorSpace1 failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
if on {
|
||||
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
|
||||
let md = hdr10_metadata();
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
|
||||
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
|
||||
);
|
||||
let _ = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes));
|
||||
} else if on {
|
||||
tracing::warn!("swapchain rejects BT.2020 PQ present colour space (SDR display?) — DWM tone-maps");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hdr = on;
|
||||
if on {
|
||||
self.apply_hdr_metadata();
|
||||
}
|
||||
}
|
||||
tracing::info!(hdr = on, "swapchain colour mode switched");
|
||||
}
|
||||
|
||||
/// Push the current `DXGI_HDR_METADATA_HDR10` to the swapchain. Uses the source's received
|
||||
/// mastering metadata when known, else a generic HDR10 baseline. Caller ensures HDR mode.
|
||||
unsafe fn apply_hdr_metadata(&self) {
|
||||
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
|
||||
let md = self
|
||||
.hdr_meta
|
||||
.map(hdr_meta_to_dxgi)
|
||||
.unwrap_or_else(generic_hdr10_metadata);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
|
||||
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
|
||||
);
|
||||
if let Err(e) = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes)) {
|
||||
tracing::warn!(error = %e, "SetHDRMetaData failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(&mut self, frame: &crate::video::CpuFrame) -> Result<()> {
|
||||
let (w, h) = (frame.width, frame.height);
|
||||
let need_new = !matches!(&self.cpu_tex, Some((_, _, tw, th)) if *tw == w && *th == h);
|
||||
@@ -578,10 +619,39 @@ fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if any attached display is currently in HDR (BT.2020 PQ) mode. The client advertises HDR
|
||||
/// caps only when this holds, so an SDR display gets a proper 8-bit BT.709 stream instead of PQ it
|
||||
/// would mis-tone-map (the washed-out/dark failure); an HDR display self-tone-maps from the
|
||||
/// mastering metadata. Coarse — checks ANY output, not the app's specific monitor; a mid-session
|
||||
/// monitor move to/from HDR is a follow-up (the `Reconfigure` downgrade).
|
||||
pub fn display_supports_hdr() -> bool {
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = match CreateDXGIFactory1() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
if let Ok(o6) = output.cast::<IDXGIOutput6>() {
|
||||
if let Ok(desc) = o6.GetDesc1() {
|
||||
if desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||
/// MaxCLL 1000 / MaxFALL 400. The protocol doesn't carry the stream's real mastering metadata yet
|
||||
/// (host follow-up), so these are sane defaults the display tone-maps from.
|
||||
fn hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
||||
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: [35400, 14600],
|
||||
GreenPrimary: [8500, 39850],
|
||||
@@ -593,3 +663,22 @@ fn hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||
MaxFrameAverageLightLevel: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the protocol's [`HdrMeta`](punktfunk_core::quic::HdrMeta) to `DXGI_HDR_METADATA_HDR10`.
|
||||
/// Two careful conversions: HdrMeta stores primaries in **ST.2086 G,B,R order**, DXGI wants
|
||||
/// **R,G,B**; and HdrMeta mastering luminance is in **0.0001-cd/m² units** while DXGI's
|
||||
/// `MaxMasteringLuminance` is in **whole nits** (MinMasteringLuminance stays 0.0001-nit). Chromaticity
|
||||
/// units (1/50000) and MaxCLL/MaxFALL (nits) match 1:1.
|
||||
fn hdr_meta_to_dxgi(m: punktfunk_core::quic::HdrMeta) -> DXGI_HDR_METADATA_HDR10 {
|
||||
let [g, b, r] = m.display_primaries; // ST.2086 order
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: r,
|
||||
GreenPrimary: g,
|
||||
BluePrimary: b,
|
||||
WhitePoint: m.white_point,
|
||||
MaxMasteringLuminance: m.max_display_mastering_luminance / 10_000, // 0.0001-nit → nit
|
||||
MinMasteringLuminance: m.min_display_mastering_luminance, // already 0.0001-nit
|
||||
MaxContentLightLevel: m.max_cll,
|
||||
MaxFrameAverageLightLevel: m.max_fall,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,13 +107,19 @@ fn pump(
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
// Advertise 10-bit + HDR10 (when enabled): the presenter handles BT.2020 PQ frames (P010 on
|
||||
// the GPU path, X2BGR10 on software), so the host may upgrade HDR content to a Main10/PQ
|
||||
// stream — it still only does so for actual HDR content with its own 10-bit gate. 8-bit SDR
|
||||
// is unaffected. A client that turns HDR off advertises `0` and always gets the 8-bit stream.
|
||||
if params.hdr_enabled {
|
||||
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
||||
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
||||
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
||||
// rather than PQ the panel would mis-tone-map (washed-out/dark). An HDR display self-tone-maps
|
||||
// from the mastering metadata we apply. The presenter handles BT.2020 PQ frames (P010 / X2BGR10).
|
||||
if params.hdr_enabled && crate::present::display_supports_hdr() {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
if params.hdr_enabled {
|
||||
tracing::info!(
|
||||
"HDR enabled in settings but no HDR display detected — requesting SDR"
|
||||
);
|
||||
}
|
||||
0
|
||||
},
|
||||
None, // launch: the Windows client has no library picker yet
|
||||
@@ -253,6 +259,13 @@ fn pump(
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the HDR static-metadata plane (0xCE): the source's real mastering display + content
|
||||
// light level. Stash the latest for the UI-thread presenter to apply via SetHDRMetaData —
|
||||
// this pump is the sole consumer of the plane. Rare (start + on change/keyframe).
|
||||
while let Ok(meta) = connector.next_hdr_meta(Duration::ZERO) {
|
||||
*crate::present::LATEST_HDR_META.lock().unwrap() = Some(meta);
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
lat_us.sort_unstable();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display.
|
||||
#
|
||||
# Deliberately self-contained (no `*.workspace = true` inheritance, no Windows deps): this crate is a
|
||||
# path dependency of BOTH the host workspace (crates/punktfunk-host) AND the out-of-workspace driver
|
||||
# workspace (packaging/windows/drivers/), so it must resolve identically from either build graph. It is
|
||||
# `no_std` (+ alloc) and platform-neutral; the GUID/LUID are plain integers each side converts to its
|
||||
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck
|
||||
# round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
|
||||
[package]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
|
||||
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Shared binary contract between the punktfunk host and the `pf-vdisplay` IddCx driver.
|
||||
//!
|
||||
//! Two planes:
|
||||
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
|
||||
//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI.
|
||||
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures
|
||||
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into
|
||||
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the
|
||||
//! `Global\` object-name scheme, and the driver-status codes.
|
||||
//!
|
||||
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
|
||||
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
|
||||
//! once here — with bytemuck `Pod` derives and `const` size asserts — makes any drift a compile error.
|
||||
//!
|
||||
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
|
||||
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
|
||||
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
/// Freshly-minted pf-vdisplay device-interface GUID — `{70667664-7044-5350-a1b2-c3d4e5f60001}`.
|
||||
/// Deliberately NOT SudoVDA's `{e5bcc234-…}`: we own the driver, so a private interface GUID signals
|
||||
/// it and removes any accidental coexistence with a real SudoVDA install. Construct on each side via
|
||||
/// `GUID::from_u128(PF_VDISPLAY_INTERFACE_GUID_U128)`.
|
||||
pub const PF_VDISPLAY_INTERFACE_GUID_U128: u128 = 0x7066_7664_7044_5350_a1b2_c3d4_e5f6_0001;
|
||||
|
||||
/// The interface GUID split into Windows `GUID` fields — `(Data1, Data2, Data3, Data4)` — so the driver
|
||||
/// (and host) can build a `windows`/`wdk_sys` `GUID` without re-deriving the byte layout. Standard GUID
|
||||
/// layout from the u128: `Data1` = high 32 bits, `Data2`/`Data3` = next two 16-bit groups, `Data4` =
|
||||
/// the low 64 bits big-endian. (This crate is `no_std` + provider-agnostic, so it returns the fields
|
||||
/// rather than depend on a `GUID` type.)
|
||||
#[must_use]
|
||||
pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
||||
let g = PF_VDISPLAY_INTERFACE_GUID_U128;
|
||||
(
|
||||
(g >> 96) as u32,
|
||||
(g >> 80) as u16,
|
||||
(g >> 64) as u16,
|
||||
(g as u64).to_be_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
|
||||
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||
pub const fn ctl_code(func: u32) -> u32 {
|
||||
(0x22u32 << 16) | (func << 2)
|
||||
}
|
||||
|
||||
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive.
|
||||
pub mod control {
|
||||
use super::ctl_code;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
|
||||
/// Add a virtual monitor at a mode → [`AddReply`]. Input [`AddRequest`].
|
||||
pub const IOCTL_ADD: u32 = ctl_code(0x900);
|
||||
/// Remove a virtual monitor by session id. Input [`RemoveRequest`].
|
||||
pub const IOCTL_REMOVE: u32 = ctl_code(0x901);
|
||||
/// Pin the IddCx render adapter (hybrid-GPU IDD-push). Input [`SetRenderAdapterRequest`].
|
||||
pub const IOCTL_SET_RENDER_ADAPTER: u32 = ctl_code(0x902);
|
||||
/// Keepalive (resets the driver watchdog). No payload.
|
||||
pub const IOCTL_PING: u32 = ctl_code(0x903);
|
||||
/// Version + watchdog handshake → [`InfoReply`]. No input.
|
||||
pub const IOCTL_GET_INFO: u32 = ctl_code(0x904);
|
||||
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
|
||||
/// SudoVDA "send-and-hope-it's-ignored" hack.
|
||||
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
|
||||
|
||||
/// `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
|
||||
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct AddRequest {
|
||||
pub session_id: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
pub _reserved: u32,
|
||||
}
|
||||
|
||||
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
|
||||
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct AddReply {
|
||||
pub adapter_luid_low: u32,
|
||||
pub adapter_luid_high: i32,
|
||||
pub target_id: u32,
|
||||
pub _reserved: u32,
|
||||
}
|
||||
|
||||
/// `IOCTL_REMOVE` input.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct RemoveRequest {
|
||||
pub session_id: u64,
|
||||
}
|
||||
|
||||
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct SetRenderAdapterRequest {
|
||||
pub luid_low: u32,
|
||||
pub luid_high: i32,
|
||||
}
|
||||
|
||||
/// `IOCTL_GET_INFO` reply: the protocol version (asserted against [`super::PROTOCOL_VERSION`]) and
|
||||
/// the watchdog timeout the host must ping within.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct InfoReply {
|
||||
pub protocol_version: u32,
|
||||
pub watchdog_timeout_s: u32,
|
||||
}
|
||||
|
||||
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
||||
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
||||
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<AddRequest>() == 24);
|
||||
assert!(offset_of!(AddRequest, session_id) == 0);
|
||||
assert!(offset_of!(AddRequest, width) == 8);
|
||||
assert!(offset_of!(AddRequest, height) == 12);
|
||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||
|
||||
assert!(size_of::<AddReply>() == 16);
|
||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||
assert!(offset_of!(AddReply, target_id) == 8);
|
||||
|
||||
assert!(size_of::<RemoveRequest>() == 8);
|
||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||
|
||||
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
||||
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
||||
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
||||
|
||||
assert!(size_of::<InfoReply>() == 8);
|
||||
assert!(offset_of!(InfoReply, protocol_version) == 0);
|
||||
assert!(offset_of!(InfoReply, watchdog_timeout_s) == 4);
|
||||
};
|
||||
}
|
||||
|
||||
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and
|
||||
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened
|
||||
/// by name on the driver side); only the *layout/contract* lives here.
|
||||
pub mod frame {
|
||||
use alloc::string::String;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
||||
/// only attaches to a fully-published ring.
|
||||
pub const MAGIC: u32 = 0x4456_4650;
|
||||
/// Frame-plane version (independent bump of the header layout).
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
|
||||
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
|
||||
/// because both read this one constant.
|
||||
pub const RING_LEN: u32 = 6;
|
||||
|
||||
/// `driver_status` values the driver writes into the host header (the host logs them on a timeout).
|
||||
pub const DRV_STATUS_NONE: u32 = 0;
|
||||
/// Driver attached to the ring and is publishing.
|
||||
pub const DRV_STATUS_OPENED: u32 = 1;
|
||||
/// Driver could not open the host's textures — render-adapter mismatch (it renders on a different GPU
|
||||
/// than where the host created the ring). `driver_status_detail` carries the HRESULT.
|
||||
pub const DRV_STATUS_TEX_FAIL: u32 = 2;
|
||||
/// Driver has no `ID3D11Device1` to open shared resources.
|
||||
pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
|
||||
|
||||
/// 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.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct SharedHeader {
|
||||
pub magic: u32,
|
||||
pub version: u32,
|
||||
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver
|
||||
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish.
|
||||
pub generation: u32,
|
||||
pub ring_len: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub dxgi_format: u32,
|
||||
pub _pad: u32,
|
||||
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
|
||||
pub latest: u64,
|
||||
pub qpc_pts: u64,
|
||||
/// Driver-written: the adapter the swap-chain actually renders on (mismatch detection).
|
||||
pub driver_render_luid_low: u32,
|
||||
pub driver_render_luid_high: i32,
|
||||
/// Driver-written status (visibility channel — UMDF hides OutputDebugString + the restricted
|
||||
/// token blocks file writes, so this header is how the driver reports state).
|
||||
pub driver_status: u32,
|
||||
pub driver_status_detail: u32,
|
||||
}
|
||||
|
||||
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
|
||||
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
|
||||
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
|
||||
/// consumes an unwritten new-ring slot — eliminating the toggle-time garbage frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FrameToken {
|
||||
pub generation: u32,
|
||||
pub seq: u32,
|
||||
pub slot: u8,
|
||||
}
|
||||
|
||||
impl FrameToken {
|
||||
/// Low 24 bits of `generation` are significant (see the field docs).
|
||||
pub const GENERATION_MASK: u32 = 0x00FF_FFFF;
|
||||
|
||||
pub const fn pack(self) -> u64 {
|
||||
(((self.generation & Self::GENERATION_MASK) as u64) << 40)
|
||||
| (((self.seq as u64) & 0xFFFF_FFFF) << 8)
|
||||
| (self.slot as u64)
|
||||
}
|
||||
|
||||
pub const fn unpack(v: u64) -> Self {
|
||||
Self {
|
||||
generation: ((v >> 40) as u32) & Self::GENERATION_MASK,
|
||||
seq: ((v >> 8) & 0xFFFF_FFFF) as u32,
|
||||
slot: (v & 0xFF) as u8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Global\pfvd-hdr-<target>` — the shared metadata header mapping name.
|
||||
pub fn header_name(target_id: u32) -> String {
|
||||
alloc::format!("Global\\pfvd-hdr-{target_id}")
|
||||
}
|
||||
/// `Global\pfvd-evt-<target>` — the frame-ready auto-reset event name.
|
||||
pub fn event_name(target_id: u32) -> String {
|
||||
alloc::format!("Global\\pfvd-evt-{target_id}")
|
||||
}
|
||||
/// `Global\pfvd-tex-<target>-<generation>-<slot>` — a ring texture's shared-handle name. The
|
||||
/// generation in the name means a recreate's new textures never collide with the old ring's
|
||||
/// not-yet-released handles.
|
||||
pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String {
|
||||
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
|
||||
}
|
||||
|
||||
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
||||
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
|
||||
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<SharedHeader>() == 64);
|
||||
assert!(offset_of!(SharedHeader, magic) == 0);
|
||||
assert!(offset_of!(SharedHeader, version) == 4);
|
||||
assert!(offset_of!(SharedHeader, generation) == 8);
|
||||
assert!(offset_of!(SharedHeader, ring_len) == 12);
|
||||
assert!(offset_of!(SharedHeader, width) == 16);
|
||||
assert!(offset_of!(SharedHeader, height) == 20);
|
||||
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
|
||||
assert!(offset_of!(SharedHeader, _pad) == 28);
|
||||
assert!(offset_of!(SharedHeader, latest) == 32);
|
||||
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
|
||||
assert!(offset_of!(SharedHeader, driver_status) == 56);
|
||||
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
|
||||
};
|
||||
}
|
||||
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
|
||||
///
|
||||
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
|
||||
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
||||
/// asserts makes a one-sided edit a compile error.
|
||||
///
|
||||
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
|
||||
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
|
||||
pub mod gamepad {
|
||||
use alloc::string::String;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
|
||||
pub const XUSB_MAGIC: u32 = 0x5558_4650;
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
|
||||
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
|
||||
/// only the u32 value is the contract.)
|
||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||
|
||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
|
||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||
|
||||
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
|
||||
pub fn xusb_shm_name(index: u8) -> String {
|
||||
alloc::format!("Global\\pfxusb-shm-{index}")
|
||||
}
|
||||
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
|
||||
pub fn pad_shm_name(index: u8) -> String {
|
||||
alloc::format!("Global\\pfds-shm-{index}")
|
||||
}
|
||||
|
||||
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
|
||||
/// `packet` number + buttons/triggers/sticks in XInput conventions); the driver answers
|
||||
/// `XInputGetState`. The driver writes force-feedback (`XInputSetState`) into `rumble_*`, bumping
|
||||
/// `rumble_seq`, which the host relays to the client.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct XusbShm {
|
||||
pub magic: u32,
|
||||
/// XInput `dwPacketNumber` — bumped by the host on every state change.
|
||||
pub packet: u32,
|
||||
pub buttons: u16,
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
pub thumb_lx: i16,
|
||||
pub thumb_ly: i16,
|
||||
pub thumb_rx: i16,
|
||||
pub thumb_ry: i16,
|
||||
pub _reserved0: u32,
|
||||
/// Bumped by the driver on a new force-feedback packet.
|
||||
pub rumble_seq: u32,
|
||||
pub rumble_large: u8,
|
||||
pub rumble_small: u8,
|
||||
pub _reserved1: [u8; 34],
|
||||
}
|
||||
|
||||
/// Virtual DualSense / DualShock 4 shared section (256 B). 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) into `output`, bumping
|
||||
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct PadShm {
|
||||
pub magic: u32,
|
||||
pub _reserved0: u32,
|
||||
/// Input report region (host-written; the codec's report is <= 64 B — see
|
||||
/// `inject::dualsense_proto::DS_INPUT_REPORT_LEN`). The region spans `magic`+pad .. `out_seq`.
|
||||
pub input: [u8; 64],
|
||||
/// Bumped by the driver when it publishes a new `output` report.
|
||||
pub out_seq: u32,
|
||||
/// Output report region (driver-written): rumble / lightbar / player-LEDs / adaptive triggers.
|
||||
pub output: [u8; 64],
|
||||
/// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`].
|
||||
pub device_type: u8,
|
||||
pub _reserved1: [u8; 115],
|
||||
}
|
||||
|
||||
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||
// assert here means the struct no longer matches the historical `OFF_*` layout (host) / `view.add(N)`
|
||||
// literal (driver) and must be fixed before either side switches to the type.
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<XusbShm>() == 64);
|
||||
assert!(offset_of!(XusbShm, magic) == 0);
|
||||
assert!(offset_of!(XusbShm, packet) == 4);
|
||||
assert!(offset_of!(XusbShm, buttons) == 8);
|
||||
assert!(offset_of!(XusbShm, left_trigger) == 10);
|
||||
assert!(offset_of!(XusbShm, right_trigger) == 11);
|
||||
assert!(offset_of!(XusbShm, thumb_lx) == 12);
|
||||
assert!(offset_of!(XusbShm, thumb_ly) == 14);
|
||||
assert!(offset_of!(XusbShm, thumb_rx) == 16);
|
||||
assert!(offset_of!(XusbShm, thumb_ry) == 18);
|
||||
assert!(offset_of!(XusbShm, rumble_seq) == 24);
|
||||
assert!(offset_of!(XusbShm, rumble_large) == 28);
|
||||
assert!(offset_of!(XusbShm, rumble_small) == 29);
|
||||
|
||||
assert!(size_of::<PadShm>() == 256);
|
||||
assert!(offset_of!(PadShm, magic) == 0);
|
||||
assert!(offset_of!(PadShm, input) == 8);
|
||||
assert!(offset_of!(PadShm, out_seq) == 72);
|
||||
assert!(offset_of!(PadShm, output) == 76);
|
||||
assert!(offset_of!(PadShm, device_type) == 140);
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytemuck::Zeroable;
|
||||
|
||||
#[test]
|
||||
fn frame_token_roundtrips() {
|
||||
for (g, s, slot) in [
|
||||
(1u32, 0u32, 0u8),
|
||||
(5, 12_345, 3),
|
||||
(frame::FrameToken::GENERATION_MASK, 0xFFFF_FFFF, 5),
|
||||
(0, 1, 255),
|
||||
] {
|
||||
let t = frame::FrameToken {
|
||||
generation: g,
|
||||
seq: s,
|
||||
slot,
|
||||
};
|
||||
assert_eq!(frame::FrameToken::unpack(t.pack()), t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_token_packing_matches_legacy_layout() {
|
||||
// The legacy code packed (gen<<40)|(seq<<8)|slot by hand; lock the bit positions.
|
||||
let t = frame::FrameToken {
|
||||
generation: 7,
|
||||
seq: 42,
|
||||
slot: 3,
|
||||
};
|
||||
assert_eq!(t.pack(), (7u64 << 40) | (42u64 << 8) | 3u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_is_pod_and_64_bytes() {
|
||||
let mut h = frame::SharedHeader::zeroed();
|
||||
h.magic = frame::MAGIC;
|
||||
h.width = 5120;
|
||||
h.height = 1440;
|
||||
let bytes = bytemuck::bytes_of(&h);
|
||||
assert_eq!(bytes.len(), 64);
|
||||
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
||||
assert_eq!(back.magic, frame::MAGIC);
|
||||
assert_eq!(back.width, 5120);
|
||||
assert_eq!(back.height, 1440);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_structs_roundtrip_through_bytes() {
|
||||
let req = control::AddRequest {
|
||||
session_id: 0xDEAD_BEEF_CAFE_F00D,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
refresh_hz: 120,
|
||||
_reserved: 0,
|
||||
};
|
||||
let bytes = bytemuck::bytes_of(&req);
|
||||
assert_eq!(bytes.len(), 24);
|
||||
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_stable() {
|
||||
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10");
|
||||
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10");
|
||||
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_names_and_magics_are_stable() {
|
||||
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
|
||||
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
|
||||
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
|
||||
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
|
||||
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctl_codes_are_contiguous_and_distinct() {
|
||||
assert_eq!(control::IOCTL_ADD, ctl_code(0x900));
|
||||
let all = [
|
||||
control::IOCTL_ADD,
|
||||
control::IOCTL_REMOVE,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
control::IOCTL_PING,
|
||||
control::IOCTL_GET_INFO,
|
||||
control::IOCTL_CLEAR_ALL,
|
||||
];
|
||||
for (i, a) in all.iter().enumerate() {
|
||||
for b in &all[i + 1..] {
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_is_not_sudovda() {
|
||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ fec-rs = { path = "vendor/fec-rs" }
|
||||
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
|
||||
zerocopy = { version = "0.8", features = ["derive"] }
|
||||
bytes = "1"
|
||||
socket2 = "0.6" # set SO_SNDBUF/SO_RCVBUF — default UDP buffers are too small for 4K/5K frame bursts
|
||||
socket2 = { version = "0.6", features = [
|
||||
"all",
|
||||
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
|
||||
thiserror = "2"
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
rand = "0.9"
|
||||
|
||||
@@ -547,6 +547,56 @@ impl PunktfunkHidOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static HDR metadata for an HDR session ([`punktfunk_connection_next_hdr_meta`]): SMPTE ST.2086
|
||||
/// mastering display colour volume + CEA-861.3 content light level. All fields are in the standard
|
||||
/// HDR10 SEI fixed-point units (primaries/white in 1/50000, luminance in 0.0001 cd/m²), ready for
|
||||
/// DXGI `DXGI_HDR_METADATA_HDR10` / Apple `CAEDRMetadata` / Android `KEY_HDR_STATIC_INFO`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkHdrMeta {
|
||||
/// Display-primaries x-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
|
||||
pub display_primaries_x: [u16; 3],
|
||||
/// Display-primaries y-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
|
||||
pub display_primaries_y: [u16; 3],
|
||||
/// White-point x-chromaticity, 1/50000 units.
|
||||
pub white_point_x: u16,
|
||||
/// White-point y-chromaticity, 1/50000 units.
|
||||
pub white_point_y: u16,
|
||||
/// Max display mastering luminance, 0.0001 cd/m² units.
|
||||
pub max_display_mastering_luminance: u32,
|
||||
/// Min display mastering luminance, 0.0001 cd/m² units.
|
||||
pub min_display_mastering_luminance: u32,
|
||||
/// Maximum content light level (MaxCLL), nits. 0 = unknown.
|
||||
pub max_cll: u16,
|
||||
/// Maximum frame-average light level (MaxFALL), nits. 0 = unknown.
|
||||
pub max_fall: u16,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkHdrMeta {
|
||||
fn from_meta(m: &crate::quic::HdrMeta) -> PunktfunkHdrMeta {
|
||||
PunktfunkHdrMeta {
|
||||
display_primaries_x: [
|
||||
m.display_primaries[0][0],
|
||||
m.display_primaries[1][0],
|
||||
m.display_primaries[2][0],
|
||||
],
|
||||
display_primaries_y: [
|
||||
m.display_primaries[0][1],
|
||||
m.display_primaries[1][1],
|
||||
m.display_primaries[2][1],
|
||||
],
|
||||
white_point_x: m.white_point[0],
|
||||
white_point_y: m.white_point[1],
|
||||
max_display_mastering_luminance: m.max_display_mastering_luminance,
|
||||
min_display_mastering_luminance: m.min_display_mastering_luminance,
|
||||
max_cll: m.max_cll,
|
||||
max_fall: m.max_fall,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PunktfunkRichInput::kind` — a touchpad contact (`finger`/`active`/`x`/`y` valid).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
@@ -637,11 +687,45 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
||||
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
||||
/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
|
||||
/// physical X-Box One/Series controller on the client.
|
||||
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
/// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||
///
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||
/// 10-bit (Main10) HEVC stream. (Mirrors `quic::VIDEO_CAP_10BIT`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
||||
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
const _: () = {
|
||||
use crate::config::GamepadPref;
|
||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
|
||||
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||
@@ -843,6 +927,59 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// Back-compat: ex4 advertises no video caps (8-bit BT.709 SDR). HDR-capable embedders call
|
||||
// `punktfunk_connect_ex5` with the cap bits.
|
||||
unsafe {
|
||||
punktfunk_connect_ex5(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
0,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex4`], but additionally advertises the embedder's video decode/present
|
||||
/// capabilities as `video_caps` — a bitfield of `PUNKTFUNK_VIDEO_CAP_10BIT` (can decode 10-bit
|
||||
/// Main10) and `PUNKTFUNK_VIDEO_CAP_HDR` (can present BT.2020 PQ HDR10). The host upgrades to a
|
||||
/// 10-bit / HDR encode ONLY when the matching bit is set (and the host opted in); `0` keeps the
|
||||
/// 8-bit BT.709 SDR stream. After connecting, read the resolved colour via
|
||||
/// [`punktfunk_connection_color_info`] and drain the mastering metadata via
|
||||
/// [`punktfunk_connection_next_hdr_meta`].
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
@@ -891,9 +1028,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
pref,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// 8-bit only over the C ABI for now — the ABI doesn't yet carry the embedder's video
|
||||
// caps (Apple/Android decode 8-bit). The native Windows client advertises 10-bit/HDR.
|
||||
0,
|
||||
video_caps,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -1195,6 +1330,90 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level) for
|
||||
/// an HDR session, into `*out`. [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`]
|
||||
/// once the session ended. The host sends one near session start and re-sends it on mastering
|
||||
/// changes / keyframes; apply the latest to the display (`SetHDRMetaData` / `CAEDRMetadata` /
|
||||
/// `KEY_HDR_STATIC_INFO`). Only an HDR session (`punktfunk_connection_color_info` reports a PQ
|
||||
/// transfer) ever emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one
|
||||
/// puller, may run alongside the other planes).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHdrMeta,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_hdr_meta(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(m) => {
|
||||
unsafe { *out = PunktfunkHdrMeta::from_meta(&m) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the session's resolved colour signalling + encode bit depth (from the host's Welcome).
|
||||
/// Each out pointer is filled when non-NULL: `primaries`/`transfer`/`matrix` are CICP code points
|
||||
/// (BT.709 = 1; BT.2020 = 9; PQ transfer = 16, HLG = 18; BT.2020-NCL matrix = 9), `full_range` is
|
||||
/// 0 (limited) or 1 (full), `bit_depth` is 8 or 10. A `transfer` of 16/18 means HDR — configure an
|
||||
/// HDR present path and drain [`punktfunk_connection_next_hdr_meta`]. Available immediately after a
|
||||
/// successful connect (these don't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
c: *mut PunktfunkConnection,
|
||||
primaries: *mut u8,
|
||||
transfer: *mut u8,
|
||||
matrix: *mut u8,
|
||||
full_range: *mut u8,
|
||||
bit_depth: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let color = c.inner.color;
|
||||
unsafe {
|
||||
if !primaries.is_null() {
|
||||
*primaries = color.primaries;
|
||||
}
|
||||
if !transfer.is_null() {
|
||||
*transfer = color.transfer;
|
||||
}
|
||||
if !matrix.is_null() {
|
||||
*matrix = color.matrix;
|
||||
}
|
||||
if !full_range.is_null() {
|
||||
*full_range = color.full_range;
|
||||
}
|
||||
if !bit_depth.is_null() {
|
||||
*bit_depth = c.inner.bit_depth;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
@@ -1525,24 +1744,31 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
|
||||
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
||||
/// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||
/// `host_drop_pct` the host-side send-buffer drop (raise `net.core.wmem_max`) — they're measured
|
||||
/// separately so a host that can't keep up reads differently from a lossy link.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PunktfunkProbeResult {
|
||||
/// 1 once the host's end-of-burst report arrived (measurement final); else 0 (partial).
|
||||
pub done: u8,
|
||||
/// Probe payload bytes / packets the client received.
|
||||
/// Delivered wire bytes (header + shard) / packets the client received during the burst.
|
||||
pub recv_bytes: u64,
|
||||
pub recv_packets: u32,
|
||||
/// Probe payload bytes / packets the host reported sending.
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// Client-measured receive window (first→last probe AU), milliseconds.
|
||||
/// The host's measured burst duration, milliseconds (the throughput denominator).
|
||||
pub elapsed_ms: u32,
|
||||
/// Measured goodput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
pub throughput_kbps: u32,
|
||||
/// Delivery loss `(host_bytes - recv_bytes) / host_bytes` as a percentage (0 if unknown).
|
||||
/// Link loss `(wire_packets_sent − recv_packets) / wire_packets_sent` as a percentage.
|
||||
pub loss_pct: f32,
|
||||
/// Host-side send-buffer drop `send_dropped / (wire_packets_sent + send_dropped)`, percent.
|
||||
pub host_drop_pct: f32,
|
||||
/// Wire packets the host put on the link, and the ones its send buffer dropped (raw counts).
|
||||
pub wire_packets_sent: u32,
|
||||
pub send_dropped: u32,
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
@@ -1602,6 +1828,9 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
|
||||
elapsed_ms: o.elapsed_ms,
|
||||
throughput_kbps: o.throughput_kbps,
|
||||
loss_pct: o.loss_pct,
|
||||
host_drop_pct: o.host_drop_pct,
|
||||
wire_packets_sent: o.wire_packets_sent,
|
||||
send_dropped: o.send_dropped,
|
||||
};
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
|
||||
@@ -16,8 +16,8 @@ use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
endpoint, io, Hello, HidOutput, ProbeRequest, ProbeResult, Reconfigure, Reconfigured,
|
||||
RequestKeyframe, RichInput, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest,
|
||||
ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RichInput, Start, Welcome,
|
||||
};
|
||||
use crate::session::{Frame, Session};
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -33,30 +33,55 @@ enum CtrlRequest {
|
||||
Mode(Mode),
|
||||
Probe(ProbeRequest),
|
||||
Keyframe,
|
||||
Loss(LossReport),
|
||||
}
|
||||
|
||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
|
||||
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's
|
||||
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
|
||||
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
|
||||
type Negotiated = (Mode, CompositorPref, GamepadPref, [u8; 32], u32, i64);
|
||||
/// The trailing `u8` is the resolved encode bit depth (8/10) and [`ColorInfo`] the resolved colour
|
||||
/// signalling, both from the [`Welcome`].
|
||||
type Negotiated = (
|
||||
Mode,
|
||||
CompositorPref,
|
||||
GamepadPref,
|
||||
[u8; 32],
|
||||
u32,
|
||||
i64,
|
||||
u8,
|
||||
ColorInfo,
|
||||
);
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump folds each
|
||||
/// received [`FLAG_PROBE`] access unit in; the control task records the host's [`ProbeResult`]
|
||||
/// when it lands. Read (and finalized into numbers) by [`NativeClient::probe_result`].
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
/// session's packet-level receive counters here; the control task finalizes the delivered figure
|
||||
/// and folds in the host's [`ProbeResult`] when it lands. Read by [`NativeClient::probe_result`].
|
||||
///
|
||||
/// Counting at the *packet* level (every delivered wire packet) — not whole reassembled probe AUs —
|
||||
/// is what makes the measurement degrade gracefully: once loss exceeds the FEC budget no AU
|
||||
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
|
||||
#[derive(Default)]
|
||||
struct ProbeState {
|
||||
/// A probe is in progress (set by `request_probe`, cleared by nothing — the latest one wins).
|
||||
active: bool,
|
||||
/// Probe access-unit payload bytes the client received, and their count.
|
||||
recv_bytes: u64,
|
||||
recv_packets: u32,
|
||||
/// First/last probe AU arrival — the measured receive window.
|
||||
start: Option<Instant>,
|
||||
last: Option<Instant>,
|
||||
/// The host's report ([`ProbeResult`]); present once the burst finished.
|
||||
host_bytes: u64,
|
||||
host_packets: u32,
|
||||
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
|
||||
/// tick while active) and latest, mirrored every pump iteration.
|
||||
base_packets: Option<u64>,
|
||||
base_bytes: Option<u64>,
|
||||
rx_packets_now: u64,
|
||||
rx_bytes_now: u64,
|
||||
/// Delivered wire packets / plaintext bytes (header + shard), frozen when the host's report lands
|
||||
/// (so resumed video after the burst can't inflate them).
|
||||
delivered_packets: u64,
|
||||
delivered_bytes: u64,
|
||||
/// The host's end-of-burst report.
|
||||
host_goodput_bytes: u64,
|
||||
host_au: u32,
|
||||
/// Wire packets the host actually put on the link, and the ones its send buffer dropped.
|
||||
host_wire_packets: u32,
|
||||
host_send_dropped: u32,
|
||||
/// The host's measured burst duration (the throughput denominator).
|
||||
host_duration_ms: u32,
|
||||
/// The host's `ProbeResult` arrived → the measurement is final.
|
||||
done: bool,
|
||||
}
|
||||
@@ -66,19 +91,27 @@ struct ProbeState {
|
||||
pub struct ProbeOutcome {
|
||||
/// The host's end-of-burst report has arrived — the numbers below are final.
|
||||
pub done: bool,
|
||||
/// Probe payload bytes / packets the client received.
|
||||
/// Delivered wire bytes (header + shard) / packets the client received during the burst.
|
||||
pub recv_bytes: u64,
|
||||
pub recv_packets: u32,
|
||||
/// Probe payload bytes / packets the host reported sending.
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// The client-measured receive window (first→last probe AU), in milliseconds.
|
||||
/// The burst duration the host measured, in milliseconds (the throughput denominator).
|
||||
pub elapsed_ms: u32,
|
||||
/// Measured goodput = `recv_bytes * 8 / elapsed_ms` (kilobits/second). This is the figure to
|
||||
/// drive a [`Hello::bitrate_kbps`] choice from.
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second). The figure to
|
||||
/// drive a [`Hello::bitrate_kbps`] choice from (allow headroom for the FEC overhead + loss).
|
||||
pub throughput_kbps: u32,
|
||||
/// Delivery loss = `(host_bytes - recv_bytes) / host_bytes`, as a percentage (0 if unknown).
|
||||
/// Link loss = `(wire_packets_sent − received) / wire_packets_sent`, percent. Packets the host
|
||||
/// put on the wire that didn't arrive.
|
||||
pub loss_pct: f32,
|
||||
/// Host-side drop = `send_dropped / (wire_packets_sent + send_dropped)`, percent. Packets the
|
||||
/// host's send buffer couldn't accept (raise `net.core.wmem_max` / lower the rate). Distinct
|
||||
/// from `loss_pct`: this is the host failing to keep up, not the link dropping traffic.
|
||||
pub host_drop_pct: f32,
|
||||
/// Wire packets the host put on the link and the ones its send buffer dropped (raw counts).
|
||||
pub wire_packets_sent: u32,
|
||||
pub send_dropped: u32,
|
||||
}
|
||||
|
||||
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder
|
||||
@@ -99,6 +132,10 @@ const RUMBLE_QUEUE: usize = 16;
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
@@ -118,6 +155,8 @@ pub struct NativeClient {
|
||||
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
||||
@@ -156,6 +195,13 @@ pub struct NativeClient {
|
||||
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
|
||||
/// answer, or genuinely synced clocks).
|
||||
pub clock_offset_ns: i64,
|
||||
/// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or
|
||||
/// `10` for a Main10 / HDR session. `8` for an older host that didn't report it.
|
||||
pub bit_depth: u8,
|
||||
/// The colour signalling the host encodes with ([`Welcome::color`]): the client configures its
|
||||
/// decoder/presenter from this. [`ColorInfo::SDR_BT709`] for an older host. The static HDR
|
||||
/// mastering metadata (when [`ColorInfo::is_hdr`]) arrives via [`NativeClient::next_hdr_meta`].
|
||||
pub color: ColorInfo,
|
||||
}
|
||||
|
||||
/// Pin the calling thread to the user-interactive QoS class on Apple targets.
|
||||
@@ -209,6 +255,7 @@ impl NativeClient {
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
@@ -224,6 +271,7 @@ impl NativeClient {
|
||||
let mode_slot_w = mode_slot.clone();
|
||||
let probe_w = probe.clone();
|
||||
let frames_dropped_w = frames_dropped.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
.spawn(move || {
|
||||
@@ -257,10 +305,12 @@ impl NativeClient {
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
input_rx,
|
||||
mic_rx,
|
||||
rich_input_rx,
|
||||
ctrl_rx,
|
||||
ctrl_tx: ctrl_tx_pump,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
mode_slot: mode_slot_w,
|
||||
@@ -277,6 +327,8 @@ impl NativeClient {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
@@ -291,6 +343,7 @@ impl NativeClient {
|
||||
audio: Mutex::new(audio_rx),
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
input_tx,
|
||||
mic_tx,
|
||||
rich_input_tx,
|
||||
@@ -305,6 +358,8 @@ impl NativeClient {
|
||||
resolved_gamepad,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -458,30 +513,52 @@ impl NativeClient {
|
||||
/// end-of-burst report lands). Derives goodput + loss from the accumulated probe bytes.
|
||||
pub fn probe_result(&self) -> ProbeOutcome {
|
||||
let p = self.probe.lock().unwrap();
|
||||
let elapsed_ms = match (p.start, p.last) {
|
||||
(Some(s), Some(l)) => l.duration_since(s).as_millis() as u32,
|
||||
_ => 0,
|
||||
// Delivered figures: live (rx_now − base) while the burst runs, frozen at the host's report.
|
||||
let (delivered_packets, delivered_bytes) = if p.done {
|
||||
(p.delivered_packets, p.delivered_bytes)
|
||||
} else {
|
||||
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||
(
|
||||
p.rx_packets_now.saturating_sub(base_p),
|
||||
p.rx_bytes_now.saturating_sub(base_b),
|
||||
)
|
||||
};
|
||||
// bytes × 8 / ms = kilobits/second.
|
||||
let throughput_kbps = if elapsed_ms > 0 {
|
||||
(p.recv_bytes.saturating_mul(8) / elapsed_ms as u64) as u32
|
||||
// The host's burst duration is the throughput denominator. bytes × 8 / ms = kilobits/second.
|
||||
let window_ms = p.host_duration_ms;
|
||||
let throughput_kbps = if window_ms > 0 {
|
||||
(delivered_bytes.saturating_mul(8) / window_ms as u64) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let loss_pct = if p.host_bytes > 0 {
|
||||
p.host_bytes.saturating_sub(p.recv_bytes) as f64 / p.host_bytes as f64 * 100.0
|
||||
// Link loss: wire packets the host put out that didn't arrive. Packet-level, so it degrades
|
||||
// smoothly past the FEC budget instead of cliffing to 100% the moment AUs stop completing.
|
||||
let loss_pct = if p.host_wire_packets > 0 {
|
||||
(p.host_wire_packets as i64 - delivered_packets as i64).max(0) as f64
|
||||
/ p.host_wire_packets as f64
|
||||
* 100.0
|
||||
} else {
|
||||
0.0
|
||||
} as f32;
|
||||
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
|
||||
let offered_wire = p.host_wire_packets + p.host_send_dropped;
|
||||
let host_drop_pct = if offered_wire > 0 {
|
||||
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
} as f32;
|
||||
ProbeOutcome {
|
||||
done: p.done,
|
||||
recv_bytes: p.recv_bytes,
|
||||
recv_packets: p.recv_packets,
|
||||
host_bytes: p.host_bytes,
|
||||
host_packets: p.host_packets,
|
||||
elapsed_ms,
|
||||
recv_bytes: delivered_bytes,
|
||||
recv_packets: delivered_packets as u32,
|
||||
host_bytes: p.host_goodput_bytes,
|
||||
host_packets: p.host_au,
|
||||
elapsed_ms: window_ms,
|
||||
throughput_kbps,
|
||||
loss_pct,
|
||||
host_drop_pct,
|
||||
wire_packets_sent: p.host_wire_packets,
|
||||
send_dropped: p.host_send_dropped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +610,20 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
/// mastering changes / keyframes, so an HDR presenter should drain this on its own thread and
|
||||
/// apply the latest value to the display (DXGI `SetHDRMetaData` / `CAEDRMetadata` /
|
||||
/// `KEY_HDR_STATIC_INFO`). Only an HDR session (`color.is_hdr()`, PQ) ever emits these.
|
||||
pub fn next_hdr_meta(&self, timeout: Duration) -> Result<HdrMeta> {
|
||||
match self.hdr_meta.lock().unwrap().recv_timeout(timeout) {
|
||||
Ok(m) => Ok(m),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one input event for delivery as a QUIC datagram.
|
||||
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
@@ -582,10 +673,12 @@ struct WorkerArgs {
|
||||
audio_tx: SyncSender<AudioPacket>,
|
||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||
hidout_tx: SyncSender<HidOutput>,
|
||||
hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
ctrl_rx: tokio::sync::mpsc::UnboundedReceiver<CtrlRequest>,
|
||||
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||
@@ -611,10 +704,12 @@ async fn worker_main(args: WorkerArgs) {
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
mut input_rx,
|
||||
mut mic_rx,
|
||||
mut rich_input_rx,
|
||||
mut ctrl_rx,
|
||||
ctrl_tx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
mode_slot,
|
||||
@@ -737,6 +832,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
welcome.bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
welcome.bit_depth,
|
||||
welcome.color,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -751,6 +848,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -765,6 +864,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
@@ -808,6 +909,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||
CtrlRequest::Probe(p) => p.encode(),
|
||||
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||
break;
|
||||
@@ -824,13 +926,24 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||
let mut p = probe.lock().unwrap();
|
||||
p.host_bytes = result.bytes_sent;
|
||||
p.host_packets = result.packets_sent;
|
||||
// Freeze the delivered figures now (the burst is done), before resumed
|
||||
// video can inflate the packet counters.
|
||||
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
|
||||
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
|
||||
p.host_goodput_bytes = result.bytes_sent;
|
||||
p.host_au = result.packets_sent;
|
||||
p.host_wire_packets = result.wire_packets_sent;
|
||||
p.host_send_dropped = result.send_dropped;
|
||||
p.host_duration_ms = result.duration_ms;
|
||||
p.done = true;
|
||||
tracing::info!(
|
||||
bytes_sent = result.bytes_sent,
|
||||
packets_sent = result.packets_sent,
|
||||
host_goodput_bytes = result.bytes_sent,
|
||||
wire_packets_sent = result.wire_packets_sent,
|
||||
send_dropped = result.send_dropped,
|
||||
duration_ms = result.duration_ms,
|
||||
delivered_packets = p.delivered_packets,
|
||||
"speed-test probe result"
|
||||
);
|
||||
} else {
|
||||
@@ -867,6 +980,11 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
}
|
||||
}
|
||||
_ => {} // unknown tag — a newer host; ignore
|
||||
}
|
||||
}
|
||||
@@ -890,23 +1008,45 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let pump_probe = probe.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
pin_thread_user_interactive(); // feeds frame_tx → the client's user-interactive video pump
|
||||
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
|
||||
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||
// loop. Updated every iteration (not just on a produced frame) so it stays current through
|
||||
// a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||
frames_dropped.store(session.stats().frames_dropped, Ordering::Relaxed);
|
||||
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
||||
// measurement. Updated every iteration (not just on a produced frame) so they stay current
|
||||
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||
let st = session.stats();
|
||||
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||
let probe_active = {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active && !p.done {
|
||||
p.rx_packets_now = st.packets_received;
|
||||
p.rx_bytes_now = st.bytes_received;
|
||||
p.base_packets.get_or_insert(st.packets_received);
|
||||
p.base_bytes.get_or_insert(st.bytes_received);
|
||||
}
|
||||
p.active && !p.done
|
||||
};
|
||||
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||
let loss_ppm = window_loss_ppm(
|
||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
st.packets_received.wrapping_sub(last_received),
|
||||
st.frames_dropped.wrapping_sub(last_dropped),
|
||||
);
|
||||
let _ = ctrl_tx.send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
last_report = Instant::now();
|
||||
last_recovered = st.fec_recovered_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
}
|
||||
match session.poll_frame() {
|
||||
Ok(frame) => {
|
||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active {
|
||||
let now = Instant::now();
|
||||
p.start.get_or_insert(now);
|
||||
p.last = Some(now);
|
||||
p.recv_bytes += frame.data.len() as u64;
|
||||
p.recv_packets += 1;
|
||||
}
|
||||
continue; // not video — never enqueue for the decoder
|
||||
continue; // speed-test filler, not video — measured via the counters above
|
||||
}
|
||||
let _ = frame_tx.try_send(frame);
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@ impl CompositorPref {
|
||||
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
|
||||
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
|
||||
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
|
||||
/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise
|
||||
/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte
|
||||
/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers
|
||||
/// simply omit/ignore it.
|
||||
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -148,15 +148,24 @@ pub enum GamepadPref {
|
||||
Xbox360,
|
||||
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
|
||||
DualSense,
|
||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity
|
||||
/// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger
|
||||
/// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`).
|
||||
XboxOne,
|
||||
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
|
||||
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
||||
DualShock4,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`.
|
||||
pub fn to_u8(self) -> u8 {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
GamepadPref::Xbox360 => 1,
|
||||
GamepadPref::DualSense => 2,
|
||||
GamepadPref::XboxOne => 3,
|
||||
GamepadPref::DualShock4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +175,8 @@ impl GamepadPref {
|
||||
match v {
|
||||
1 => GamepadPref::Xbox360,
|
||||
2 => GamepadPref::DualSense,
|
||||
3 => GamepadPref::XboxOne,
|
||||
4 => GamepadPref::DualShock4,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -177,16 +188,23 @@ impl GamepadPref {
|
||||
"auto" | "default" => GamepadPref::Auto,
|
||||
"xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360,
|
||||
"dualsense" | "ds" | "ps5" => GamepadPref::DualSense,
|
||||
"xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => {
|
||||
GamepadPref::XboxOne
|
||||
}
|
||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`).
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
GamepadPref::Xbox360 => "xbox360",
|
||||
GamepadPref::DualSense => "dualsense",
|
||||
GamepadPref::XboxOne => "xboxone",
|
||||
GamepadPref::DualShock4 => "dualshock4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,18 @@ impl Packetizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live-adjust the FEC recovery percentage (adaptive FEC). Takes effect on the next
|
||||
/// [`packetize`](Self::packetize); the wire is self-describing (each packet carries its block's
|
||||
/// data/recovery counts), so the receiver needs no notification. Clamped to ≤ 90.
|
||||
pub fn set_fec_percent(&mut self, pct: u8) {
|
||||
self.fec.fec_percent = pct.min(90);
|
||||
}
|
||||
|
||||
/// The current FEC recovery percentage.
|
||||
pub fn fec_percent(&self) -> u8 {
|
||||
self.fec.fec_percent
|
||||
}
|
||||
|
||||
/// Packetize one access unit into wire packets (header + shard payload each).
|
||||
pub fn packetize(
|
||||
&mut self,
|
||||
@@ -284,8 +296,9 @@ impl Reassembler {
|
||||
stats: &StatsCounters,
|
||||
) -> Result<Option<Frame>> {
|
||||
// On a lossy datagram link a malformed or non-video packet is dropped, never
|
||||
// fatal: it must not abort `poll_frame`. Only a genuine FEC reconstruction
|
||||
// failure propagates as an error.
|
||||
// fatal: it must not abort `poll_frame`. A FEC reconstruction failure (corrupt or
|
||||
// incompatible shards that passed the header checks) likewise drops the block rather
|
||||
// than killing the whole session — the stream recovers at the next keyframe/RFI.
|
||||
if pkt.len() < HEADER_LEN {
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
return Ok(None);
|
||||
@@ -395,8 +408,22 @@ impl Reassembler {
|
||||
.iter()
|
||||
.filter(|s| s.is_some())
|
||||
.count();
|
||||
let recovered =
|
||||
coder.reconstruct(block.data_shards, block.recovery_shards, &mut block.shards)?;
|
||||
let recovered = match coder.reconstruct(
|
||||
block.data_shards,
|
||||
block.recovery_shards,
|
||||
&mut block.shards,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// Corrupt/incompatible shards that slipped past the header checks: discard this
|
||||
// block (mark done so later shards for it are ignored) and keep the session
|
||||
// alive — a lossy link must not be torn down by one unrecoverable block; the
|
||||
// frame stays incomplete and the client recovers at the next keyframe/RFI.
|
||||
block.done = true;
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
block.done = true;
|
||||
StatsCounters::add(
|
||||
&stats.fec_recovered_shards,
|
||||
|
||||
@@ -85,6 +85,72 @@ pub const VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
pub const VIDEO_CAP_HDR: u8 = 0x02;
|
||||
|
||||
/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the
|
||||
/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these
|
||||
/// instead of inferring them from the bitstream VUI. An older host omits the bytes on the wire →
|
||||
/// [`ColorInfo::SDR_BT709`] (the 8-bit BT.709 limited stream every pre-HDR build produced).
|
||||
///
|
||||
/// The *static* HDR mastering metadata (ST.2086 + content light level) is larger and can change
|
||||
/// mid-stream, so it rides the [`HDR_META_MAGIC`] datagram rather than this fixed struct.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColorInfo {
|
||||
/// CICP colour primaries: 1 = BT.709, 9 = BT.2020.
|
||||
pub primaries: u8,
|
||||
/// CICP transfer characteristics: 1 = BT.709, 16 = PQ (SMPTE ST.2084), 18 = HLG.
|
||||
pub transfer: u8,
|
||||
/// CICP matrix coefficients: 1 = BT.709, 9 = BT.2020 non-constant-luminance.
|
||||
pub matrix: u8,
|
||||
/// `video_full_range_flag`: 0 = limited/studio range, 1 = full range.
|
||||
pub full_range: u8,
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
/// CICP colour-primaries code point: BT.709.
|
||||
pub const CP_BT709: u8 = 1;
|
||||
/// CICP colour-primaries code point: BT.2020.
|
||||
pub const CP_BT2020: u8 = 9;
|
||||
/// CICP transfer code point: BT.709.
|
||||
pub const TRC_BT709: u8 = 1;
|
||||
/// CICP transfer code point: PQ (SMPTE ST.2084).
|
||||
pub const TRC_PQ: u8 = 16;
|
||||
/// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100).
|
||||
pub const TRC_HLG: u8 = 18;
|
||||
/// CICP matrix code point: BT.709.
|
||||
pub const MC_BT709: u8 = 1;
|
||||
/// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance —
|
||||
/// no client decodes it.)
|
||||
pub const MC_BT2020_NCL: u8 = 9;
|
||||
|
||||
/// 8-bit BT.709 limited-range SDR — what every pre-HDR build produced, and the back-compat
|
||||
/// default when a peer omits the colour bytes.
|
||||
pub const SDR_BT709: ColorInfo = ColorInfo {
|
||||
primaries: Self::CP_BT709,
|
||||
transfer: Self::TRC_BT709,
|
||||
matrix: Self::MC_BT709,
|
||||
full_range: 0,
|
||||
};
|
||||
|
||||
/// BT.2020 PQ (HDR10), limited range — what the Windows host's HEVC VUI emits.
|
||||
pub const HDR10_BT2020_PQ: ColorInfo = ColorInfo {
|
||||
primaries: Self::CP_BT2020,
|
||||
transfer: Self::TRC_PQ,
|
||||
matrix: Self::MC_BT2020_NCL,
|
||||
full_range: 0,
|
||||
};
|
||||
|
||||
/// True when the transfer is an HDR curve (PQ or HLG): the stream needs HDR present, and
|
||||
/// (for PQ) a [`HdrMeta`] datagram carries the mastering metadata.
|
||||
pub fn is_hdr(&self) -> bool {
|
||||
self.transfer == Self::TRC_PQ || self.transfer == Self::TRC_HLG
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ColorInfo {
|
||||
fn default() -> Self {
|
||||
Self::SDR_BT709
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||
pub const HELLO_NAME_MAX: usize = 64;
|
||||
@@ -124,9 +190,14 @@ pub struct Welcome {
|
||||
/// The luma/chroma bit depth the host actually encodes at — `8` (default / older host) or
|
||||
/// `10` (Main10, enabled only when the client advertised [`VIDEO_CAP_10BIT`]). The client
|
||||
/// configures its decoder for 10-bit (P010) when this is `10`. Appended to the wire form as a
|
||||
/// single trailing byte; `8` when an older host omitted it. (Color space stays BT.709 in
|
||||
/// Phase 1; BT.2020 PQ HDR signaling is added alongside HDR support.)
|
||||
/// single trailing byte; `8` when an older host omitted it.
|
||||
pub bit_depth: u8,
|
||||
/// The colour signalling (CICP primaries/transfer/matrix/range) the host encodes with — BT.709
|
||||
/// limited SDR by default, BT.2020 PQ when a 10-bit HDR session was negotiated. Appended after
|
||||
/// `bit_depth` as 4 trailing bytes; an older host that omits them decodes to
|
||||
/// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of
|
||||
/// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`].
|
||||
pub color: ColorInfo,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -167,6 +238,18 @@ pub struct Reconfigured {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RequestKeyframe;
|
||||
|
||||
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
|
||||
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
|
||||
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
|
||||
/// window — i.e. the loss FEC is currently absorbing. The host maps it to a recovery percentage,
|
||||
/// clamped to a sane band, and applies it live; a clean link decays toward the floor (fewer packets,
|
||||
/// which directly helps a packet-rate-bound uplink like the Steam Deck's WiFi tx). Fire-and-forget.
|
||||
/// A host that predates this ignores it (unknown control message) and keeps its static FEC.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LossReport {
|
||||
pub loss_ppm: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
|
||||
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
|
||||
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
|
||||
@@ -181,17 +264,30 @@ pub struct ProbeRequest {
|
||||
pub duration_ms: u32,
|
||||
}
|
||||
|
||||
/// `host → client`: the probe burst is finished. Reports what the host actually sent so the
|
||||
/// client can compute delivery ratio (loss) = `received / bytes_sent` and throughput =
|
||||
/// `received_bytes * 8 / elapsed`.
|
||||
/// `host → client`: the probe burst is finished. Reports what the host actually put on the wire so
|
||||
/// the client can split the two failure modes apart: **host-side** drops (the send buffer couldn't
|
||||
/// keep up — raise `net.core.wmem_max`) vs **link** loss (wire packets the air dropped). The client
|
||||
/// measures delivered wire packets itself and computes:
|
||||
///
|
||||
/// - link loss = `(wire_packets_sent − received) / wire_packets_sent`
|
||||
/// - host drop = `send_dropped / (wire_packets_sent + send_dropped)`
|
||||
/// - throughput = `received_wire_bytes * 8 / duration_ms`
|
||||
///
|
||||
/// Counting delivered traffic at the *packet* level (not whole reassembled AUs) makes the figure
|
||||
/// degrade gracefully past the FEC budget instead of cliffing to zero.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ProbeResult {
|
||||
/// Total access-unit payload bytes the host emitted for the probe.
|
||||
/// Total access-unit payload bytes the host emitted for the probe (application goodput offered).
|
||||
pub bytes_sent: u64,
|
||||
/// Number of probe access units the host emitted.
|
||||
pub packets_sent: u32,
|
||||
/// The burst's actual duration in milliseconds (the host clamps/measures the request).
|
||||
pub duration_ms: u32,
|
||||
/// Wire packets the kernel ACCEPTED for transmission — what actually went on the link (offered
|
||||
/// minus the send-buffer drops below). `0` from a pre-wire-stats host (back-compat decode).
|
||||
pub wire_packets_sent: u32,
|
||||
/// Wire packets the host could NOT hand to the kernel (send buffer full): the host-side ceiling.
|
||||
pub send_dropped: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, right after [`Start`]: one round of the wall-clock skew handshake. The client
|
||||
@@ -238,6 +334,8 @@ pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||
pub const MSG_RECONFIGURED: u8 = 0x02;
|
||||
/// Type byte of [`RequestKeyframe`].
|
||||
pub const MSG_REQUEST_KEYFRAME: u8 = 0x03;
|
||||
/// Type byte of [`LossReport`].
|
||||
pub const MSG_LOSS_REPORT: u8 = 0x04;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -644,6 +742,11 @@ impl Welcome {
|
||||
b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline
|
||||
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 55..59
|
||||
b.push(self.bit_depth); // appended at offset 59 — older clients read [0..59] and skip it
|
||||
// Colour signalling at offsets 60..64 — older clients stop before these → SDR BT.709.
|
||||
b.push(self.color.primaries);
|
||||
b.push(self.color.transfer);
|
||||
b.push(self.color.matrix);
|
||||
b.push(self.color.full_range);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -651,7 +754,8 @@ impl Welcome {
|
||||
// Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22]
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] (compositor/gamepad/bitrate/bit_depth are optional trailing bytes).
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// (everything from compositor on is an optional trailing byte; an older host stops earlier).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -701,6 +805,13 @@ impl Welcome {
|
||||
// Optional trailing byte — absent on an older host → `8` (8-bit, the only depth they
|
||||
// encode).
|
||||
bit_depth: b.get(59).copied().unwrap_or(8),
|
||||
// Optional trailing colour bytes — absent on an older host → SDR BT.709 limited.
|
||||
color: ColorInfo {
|
||||
primaries: b.get(60).copied().unwrap_or(ColorInfo::CP_BT709),
|
||||
transfer: b.get(61).copied().unwrap_or(ColorInfo::TRC_BT709),
|
||||
matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709),
|
||||
full_range: b.get(63).copied().unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -808,6 +919,43 @@ impl RequestKeyframe {
|
||||
}
|
||||
}
|
||||
|
||||
impl LossReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] loss_ppm[5..9]
|
||||
let mut b = Vec::with_capacity(9);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_LOSS_REPORT);
|
||||
b.extend_from_slice(&self.loss_ppm.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<LossReport> {
|
||||
if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_LOSS_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad LossReport"));
|
||||
}
|
||||
Ok(LossReport {
|
||||
loss_ppm: u32::from_le_bytes(b[5..9].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered
|
||||
/// (the loss it absorbed), shards received, and frames that went unrecoverable. Loss ≈ recovered /
|
||||
/// (received + recovered) — the fraction of shards that arrived missing. A frame drop means loss
|
||||
/// exceeded the current FEC budget (so `recovered` plateaus), so add a fixed bump to push the host's
|
||||
/// FEC up past the cap on the next adjustment. Returns parts-per-million, capped at 1e6.
|
||||
pub fn window_loss_ppm(recovered: u64, received: u64, frames_dropped: u64) -> u32 {
|
||||
let denom = received.saturating_add(recovered);
|
||||
let mut ppm = recovered
|
||||
.saturating_mul(1_000_000)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0) as u32;
|
||||
if frames_dropped > 0 {
|
||||
ppm = ppm.saturating_add(50_000); // +5%: unrecoverable loss → raise FEC past the current cap
|
||||
}
|
||||
ppm.min(1_000_000)
|
||||
}
|
||||
|
||||
impl ProbeRequest {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13]
|
||||
@@ -834,23 +982,36 @@ impl ProbeRequest {
|
||||
impl ProbeResult {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] bytes_sent[5..13] packets_sent[13..17] duration_ms[17..21]
|
||||
let mut b = Vec::with_capacity(21);
|
||||
// wire_packets_sent[21..25] send_dropped[25..29]
|
||||
let mut b = Vec::with_capacity(29);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_PROBE_RESULT);
|
||||
b.extend_from_slice(&self.bytes_sent.to_le_bytes());
|
||||
b.extend_from_slice(&self.packets_sent.to_le_bytes());
|
||||
b.extend_from_slice(&self.duration_ms.to_le_bytes());
|
||||
b.extend_from_slice(&self.wire_packets_sent.to_le_bytes());
|
||||
b.extend_from_slice(&self.send_dropped.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ProbeResult> {
|
||||
if b.len() != 21 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_RESULT {
|
||||
// Back-compat: 21 bytes (pre-wire-stats host, new fields default 0) or 29 bytes (with the
|
||||
// wire_packets_sent + send_dropped tail). Accept either; reject anything shorter/garbled.
|
||||
if b.len() < 21 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_RESULT {
|
||||
return Err(PunktfunkError::InvalidArg("bad ProbeResult"));
|
||||
}
|
||||
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
let (wire_packets_sent, send_dropped) = if b.len() >= 29 {
|
||||
(u32at(21), u32at(25))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
Ok(ProbeResult {
|
||||
bytes_sent: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
packets_sent: u32::from_le_bytes(b[13..17].try_into().unwrap()),
|
||||
duration_ms: u32::from_le_bytes(b[17..21].try_into().unwrap()),
|
||||
packets_sent: u32at(13),
|
||||
duration_ms: u32at(17),
|
||||
wire_packets_sent,
|
||||
send_dropped,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -911,7 +1072,8 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client).
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -1126,6 +1288,79 @@ impl HidOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
|
||||
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
|
||||
/// is larger and can change mid-stream when the source's mastering intent changes; the host
|
||||
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
|
||||
/// for HLG (scene-referred — no mastering metadata).
|
||||
///
|
||||
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
|
||||
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
|
||||
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct HdrMeta {
|
||||
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
|
||||
/// is G, B, R).
|
||||
pub display_primaries: [[u16; 2]; 3],
|
||||
/// White point (x, y) in 1/50000 units.
|
||||
pub white_point: [u16; 2],
|
||||
/// Max display mastering luminance, 0.0001 cd/m² units.
|
||||
pub max_display_mastering_luminance: u32,
|
||||
/// Min display mastering luminance, 0.0001 cd/m² units.
|
||||
pub min_display_mastering_luminance: u32,
|
||||
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
|
||||
pub max_cll: u16,
|
||||
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
|
||||
pub max_fall: u16,
|
||||
}
|
||||
|
||||
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||
pub const HDR_META_MAGIC: u8 = 0xCE;
|
||||
|
||||
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32
|
||||
/// luminance + 2×u16 CLL/FALL = 29 bytes.
|
||||
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4;
|
||||
|
||||
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
|
||||
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(HDR_META_LEN);
|
||||
b.push(HDR_META_MAGIC);
|
||||
for p in m.display_primaries.iter() {
|
||||
b.extend_from_slice(&p[0].to_le_bytes());
|
||||
b.extend_from_slice(&p[1].to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(&m.white_point[0].to_le_bytes());
|
||||
b.extend_from_slice(&m.white_point[1].to_le_bytes());
|
||||
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
|
||||
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
|
||||
b.extend_from_slice(&m.max_cll.to_le_bytes());
|
||||
b.extend_from_slice(&m.max_fall.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
|
||||
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
|
||||
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
|
||||
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
Some(HdrMeta {
|
||||
display_primaries: [
|
||||
[u16at(1), u16at(3)],
|
||||
[u16at(5), u16at(7)],
|
||||
[u16at(9), u16at(11)],
|
||||
],
|
||||
white_point: [u16at(13), u16at(15)],
|
||||
max_display_mastering_luminance: u32at(17),
|
||||
min_display_mastering_luminance: u32at(21),
|
||||
max_cll: u16at(25),
|
||||
max_fall: u16at(27),
|
||||
})
|
||||
}
|
||||
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io {
|
||||
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||
@@ -1219,11 +1454,16 @@ pub mod endpoint {
|
||||
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||
use std::time::Duration;
|
||||
const MAX_IDLE: Duration = Duration::from_secs(20);
|
||||
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
|
||||
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
|
||||
// a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the
|
||||
// still-lingering old session. Active sessions are unaffected: video keeps the connection live,
|
||||
// and the 4s keep-alive holds it open through quiet control periods.
|
||||
const MAX_IDLE: Duration = Duration::from_secs(8);
|
||||
const KEEP_ALIVE: Duration = Duration::from_secs(4);
|
||||
let mut t = quinn::TransportConfig::default();
|
||||
t.max_idle_timeout(Some(
|
||||
quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"),
|
||||
quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"),
|
||||
));
|
||||
t.keep_alive_interval(Some(KEEP_ALIVE));
|
||||
Arc::new(t)
|
||||
@@ -1255,6 +1495,12 @@ pub mod endpoint {
|
||||
server_from_der(cert_der, key_der, addr)
|
||||
}
|
||||
|
||||
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
||||
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
|
||||
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
|
||||
/// together (acceptable while the protocol/ABI is still evolving).
|
||||
const QUIC_ALPN: &[u8] = b"pkf1";
|
||||
|
||||
fn server_from_der(
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
@@ -1265,10 +1511,11 @@ pub mod endpoint {
|
||||
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||
// one that presents none still connects (and is rejected at the app layer when
|
||||
// pairing is required).
|
||||
let rustls_cfg = rustls::ServerConfig::builder()
|
||||
let mut rustls_cfg = rustls::ServerConfig::builder()
|
||||
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||
.with_single_cert(vec![cert_der], key_der)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||
@@ -1345,7 +1592,7 @@ pub mod endpoint {
|
||||
pin,
|
||||
observed: observed.clone(),
|
||||
}));
|
||||
let rustls_cfg = match identity {
|
||||
let mut rustls_cfg = match identity {
|
||||
None => builder.with_no_client_auth(),
|
||||
Some((cert_pem, key_pem)) => {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
@@ -1361,6 +1608,8 @@ pub mod endpoint {
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
||||
}
|
||||
};
|
||||
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
|
||||
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||
@@ -1559,10 +1808,34 @@ mod tests {
|
||||
gamepad: GamepadPref::DualSense,
|
||||
bitrate_kbps: 50_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_meta_datagram_roundtrip_and_truncation() {
|
||||
let m = HdrMeta {
|
||||
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
|
||||
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||
white_point: [15635, 16450], // D65
|
||||
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
|
||||
min_display_mastering_luminance: 1, // 0.0001 nits
|
||||
max_cll: 1000,
|
||||
max_fall: 400,
|
||||
};
|
||||
let d = encode_hdr_meta_datagram(&m);
|
||||
assert_eq!(d[0], HDR_META_MAGIC);
|
||||
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
|
||||
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||
for n in 0..d.len() {
|
||||
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
|
||||
}
|
||||
let mut bad = d.clone();
|
||||
bad[0] = HIDOUT_MAGIC;
|
||||
assert_eq!(decode_hdr_meta_datagram(&bad), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_start_roundtrip() {
|
||||
let h = Hello {
|
||||
@@ -1615,13 +1888,25 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
GamepadPref::Xbox360,
|
||||
GamepadPref::DualSense,
|
||||
GamepadPref::XboxOne,
|
||||
GamepadPref::DualShock4,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
||||
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
||||
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
);
|
||||
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
|
||||
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||
@@ -1683,9 +1968,10 @@ mod tests {
|
||||
gamepad: GamepadPref::Xbox360,
|
||||
bitrate_kbps: 120_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 60);
|
||||
assert_eq!(wenc.len(), 64); // 60 base + 4 colour bytes
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
@@ -1701,8 +1987,17 @@ mod tests {
|
||||
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
|
||||
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
|
||||
assert_eq!(legacy_w.bit_depth, 8);
|
||||
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
|
||||
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||
assert_eq!(pre_color_w.bit_depth, 10);
|
||||
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().color,
|
||||
ColorInfo::HDR10_BT2020_PQ
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1851,6 +2146,35 @@ mod tests {
|
||||
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_report_roundtrip() {
|
||||
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||
let r = LossReport { loss_ppm };
|
||||
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
// Disjoint from the other control messages (type byte + length).
|
||||
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||
assert!(LossReport::decode(
|
||||
&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
assert_eq!(window_loss_ppm(0, 0, 0), 0);
|
||||
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
|
||||
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
|
||||
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
|
||||
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
|
||||
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_messages_roundtrip() {
|
||||
let req = ProbeRequest {
|
||||
@@ -1862,8 +2186,20 @@ mod tests {
|
||||
bytes_sent: 62_500_000,
|
||||
packets_sent: 480,
|
||||
duration_ms: 2003,
|
||||
wire_packets_sent: 41_000,
|
||||
send_dropped: 1_200,
|
||||
};
|
||||
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
|
||||
assert_eq!(res.encode().len(), 29);
|
||||
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
|
||||
let legacy = {
|
||||
let full = res.encode();
|
||||
full[..21].to_vec()
|
||||
};
|
||||
let decoded = ProbeResult::decode(&legacy).unwrap();
|
||||
assert_eq!(decoded.wire_packets_sent, 0);
|
||||
assert_eq!(decoded.send_dropped, 0);
|
||||
assert_eq!(decoded.bytes_sent, res.bytes_sent);
|
||||
// Type bytes keep the control messages disjoint from each other.
|
||||
assert!(ProbeRequest::decode(&res.encode()).is_err());
|
||||
assert!(Reconfigure::decode(&req.encode()).is_err());
|
||||
|
||||
@@ -201,6 +201,18 @@ impl Session {
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Host: live-adjust the FEC recovery percentage (adaptive FEC). Affects the next
|
||||
/// [`submit_frame`](Self::submit_frame)/[`seal_frame`](Self::seal_frame); the receiver needs no
|
||||
/// notification (each packet's header carries its block's data/recovery shard counts).
|
||||
pub fn set_fec_percent(&mut self, pct: u8) {
|
||||
self.packetizer.set_fec_percent(pct);
|
||||
}
|
||||
|
||||
/// The current FEC recovery percentage (host side).
|
||||
pub fn fec_percent(&self) -> u8 {
|
||||
self.packetizer.fec_percent()
|
||||
}
|
||||
|
||||
/// Host: drain one pending input event from the client, if any.
|
||||
pub fn poll_input(&mut self) -> Result<Option<InputEvent>> {
|
||||
if self.config.role != Role::Host {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
//! directly — no async runtime is involved.
|
||||
|
||||
mod loopback;
|
||||
mod qos;
|
||||
mod udp;
|
||||
|
||||
pub use loopback::{loopback_pair, LoopbackTransport};
|
||||
pub use qos::{grow_socket_buffers, set_media_qos, MediaClass};
|
||||
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
|
||||
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Shared UDP socket tuning for the media planes: send/recv buffer growth + best-effort link-layer
|
||||
//! QoS.
|
||||
//!
|
||||
//! [`grow_socket_buffers`] is the `SO_SNDBUF`/`SO_RCVBUF` growth the native data plane applies; the
|
||||
//! GameStream video/audio sockets reuse it so they don't go ENOBUFS-bound at high bitrate.
|
||||
//!
|
||||
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
|
||||
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
|
||||
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
|
||||
//! is **opt-in** (`PUNKTFUNK_DSCP=1`): DSCP can interact badly with some consumer ISPs/routers, and on
|
||||
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the
|
||||
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows).
|
||||
|
||||
use std::net::UdpSocket;
|
||||
|
||||
/// Target kernel socket-buffer size (`SO_SNDBUF`/`SO_RCVBUF`). A high-resolution frame is a burst (a
|
||||
/// 5120×1440 keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default UDP
|
||||
/// buffer (~208 KB on Linux) overflows on it, which EAGAINs the host send (dropping packets) or drops
|
||||
/// on the client recv — and with infinite-GOP a single lost frame freezes the decode until the next
|
||||
/// RFI refresh. Requested large; the OS clamps to `net.core.{wmem,rmem}_max` (Linux) /
|
||||
/// `kern.ipc.maxsockbuf` (macOS).
|
||||
///
|
||||
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state, and a
|
||||
/// single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB gives ~200 ms
|
||||
/// of headroom and absorbs a keyframe burst without EAGAIN/ENOBUFS drops. (Paced sending —
|
||||
/// `punktfunk1.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
|
||||
/// immediate microburst rather than a whole unpaced frame.)
|
||||
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Best-effort grow of `SO_SNDBUF`/`SO_RCVBUF` to [`TARGET_SOCKBUF`]. A failure isn't fatal (the
|
||||
/// stream just runs lossier); a grant far below the request means the OS cap is too low for clean
|
||||
/// 4K/5K streaming, so warn with the knob to raise.
|
||||
pub fn grow_socket_buffers(socket: &UdpSocket) {
|
||||
let sock = socket2::SockRef::from(socket);
|
||||
let _ = sock.set_send_buffer_size(TARGET_SOCKBUF);
|
||||
let _ = sock.set_recv_buffer_size(TARGET_SOCKBUF);
|
||||
// The kernel reports back the (possibly clamped, Linux-doubled) granted size.
|
||||
let granted = sock
|
||||
.send_buffer_size()
|
||||
.unwrap_or(0)
|
||||
.min(sock.recv_buffer_size().unwrap_or(0));
|
||||
if granted < TARGET_SOCKBUF / 4 {
|
||||
tracing::warn!(
|
||||
granted_kb = granted / 1024,
|
||||
"UDP socket buffer capped well below target — high-resolution streaming may drop \
|
||||
frames; raise net.core.wmem_max / net.core.rmem_max (Linux) for clean 4K/5K"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Media class of a socket — selects the DSCP code point (and Linux `SO_PRIORITY`), matching Apollo's
|
||||
/// mapping: video = CS5, audio = CS6.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MediaClass {
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
impl MediaClass {
|
||||
/// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte).
|
||||
const fn dscp(self) -> u32 {
|
||||
match self {
|
||||
MediaClass::Video => 40, // CS5
|
||||
MediaClass::Audio => 48, // CS6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether DSCP/QoS marking is enabled (`PUNKTFUNK_DSCP=1`). Off by default.
|
||||
pub(crate) fn dscp_enabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_DSCP").as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("on")
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
|
||||
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
|
||||
/// is a nicety, not required for correctness.
|
||||
///
|
||||
/// IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't tagged. On Windows
|
||||
/// the `IP_TOS` set succeeds but the OS doesn't tag the wire without a qWAVE policy (follow-up).
|
||||
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) {
|
||||
if dscp_enabled() {
|
||||
apply_media_qos(socket, class);
|
||||
}
|
||||
}
|
||||
|
||||
/// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable
|
||||
/// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues).
|
||||
fn apply_media_qos(socket: &UdpSocket, class: MediaClass) {
|
||||
let sock = socket2::SockRef::from(socket);
|
||||
// DSCP occupies the high 6 bits of the TOS byte → shift left 2.
|
||||
if let Err(e) = sock.set_tos_v4(class.dscp() << 2) {
|
||||
tracing::debug!(error = %e, ?class, "set IP_TOS (DSCP) failed — QoS marking skipped");
|
||||
}
|
||||
// SO_PRIORITY must be set AFTER IP_TOS (setting TOS resets SO_PRIORITY to 0 on Linux). Linux-only;
|
||||
// 6 is the highest priority allowed without CAP_NET_ADMIN, so video=5 / audio=6 (Apollo's scheme).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let prio = match class {
|
||||
MediaClass::Video => 5,
|
||||
MediaClass::Audio => 6,
|
||||
};
|
||||
if let Err(e) = sock.set_priority(prio) {
|
||||
tracing::debug!(error = %e, "set SO_PRIORITY failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dscp_code_points_match_apollo() {
|
||||
// CS5 video / CS6 audio, shifted into the TOS byte (high 6 bits).
|
||||
assert_eq!(MediaClass::Video.dscp(), 40);
|
||||
assert_eq!(MediaClass::Audio.dscp(), 48);
|
||||
assert_eq!(MediaClass::Video.dscp() << 2, 0xA0);
|
||||
assert_eq!(MediaClass::Audio.dscp() << 2, 0xC0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
|
||||
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
|
||||
set_media_qos(&sock, MediaClass::Video);
|
||||
set_media_qos(&sock, MediaClass::Audio);
|
||||
grow_socket_buffers(&sock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_qos_tags_the_socket() {
|
||||
// Exercise the enabled path directly (no env), and read the options back where we can.
|
||||
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
apply_media_qos(&sock, MediaClass::Video);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let s = socket2::SockRef::from(&sock);
|
||||
assert_eq!(s.tos_v4().unwrap(), 0xA0, "video → CS5 in the TOS byte");
|
||||
assert_eq!(s.priority().unwrap(), 5, "video → SO_PRIORITY 5");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,35 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
|
||||
/// so erroring out here kills a stream that the very next packet would resume. If the peer is
|
||||
/// genuinely gone, the QUIC control plane times out and ends the session cleanly instead. (This is
|
||||
/// the classic connected-UDP "ICMP errors are advisory" rule, doubly true with hole-punching.)
|
||||
/// - `ENOBUFS`: a WiFi/wlan driver (e.g. `ath11k` on the Steam Deck) returns this — NOT `EAGAIN`/
|
||||
/// `WouldBlock` — when its tx queue is momentarily full. Rust maps `ENOBUFS` to
|
||||
/// `ErrorKind::Uncategorized`, so the `WouldBlock` arm misses it; without this a transient
|
||||
/// tx-queue burst tears the whole stream down (observed live: the host streamed flawlessly on
|
||||
/// loopback / under a debugger — anything slow enough not to fill the small wlan0 buffer — but
|
||||
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
|
||||
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||
/// are droppable for the same reason a stale ICMP is.
|
||||
fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||
matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset)
|
||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||
return true;
|
||||
}
|
||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
matches!(
|
||||
e.raw_os_error(),
|
||||
Some(libc::ENOBUFS)
|
||||
| Some(libc::ENETUNREACH)
|
||||
| Some(libc::EHOSTUNREACH)
|
||||
| Some(libc::ENETDOWN)
|
||||
| Some(libc::EHOSTDOWN)
|
||||
)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
|
||||
@@ -387,26 +413,15 @@ pub struct UdpTransport {
|
||||
}
|
||||
|
||||
impl UdpTransport {
|
||||
/// Target kernel socket-buffer size. A high-resolution frame is a burst (a 5120×1440
|
||||
/// keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default
|
||||
/// UDP buffer (~208 KB on Linux) overflows on it, which EAGAINs the host send (dropping
|
||||
/// packets) or drops on the client recv — and with infinite-GOP a single lost frame
|
||||
/// freezes the decode until the next RFI refresh. Requested large; the OS clamps to
|
||||
/// `net.core.{wmem,rmem}_max` (Linux) / `kern.ipc.maxsockbuf` (macOS).
|
||||
///
|
||||
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state,
|
||||
/// and a single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB
|
||||
/// gives ~200 ms of headroom and absorbs a keyframe burst without EAGAIN drops. (Paced sending
|
||||
/// — `punktfunk1.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
|
||||
/// the immediate microburst rather than a whole unpaced frame.)
|
||||
const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
|
||||
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
|
||||
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
|
||||
let socket = UdpSocket::bind(local)?;
|
||||
socket.connect(peer)?;
|
||||
Self::grow_buffers(&socket);
|
||||
super::qos::grow_socket_buffers(&socket);
|
||||
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
||||
// PUNKTFUNK_DSCP). Each end marks its own egress.
|
||||
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(UdpTransport { socket })
|
||||
}
|
||||
@@ -455,7 +470,8 @@ impl UdpTransport {
|
||||
let target = observed.map(|s| s.to_string());
|
||||
socket.connect(target.as_deref().unwrap_or(fallback_peer))?;
|
||||
socket.set_read_timeout(None)?;
|
||||
Self::grow_buffers(&socket);
|
||||
super::qos::grow_socket_buffers(&socket);
|
||||
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok((UdpTransport { socket }, punched))
|
||||
}
|
||||
@@ -472,27 +488,6 @@ impl UdpTransport {
|
||||
self.socket.local_addr()
|
||||
}
|
||||
|
||||
/// Best-effort grow of SO_SNDBUF/SO_RCVBUF (see [`TARGET_SOCKBUF`]). A failure isn't fatal
|
||||
/// (the stream just runs lossier); a grant far below the request means the OS cap is too
|
||||
/// low for clean 4K/5K streaming, so warn once with the knob to raise.
|
||||
fn grow_buffers(socket: &UdpSocket) {
|
||||
let sock = socket2::SockRef::from(socket);
|
||||
let _ = sock.set_send_buffer_size(Self::TARGET_SOCKBUF);
|
||||
let _ = sock.set_recv_buffer_size(Self::TARGET_SOCKBUF);
|
||||
// The kernel reports back the (possibly clamped, Linux-doubled) granted size.
|
||||
let granted = sock
|
||||
.send_buffer_size()
|
||||
.unwrap_or(0)
|
||||
.min(sock.recv_buffer_size().unwrap_or(0));
|
||||
if granted < Self::TARGET_SOCKBUF / 4 {
|
||||
tracing::warn!(
|
||||
granted_kb = granted / 1024,
|
||||
"UDP socket buffer capped well below target — high-resolution streaming may drop \
|
||||
frames; raise net.core.wmem_max / net.core.rmem_max (Linux) for clean 4K/5K"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple batched receive via `recvmsg_x` — drains up to `out.len()` datagrams in one syscall into
|
||||
/// the caller's reused buffers (the recv counterpart of Linux `recvmmsg`, which Darwin lacks).
|
||||
/// SAFETY: each `MsghdrX` holds a raw pointer into `iovs`, which holds raw pointers into `out`'s
|
||||
|
||||
@@ -25,6 +25,14 @@ aes-gcm = "0.10"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
|
||||
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
|
||||
base64 = "0.22"
|
||||
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
|
||||
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
|
||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
@@ -85,6 +93,10 @@ wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
||||
serde_json = "1"
|
||||
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
||||
@@ -116,7 +128,13 @@ libloading = "0.8"
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
# ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense
|
||||
# shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it.
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Devices_DeviceAndDriverInstallation",
|
||||
# SwDeviceCreate/SwDeviceClose — the per-session virtual-DualSense devnode
|
||||
# (inject/dualsense_windows.rs).
|
||||
"Win32_Devices_Enumeration_Pnp",
|
||||
"Win32_Devices_Display",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_IO",
|
||||
@@ -149,7 +167,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
|
||||
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
|
||||
# See capture/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||
"Win32_System_Memory",
|
||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||
@@ -163,21 +181,37 @@ windows = { version = "0.62", features = [
|
||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||
# the `windows` crate above.
|
||||
windows-service = "0.7"
|
||||
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
|
||||
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
|
||||
winreg = "0.56"
|
||||
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
|
||||
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
|
||||
roxmltree = "0.21"
|
||||
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
|
||||
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
|
||||
openh264 = "0.9"
|
||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||
wasapi = "0.23"
|
||||
# Virtual Xbox 360 gamepad via ViGEmBus (the uinput-xpad analogue) — driver installed separately.
|
||||
# `unstable_xtarget_notification` exposes the rumble/LED back-channel (the game's force-feedback →
|
||||
# `request_notification`), the analogue of the Linux uinput EV_FF read path.
|
||||
vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] }
|
||||
# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver),
|
||||
# driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency.
|
||||
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
|
||||
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
|
||||
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
|
||||
# the crate builds on docs.rs/CI. We enable it so the GPU-less VM/CI compiles; the DirectX NVENC path
|
||||
# never calls CUDA at runtime, so the pinned CUDA bindings version is irrelevant.
|
||||
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
|
||||
# AMD (AMF) + Intel (QSV) hardware encode on Windows via libavcodec — the analogue of the Linux
|
||||
# VAAPI backend (`src/encode/ffmpeg_win.rs`). Optional + behind the `amf-qsv` feature because it
|
||||
# link-imports the FFmpeg libs at build time (needs a `FFMPEG_DIR` with the AMF/QSV encoders — the
|
||||
# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs
|
||||
# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62).
|
||||
ffmpeg-next = { version = "8", optional = true }
|
||||
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
|
||||
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
|
||||
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
|
||||
# to/from the DeviceIoControl byte buffers.
|
||||
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
# NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs
|
||||
@@ -185,3 +219,7 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
|
||||
# time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from
|
||||
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`.
|
||||
nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
||||
# `FFMPEG_DIR` (BtbN gpl-shared, includes `*_amf`/`*_qsv`) at build time and bundles the FFmpeg
|
||||
# DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||
amf-qsv = ["dep:ffmpeg-next"]
|
||||
|
||||
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_mic.rs"]
|
||||
mod wasapi_mic;
|
||||
|
||||
+10
@@ -13,6 +13,9 @@
|
||||
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
|
||||
//! (mirrors `WasapiLoopbackCapturer`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
@@ -154,6 +157,13 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
Ok(d) => Ok(d),
|
||||
Err(e) => {
|
||||
tracing::info!("no virtual mic device present — attempting auto-install");
|
||||
// SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s
|
||||
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
|
||||
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
|
||||
// Its internal contract holds: the `DiInstall` type matches the documented
|
||||
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
|
||||
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
|
||||
// dedicated mic thread.
|
||||
if unsafe { try_install_virtual_mic() } {
|
||||
find_device()
|
||||
} else {
|
||||
@@ -2,6 +2,10 @@
|
||||
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
||||
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
||||
|
||||
// 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 (capture::windows/linux::*).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||||
@@ -44,6 +48,49 @@ impl PixelFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 → `Rgb10a2`; the DDA secure-desktop HDR hint).
|
||||
/// `false` = 8-bit SDR.
|
||||
pub hdr: 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` from the resolved encode backend,
|
||||
/// `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) -> Self {
|
||||
OutputFormat {
|
||||
gpu: gpu_encode(),
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
||||
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
!matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -133,6 +180,25 @@ pub trait Capturer: Send {
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||
@@ -293,7 +359,14 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was
|
||||
/// created at — native, no scaling.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
|
||||
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
|
||||
// arg is a Windows-only dispatch — ignored here).
|
||||
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -304,11 +377,16 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
||||
/// compiled and comes back the moment the flag is unset.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn wgc_disabled() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_NO_WGC").is_some()
|
||||
crate::config::config().no_wgc
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
use crate::session_plan::CaptureBackend;
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
@@ -316,16 +394,39 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
||||
})?;
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||
// display) so there's no fall-through.
|
||||
if capture == CaptureBackend::IddPush {
|
||||
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
|
||||
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
|
||||
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
|
||||
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
|
||||
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
|
||||
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
|
||||
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
|
||||
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
|
||||
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
|
||||
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
|
||||
Err((e, keep)) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"IDD-push open/attach failed — falling back to DDA"
|
||||
);
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
}
|
||||
}
|
||||
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
|
||||
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
|
||||
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
|
||||
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback.
|
||||
let backend = std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||
if capture == CaptureBackend::Dda {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||
@@ -336,6 +437,11 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
||||
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
|
||||
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
|
||||
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
|
||||
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
|
||||
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
|
||||
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
|
||||
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
|
||||
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
|
||||
unsafe {
|
||||
let _ = windows::Win32::System::WinRT::RoInitialize(
|
||||
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
|
||||
@@ -355,31 +461,45 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
||||
pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/`
|
||||
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/composed_flip.rs"]
|
||||
pub mod composed_flip;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/desktop_watch.rs"]
|
||||
pub mod desktop_watch;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/dxgi.rs"]
|
||||
pub mod dxgi;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/idd_push.rs"]
|
||||
pub mod idd_push;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc.rs"]
|
||||
pub mod wgc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc_relay.rs"]
|
||||
pub mod wgc_relay;
|
||||
|
||||
+77
@@ -17,6 +17,9 @@
|
||||
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
|
||||
//! connection until process exit.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
@@ -498,6 +501,12 @@ mod pipewire {
|
||||
|
||||
impl DmabufMap {
|
||||
fn new(fd: i32, len: usize) -> Option<DmabufMap> {
|
||||
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
|
||||
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
|
||||
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
|
||||
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
|
||||
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
|
||||
// object mapped independently of `fd` (which may be closed after this returns).
|
||||
let ptr = unsafe {
|
||||
libc::mmap(
|
||||
std::ptr::null_mut(),
|
||||
@@ -514,6 +523,11 @@ mod pipewire {
|
||||
|
||||
impl Drop for DmabufMap {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
|
||||
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
|
||||
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
|
||||
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
|
||||
// lifetime, so no borrow outlives the unmap.
|
||||
unsafe {
|
||||
libc::munmap(self.ptr, self.len);
|
||||
}
|
||||
@@ -719,6 +733,14 @@ mod pipewire {
|
||||
if !ud.active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
|
||||
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
|
||||
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
|
||||
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
|
||||
// this `&mut` slice is the only reference to it (no aliasing/data race).
|
||||
let datas: &mut [pw::spa::buffer::Data] = unsafe {
|
||||
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
|
||||
&mut []
|
||||
@@ -783,6 +805,10 @@ mod pipewire {
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. (Content stability across the brief map+CSC window relies on
|
||||
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
|
||||
let dup =
|
||||
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if dup >= 0 {
|
||||
@@ -796,6 +822,10 @@ mod pipewire {
|
||||
pts_ns,
|
||||
format: fmt,
|
||||
payload: FramePayload::Dmabuf(DmabufFrame {
|
||||
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
|
||||
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
|
||||
// ownership and closes it exactly once on drop — no alias, no
|
||||
// double-close.
|
||||
fd: unsafe { OwnedFd::from_raw_fd(dup) },
|
||||
fourcc,
|
||||
modifier: ud.modifier,
|
||||
@@ -930,6 +960,11 @@ mod pipewire {
|
||||
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
|
||||
// trust `d.data()`.
|
||||
let fd_len = if raw_fd > 0 {
|
||||
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
|
||||
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
|
||||
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
|
||||
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
|
||||
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
|
||||
unsafe {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
|
||||
@@ -946,6 +981,14 @@ mod pipewire {
|
||||
match DmabufMap::new(raw_fd as i32, map_len) {
|
||||
Some(m) => {
|
||||
_mapping = m;
|
||||
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
|
||||
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
|
||||
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
|
||||
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
|
||||
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
|
||||
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
|
||||
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
|
||||
// read here, so there is no aliasing/mutation.
|
||||
Some(unsafe {
|
||||
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
|
||||
})
|
||||
@@ -1177,24 +1220,43 @@ mod pipewire {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||
// queued buffers, requeue the older ones, keep only the newest.
|
||||
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||
if newest.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut drained = 1u32;
|
||||
loop {
|
||||
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
|
||||
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
|
||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||
if next.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
|
||||
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
|
||||
// touched again (no use-after-requeue). Loop thread, single-threaded.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
newest = next;
|
||||
drained += 1;
|
||||
}
|
||||
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
||||
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
||||
// load through a valid pointer — no mutation or aliasing.
|
||||
let spa_buf = unsafe { (*newest).buffer };
|
||||
|
||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
|
||||
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
|
||||
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
|
||||
// buffer's metadata (or null). The size argument matches the struct the result is cast
|
||||
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
|
||||
// handled below.
|
||||
let hdr = unsafe {
|
||||
spa::sys::spa_buffer_find_meta_data(
|
||||
spa_buf,
|
||||
@@ -1205,11 +1267,20 @@ mod pipewire {
|
||||
let hdr_flags = if hdr.is_null() {
|
||||
0u32
|
||||
} else {
|
||||
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
|
||||
// inside the live buffer's metadata (returned for a size >=
|
||||
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
|
||||
// while the buffer is still held.
|
||||
unsafe { (*hdr).flags }
|
||||
};
|
||||
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
|
||||
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
|
||||
// stale skip only applies to mappable SHM buffers).
|
||||
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
|
||||
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
|
||||
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
|
||||
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
|
||||
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
|
||||
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
|
||||
if !spa_buf.is_null()
|
||||
&& (*spa_buf).n_datas > 0
|
||||
@@ -1246,11 +1317,17 @@ mod pipewire {
|
||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||
);
|
||||
}
|
||||
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
|
||||
// skip path); hand it back to the stream exactly once and return without touching it
|
||||
// again. Loop thread inside `.process`.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
return;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||
// use-after-requeue. Loop thread inside `.process`.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
+10
@@ -15,6 +15,9 @@
|
||||
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
|
||||
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use windows::core::w;
|
||||
@@ -48,6 +51,10 @@ impl ForceComposedFlip {
|
||||
let st = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("composed-flip".into())
|
||||
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
|
||||
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
|
||||
// precondition. It is designed to own its thread for its whole duration — exactly the
|
||||
// dedicated `composed-flip` thread spawned here.
|
||||
.spawn(move || unsafe { run(st) })
|
||||
.ok()?;
|
||||
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
|
||||
@@ -62,6 +69,9 @@ impl Drop for ForceComposedFlip {
|
||||
}
|
||||
|
||||
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
||||
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
|
||||
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
|
||||
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
|
||||
unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,5 +1,5 @@
|
||||
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
|
||||
//! two-process secure-desktop design (docs/windows-secure-desktop.md).
|
||||
//! two-process secure-desktop design (design/archive/windows-secure-desktop.md).
|
||||
//!
|
||||
//! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the
|
||||
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
|
||||
@@ -7,6 +7,9 @@
|
||||
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
|
||||
//! and publishes it as an atomic the capture mux + input path read.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -33,6 +36,10 @@ impl DesktopWatcher {
|
||||
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
|
||||
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
|
||||
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
|
||||
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
|
||||
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
|
||||
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
|
||||
// call from any thread (here, on the thread running `DesktopWatcher::start`).
|
||||
let initial = if unsafe { is_secure_desktop() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
@@ -53,6 +60,9 @@ impl DesktopWatcher {
|
||||
let mut candidate = initial;
|
||||
let mut stable = 0u32;
|
||||
while !st.load(Ordering::Relaxed) {
|
||||
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
|
||||
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
|
||||
// polling thread.
|
||||
let v = if unsafe { is_secure_desktop() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
+230
-53
@@ -7,6 +7,9 @@
|
||||
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
|
||||
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::ffi::c_void;
|
||||
@@ -41,7 +44,7 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIDevice, IDXGIDevice1, IDXGIFactory1, IDXGIOutput1,
|
||||
IDXGIOutput5, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
|
||||
IDXGIOutput5, IDXGIOutput6, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
|
||||
DXGI_ERROR_DEVICE_REMOVED, DXGI_ERROR_DEVICE_RESET, DXGI_ERROR_INVALID_CALL,
|
||||
DXGI_ERROR_MODE_CHANGE_IN_PROGRESS, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC,
|
||||
DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO,
|
||||
@@ -69,7 +72,12 @@ pub struct D3d11Frame {
|
||||
pub texture: ID3D11Texture2D,
|
||||
pub device: ID3D11Device,
|
||||
}
|
||||
// COM pointers, used only from the single owning thread.
|
||||
// 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 {
|
||||
@@ -129,6 +137,33 @@ pub(crate) unsafe fn find_output(gdi_name: &str) -> Result<(IDXGIAdapter1, IDXGI
|
||||
bail!("no DXGI output named {gdi_name} (gone after ACCESS_LOST?)")
|
||||
}
|
||||
|
||||
/// Read the source display's static HDR mastering metadata via `IDXGIOutput6::GetDesc1` (the
|
||||
/// monitor IS the "mastering display" for a desktop capture, exactly as Sunshine/Apollo treat it).
|
||||
/// GetDesc1 exposes the colour primaries, white point, and min/max mastering luminance but NOT a
|
||||
/// content light level, so MaxCLL/MaxFALL are left `0` (unknown — the display tone-maps from the
|
||||
/// mastering luminance). `None` if the output can't be cast to `IDXGIOutput6` or the call fails.
|
||||
unsafe fn read_output_hdr_meta(output: &IDXGIOutput1) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
let out6: IDXGIOutput6 = output.cast().ok()?;
|
||||
let d = out6.GetDesc1().ok()?;
|
||||
let m = crate::hdr::hdr_meta_from_display(
|
||||
(d.RedPrimary[0], d.RedPrimary[1]),
|
||||
(d.GreenPrimary[0], d.GreenPrimary[1]),
|
||||
(d.BluePrimary[0], d.BluePrimary[1]),
|
||||
(d.WhitePoint[0], d.WhitePoint[1]),
|
||||
d.MaxLuminance,
|
||||
d.MinLuminance,
|
||||
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
|
||||
0, // MaxFALL
|
||||
);
|
||||
tracing::info!(
|
||||
max_nits = d.MaxLuminance,
|
||||
min_nits = d.MinLuminance,
|
||||
max_full_frame_nits = d.MaxFullFrameLuminance,
|
||||
"read source display HDR mastering metadata (GetDesc1)"
|
||||
);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -175,41 +210,35 @@ pub(crate) unsafe fn make_device(
|
||||
Ok((device, context))
|
||||
}
|
||||
|
||||
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). 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).
|
||||
fn elevate_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| unsafe {
|
||||
use windows::core::{s, PCWSTR};
|
||||
/// 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 (what `CreateProcessAsUserW` hands the WGC helper) does NOT have it, which is why the helper
|
||||
/// can't elevate itself and the SYSTEM host stamps the class onto it cross-process instead (see
|
||||
/// [`set_child_gpu_priority_class`]).
|
||||
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,
|
||||
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES,
|
||||
TOKEN_QUERY,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
|
||||
// REALTIME 5.
|
||||
let prio: i32 = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS").ok().as_deref() {
|
||||
Some("off") => {
|
||||
tracing::info!("GPU process scheduling priority class left at default (off)");
|
||||
return;
|
||||
}
|
||||
Some("normal") => 2,
|
||||
Some("realtime") => 5,
|
||||
_ => 4, // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
|
||||
};
|
||||
|
||||
// 1. Enable SE_INC_BASE_PRIORITY so the kernel permits the GPU priority bump.
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
@@ -242,29 +271,97 @@ fn elevate_process_gpu_priority() {
|
||||
}
|
||||
let _ = CloseHandle(token);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. D3DKMTSetProcessSchedulingPriorityClass via gdi32 (no stable windows-rs binding).
|
||||
if let Ok(gdi32) = LoadLibraryA(s!("gdi32.dll")) {
|
||||
if let Some(p) = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) {
|
||||
/// 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);
|
||||
let st = f(GetCurrentProcess(), prio);
|
||||
if st == 0 {
|
||||
tracing::info!(
|
||||
Some(f(process, prio))
|
||||
}
|
||||
|
||||
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). 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). NOTE: in the SYSTEM-host + user-session-helper deployment this self-set NO-OPs in
|
||||
/// the helper (filtered token), so the host also sets it on the helper via [`set_child_gpu_priority_class`].
|
||||
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)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
),
|
||||
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"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the GPU scheduling-priority class of ANOTHER process we created — the WGC capture+encode helper
|
||||
/// in the interactive user session. The helper is spawned with the user's UAC-FILTERED token, which
|
||||
/// lacks SE_INC_BASE_PRIORITY, so its own [`elevate_process_gpu_priority`] silently no-ops and NVENC
|
||||
/// gets starved under a GPU-saturating game (the "240→40 fps in-game collapse"). The SYSTEM host DOES
|
||||
/// hold the privilege, so it stamps the class onto the child's process handle right after spawn — the
|
||||
/// process-level class applies to GPU contexts the child creates afterwards. Best-effort; logged.
|
||||
/// `PUNKTFUNK_GPU_PRIORITY_CLASS=off` disables it (same knob as the self path).
|
||||
///
|
||||
/// # Safety
|
||||
/// `process` must be a valid handle to a process we own with at least PROCESS_SET_INFORMATION access
|
||||
/// (the just-created helper, `PROCESS_INFORMATION::hProcess`).
|
||||
pub(crate) unsafe fn set_child_gpu_priority_class(process: windows::Win32::Foundation::HANDLE) {
|
||||
let Some(prio) = configured_gpu_priority_class() else {
|
||||
return;
|
||||
};
|
||||
enable_inc_base_priority(); // the SYSTEM host holds SE_INC_BASE_PRIORITY; the helper does not
|
||||
match d3dkmt_set_scheduling_priority_class(process, prio) {
|
||||
Some(0) => tracing::info!(
|
||||
priority_class = prio,
|
||||
"WGC helper GPU scheduling priority class set cross-process from the SYSTEM host \
|
||||
(2=normal 4=high 5=realtime)"
|
||||
),
|
||||
Some(st) => tracing::warn!(
|
||||
status = format!("0x{st:08X}"),
|
||||
"cross-process D3DKMTSetProcessSchedulingPriorityClass on the WGC helper failed"
|
||||
),
|
||||
None => tracing::warn!(
|
||||
"D3DKMTSetProcessSchedulingPriorityClass export not found — WGC helper has no GPU priority"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-find the output, make a fresh device on its adapter, and duplicate it. Used by the ACCESS_LOST
|
||||
/// recovery to rebuild the whole capture on the current (possibly secure) input desktop.
|
||||
unsafe fn reopen_duplication(
|
||||
@@ -455,6 +552,17 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
||||
pub(crate) fn install_gpu_pref_hook() {
|
||||
use std::sync::Once;
|
||||
static HOOK: Once = Once::new();
|
||||
// SAFETY: this one-time hook install only touches a region it has just validated.
|
||||
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
|
||||
// live base of the real exported function, so `target` is a valid executable code pointer to at
|
||||
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
|
||||
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
|
||||
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
|
||||
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
|
||||
// vs. loaded module image), so every access stays in mapped, in-bounds memory.
|
||||
// `FlushInstructionCache` gets the current-process pseudo-handle + that same range. The DPI calls
|
||||
// take by-value context handles / fill the live local `&mut old`/`&mut restore` for the duration of
|
||||
// each synchronous call. Runs once via `Once::call_once`, before any DXGI use.
|
||||
HOOK.call_once(|| unsafe {
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
use windows::Win32::System::Memory::{
|
||||
@@ -1306,6 +1414,14 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
@@ -1900,6 +2016,10 @@ pub struct DuplCapturer {
|
||||
/// produce a BT.2020 PQ 10-bit (`R10G10B10A2`) frame for NVENC. Toggling HDR fires ACCESS_LOST →
|
||||
/// `recreate_dupl` re-detects the format, so this tracks the *current* duplication.
|
||||
hdr_fp16: bool,
|
||||
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
|
||||
/// `IDXGIOutput6::GetDesc1` whenever the duplication is HDR (`hdr_fp16`). The stream loop forwards
|
||||
/// it to the encoder (in-band SEI) and the client (0xCE). `None` when SDR or the read failed.
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// FP16 copy of the duplication surface (RT|SRV): the cursor composites onto it and the converter
|
||||
/// samples it. Reallocated on device/size change.
|
||||
fp16_src: Option<ID3D11Texture2D>,
|
||||
@@ -1951,7 +2071,11 @@ pub struct DuplCapturer {
|
||||
dbg_cursor: u64,
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// COM objects used only from the one thread that owns the capturer (the encode thread).
|
||||
// SAFETY: `DuplCapturer` holds D3D11 device/context/duplication COM pointers plus plain data. The
|
||||
// device is created free-threaded (`make_device` sets no `D3D11_CREATE_DEVICE_SINGLETHREADED`) and
|
||||
// COM reference counting is interlocked, so moving ownership of the whole capturer to another thread
|
||||
// is sound. It is used by exactly one thread (the encode thread) at a time — moved to it once, never
|
||||
// shared (no `Sync`) — so the single-threaded immediate context is never touched concurrently.
|
||||
unsafe impl Send for DuplCapturer {}
|
||||
|
||||
impl DuplCapturer {
|
||||
@@ -1959,8 +2083,18 @@ impl DuplCapturer {
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
// Whether the (already-resolved) encode backend wants GPU-resident frames — passed IN (Goal-1
|
||||
// stage 5) so the capturer never re-derives the encode backend itself.
|
||||
gpu: bool,
|
||||
want_hdr: bool,
|
||||
) -> Result<Self> {
|
||||
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
|
||||
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
|
||||
// `SetThreadExecutionState` takes a flags bitmask by value. `CreateDXGIFactory1` yields a live
|
||||
// `IDXGIFactory1`, and every subsequent COM method (`EnumAdapters1`/`EnumOutputs`/`GetDesc1`/
|
||||
// `GetDesc`/`cast`) is called on that factory or on an adapter/output it returned — each obtained
|
||||
// through a checked `while let Ok(..)`/`?` — all from this one thread. No raw pointers are
|
||||
// dereferenced; the borrowed strings/locals outlive each synchronous call.
|
||||
unsafe {
|
||||
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
|
||||
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
|
||||
@@ -2096,9 +2230,9 @@ impl DuplCapturer {
|
||||
let context = context.context("null D3D11 context")?;
|
||||
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
|
||||
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
|
||||
// The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor
|
||||
// (registry-persisted), so the secure desktop has nowhere to render but the output we
|
||||
// capture — no per-open re-isolation needed.
|
||||
// The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
|
||||
// applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
|
||||
// but the output we capture — no per-open re-isolation needed.
|
||||
attach_input_desktop();
|
||||
let dupl = duplicate_output(&output, &device, want_hdr)
|
||||
.context("DuplicateOutput (already duplicated by another app?)")?;
|
||||
@@ -2126,9 +2260,21 @@ impl DuplCapturer {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or((2000 / refresh_hz.max(1)).max(100));
|
||||
let gpu_mode = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "nvenc" | "hw" | "nvidia"))
|
||||
.unwrap_or(false);
|
||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
|
||||
// read back / import) whenever the encode backend is a GPU one — so the capturer's output
|
||||
// format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
|
||||
// The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
|
||||
// capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
|
||||
// capture and encode disagree (plan §2.3/§5).
|
||||
let gpu_mode = gpu;
|
||||
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
||||
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
||||
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
let hdr_meta_init = if is_hdr_init {
|
||||
read_output_hdr_meta(&output)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::info!(
|
||||
"DXGI duplication: {}x{}@{} on {} ({}) dxgi_format={} (87=BGRA8 24=R10G10B10A2 10=R16G16B16A16_FLOAT)",
|
||||
width,
|
||||
@@ -2165,7 +2311,8 @@ impl DuplCapturer {
|
||||
gpu_copy: None,
|
||||
last_present: None,
|
||||
want_hdr,
|
||||
hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
hdr_fp16: is_hdr_init,
|
||||
hdr_meta: hdr_meta_init,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
hdr10_out: None,
|
||||
@@ -2611,7 +2758,7 @@ impl DuplCapturer {
|
||||
}
|
||||
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
|
||||
// re-resolve from the STABLE target id so we find it under its current name.
|
||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) {
|
||||
if let Some(n) = crate::win_display::resolve_gdi_name(self.target_id) {
|
||||
self.gdi_name = n;
|
||||
}
|
||||
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
|
||||
@@ -2661,6 +2808,12 @@ impl DuplCapturer {
|
||||
// Re-detect HDR and drop the HDR textures/converter (old device). Toggling HDR on or
|
||||
// off is exactly this path: the duplication comes back as FP16 (HDR) or BGRA8.
|
||||
self.hdr_fp16 = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
// Re-read the source mastering metadata for the (possibly new) HDR output, or clear it on SDR.
|
||||
self.hdr_meta = if self.hdr_fp16 {
|
||||
read_output_hdr_meta(&self.output)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.fp16_src = None;
|
||||
self.fp16_srv = None;
|
||||
self.hdr10_out = None;
|
||||
@@ -3084,11 +3237,25 @@ fn now_ns() -> u64 {
|
||||
}
|
||||
|
||||
impl Capturer for DuplCapturer {
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
// Only when the duplication is actually HDR (FP16); cleared to None on an SDR rebuild.
|
||||
if self.hdr_fp16 {
|
||||
self.hdr_meta
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// Generous: a secure-desktop switch can take several seconds to settle (re-resolve + recreate
|
||||
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
|
||||
let mut deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
// SAFETY: `acquire` is an `unsafe fn` because it drives the D3D11 immediate context + the
|
||||
// output duplication, which must be touched only from the capturer's owning thread.
|
||||
// `next_frame` runs on that one thread — `DuplCapturer` is `Send` but not `Sync`, so it is
|
||||
// owned by a single (encode) thread for its whole life — and `&mut self` gives exclusive
|
||||
// access for the call, satisfying that contract.
|
||||
if let Some(f) = unsafe { self.acquire() }? {
|
||||
self.ever_got_frame = true;
|
||||
return Ok(f);
|
||||
@@ -3135,6 +3302,8 @@ impl Capturer for DuplCapturer {
|
||||
}
|
||||
|
||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
// SAFETY: as in `next_frame` — `acquire` must run on the capturer's single owning thread, and
|
||||
// `try_latest` is called on it (`DuplCapturer` is `Send`, not `Sync`); `&mut self` is exclusive.
|
||||
unsafe { self.acquire() }
|
||||
}
|
||||
|
||||
@@ -3146,11 +3315,19 @@ impl Capturer for DuplCapturer {
|
||||
impl Drop for DuplCapturer {
|
||||
fn drop(&mut self) {
|
||||
if self.holding_frame {
|
||||
// SAFETY: `self.dupl` is the live `IDXGIOutputDuplication` this capturer created and owns;
|
||||
// `ReleaseFrame` is a valid COM method on it, called only when `holding_frame` records that a
|
||||
// frame was acquired and not yet released (so it is not an unbalanced release). Drop runs on
|
||||
// whichever thread owns the capturer — its sole owner, since it is `!Sync` — and the `&`
|
||||
// borrow of the duplication outlives this synchronous call.
|
||||
unsafe {
|
||||
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
|
||||
}
|
||||
}
|
||||
// Release the display/system-required execution state we took at open().
|
||||
// SAFETY: `SetThreadExecutionState` is a Win32 FFI call taking an execution-state flag bitmask
|
||||
// by value (`ES_CONTINUOUS` clears the display/system-required state taken at open); it borrows
|
||||
// no Rust memory and is safe to call from any thread.
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+63
-4
@@ -16,6 +16,9 @@
|
||||
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
|
||||
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
|
||||
HdrP010Converter, VideoConverter, WinCaptureTarget,
|
||||
@@ -92,6 +95,10 @@ struct Deimpersonate(Option<HANDLE>);
|
||||
impl Drop for Deimpersonate {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tok) = self.0.take() {
|
||||
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
|
||||
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
|
||||
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
|
||||
// no double-close). Both are FFI calls borrowing no Rust memory.
|
||||
unsafe {
|
||||
let _ = RevertToSelf();
|
||||
let _ = CloseHandle(tok);
|
||||
@@ -127,6 +134,11 @@ pub struct WgcCapturer {
|
||||
first_frame: bool,
|
||||
|
||||
hdr: bool,
|
||||
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
|
||||
/// `IDXGIOutput6::GetDesc1` at open when the output is HDR. Forwarded to the encoder (in-band SEI)
|
||||
/// and the client (0xCE) by the stream loop. `None` when SDR. (The helper relay path also encodes,
|
||||
/// so this is what gives the secure/normal-desktop HDR stream its mastering SEI.)
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hdr_conv: Option<HdrConverter>,
|
||||
fp16_src: Option<ID3D11Texture2D>,
|
||||
fp16_srv: Option<ID3D11ShaderResourceView>,
|
||||
@@ -169,7 +181,12 @@ pub struct WgcCapturer {
|
||||
_keepalive: Option<Box<dyn Send>>,
|
||||
}
|
||||
|
||||
// COM + WinRT pointers; confined to the single owning (encode) thread, like DuplCapturer.
|
||||
// SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
|
||||
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
|
||||
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
|
||||
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
|
||||
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
|
||||
// the capturer's COM fields.
|
||||
unsafe impl Send for WgcCapturer {}
|
||||
|
||||
impl WgcCapturer {
|
||||
@@ -177,6 +194,15 @@ impl WgcCapturer {
|
||||
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
|
||||
/// keepalive with the caller to hand to the DDA fallback.
|
||||
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
|
||||
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
|
||||
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
|
||||
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
|
||||
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
|
||||
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
|
||||
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
|
||||
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
|
||||
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
|
||||
// locals outlive their synchronous calls.
|
||||
unsafe {
|
||||
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
|
||||
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
|
||||
@@ -191,7 +217,7 @@ impl WgcCapturer {
|
||||
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
|
||||
let deadline = Instant::now() + Duration::from_millis(2000);
|
||||
let (adapter, output) = loop {
|
||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(target.target_id) {
|
||||
if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
|
||||
if let Ok(found) = find_output(&n) {
|
||||
break found;
|
||||
}
|
||||
@@ -213,12 +239,31 @@ impl WgcCapturer {
|
||||
let hmonitor = od.Monitor;
|
||||
|
||||
// HDR iff the output's colour space is BT.2020 PQ (G2084) — matches the DDA FP16 detection.
|
||||
let hdr = output
|
||||
// From the same desc, read the source display's mastering metadata (ST.2086) when HDR.
|
||||
let desc1 = output
|
||||
.cast::<IDXGIOutput6>()
|
||||
.ok()
|
||||
.and_then(|o6| o6.GetDesc1().ok())
|
||||
.and_then(|o6| o6.GetDesc1().ok());
|
||||
let hdr = desc1
|
||||
.as_ref()
|
||||
.map(|d1| d1.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
|
||||
.unwrap_or(false);
|
||||
let hdr_meta = if hdr {
|
||||
desc1.as_ref().map(|d| {
|
||||
crate::hdr::hdr_meta_from_display(
|
||||
(d.RedPrimary[0], d.RedPrimary[1]),
|
||||
(d.GreenPrimary[0], d.GreenPrimary[1]),
|
||||
(d.BluePrimary[0], d.BluePrimary[1]),
|
||||
(d.WhitePoint[0], d.WhitePoint[1]),
|
||||
d.MaxLuminance,
|
||||
d.MinLuminance,
|
||||
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
|
||||
0, // MaxFALL
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wrap our D3D11 device as a WinRT IDirect3DDevice so the frame pool allocates on it (the
|
||||
// pool textures land on our device → CopyResource + NVENC are same-device, no readback).
|
||||
@@ -326,6 +371,7 @@ impl WgcCapturer {
|
||||
timeout_ms,
|
||||
first_frame: true,
|
||||
hdr,
|
||||
hdr_meta,
|
||||
hdr_conv: None,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
@@ -560,6 +606,15 @@ impl WgcCapturer {
|
||||
}
|
||||
|
||||
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
|
||||
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
|
||||
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
|
||||
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
|
||||
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
|
||||
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
|
||||
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
|
||||
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
|
||||
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
|
||||
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
|
||||
unsafe {
|
||||
let surface = frame.Surface().context("frame Surface")?;
|
||||
let access: IDirect3DDxgiInterfaceAccess = surface
|
||||
@@ -680,6 +735,10 @@ impl WgcCapturer {
|
||||
}
|
||||
|
||||
impl Capturer for WgcCapturer {
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
self.hdr_meta
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let overall = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
+48
-3
@@ -1,5 +1,5 @@
|
||||
//! Host-side WGC helper relay (Windows two-process secure-desktop design,
|
||||
//! docs/windows-secure-desktop.md — step 4).
|
||||
//! design/archive/windows-secure-desktop.md — step 4).
|
||||
//!
|
||||
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
|
||||
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
|
||||
@@ -13,6 +13,9 @@
|
||||
//! Wire framing (must match `wgc_helper::write_au`): per AU
|
||||
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::capture::dxgi::WinCaptureTarget;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
@@ -56,9 +59,15 @@ pub struct HelperRelay {
|
||||
rx: Receiver<RelayAu>,
|
||||
}
|
||||
|
||||
// HANDLEs are just kernel handle values; we own them for the relay's lifetime and close them on Drop.
|
||||
// SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
|
||||
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
|
||||
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
|
||||
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
|
||||
unsafe impl Send for HelperRelay {}
|
||||
unsafe impl Sync for HelperRelay {}
|
||||
// NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
|
||||
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
|
||||
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
|
||||
// `unsafe impl Sync` here asserted more than the fields support; removed.)
|
||||
|
||||
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
|
||||
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
|
||||
@@ -84,6 +93,10 @@ impl HelperRelay {
|
||||
);
|
||||
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
|
||||
|
||||
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
|
||||
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
|
||||
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
|
||||
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
|
||||
unsafe { spawn_inner(&cmdline, w, h, hz) }
|
||||
}
|
||||
|
||||
@@ -108,6 +121,11 @@ impl HelperRelay {
|
||||
pub fn request_keyframe(&self) {
|
||||
let h = self.stdin_w.lock().unwrap();
|
||||
let mut written = 0u32;
|
||||
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
|
||||
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
|
||||
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
|
||||
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
|
||||
// discarded as documented.
|
||||
unsafe {
|
||||
let _ = windows::Win32::Storage::FileSystem::WriteFile(
|
||||
*h,
|
||||
@@ -121,6 +139,10 @@ impl HelperRelay {
|
||||
|
||||
impl Drop for HelperRelay {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
|
||||
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
|
||||
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
|
||||
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
|
||||
unsafe {
|
||||
// Terminate the child first so its WGC capture + NVENC session tear down, then close our
|
||||
// handles (the reader threads end on the resulting broken pipe).
|
||||
@@ -278,6 +300,13 @@ unsafe fn spawn_inner(cmdline: &str, w: u32, h: u32, hz: u32) -> Result<HelperRe
|
||||
}
|
||||
tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned");
|
||||
|
||||
// The helper does the WGC capture + NVENC encode, but it runs under the user's UAC-FILTERED token
|
||||
// (no SE_INC_BASE_PRIORITY), so it can't raise its OWN GPU scheduling-priority class — under a
|
||||
// GPU-saturating game NVENC then gets starved (the "240→40 fps in-game collapse"). The SYSTEM host
|
||||
// holds the privilege, so stamp the HIGH GPU priority class onto the child here, right after spawn
|
||||
// (the process-level class applies to the GPU contexts the helper creates afterwards).
|
||||
crate::capture::dxgi::set_child_gpu_priority_class(pi.hProcess);
|
||||
|
||||
// stderr → host tracing, line by line.
|
||||
let err_handle = HandleReader(err_r);
|
||||
std::thread::Builder::new()
|
||||
@@ -357,10 +386,17 @@ fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
|
||||
|
||||
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
|
||||
struct HandleReader(HANDLE);
|
||||
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
|
||||
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
|
||||
// Drop), never shared — so transferring ownership across threads is sound.
|
||||
unsafe impl Send for HandleReader {}
|
||||
impl Read for HandleReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut read = 0u32;
|
||||
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
|
||||
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
|
||||
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
|
||||
// surfaces as `Err` and is mapped to EOF below.
|
||||
let ok = unsafe {
|
||||
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
|
||||
};
|
||||
@@ -373,6 +409,8 @@ impl Read for HandleReader {
|
||||
}
|
||||
impl Drop for HandleReader {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
|
||||
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
@@ -384,6 +422,13 @@ impl Drop for HandleReader {
|
||||
pub fn running_as_system() -> bool {
|
||||
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
|
||||
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
|
||||
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
|
||||
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
|
||||
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
|
||||
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
|
||||
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
|
||||
unsafe {
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `HostConfig` — the host's runtime knobs parsed ONCE from the environment, instead of the ~68 scattered
|
||||
//! `env::var` reads recomputed at every call site (some up to 8×, which lets capture + encode silently
|
||||
//! disagree on the resolved backend — plan §2.4). The service / launcher loads `host.env` into the process
|
||||
//! environment before the host starts, and **for the knobs captured here the environment is constant for the
|
||||
//! process lifetime**, so a lazily-parsed global is equivalent to "parsed once at startup".
|
||||
//!
|
||||
//! **Goal-1 stages 1–2** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
|
||||
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
|
||||
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
|
||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
|
||||
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||
//! capture/topology/encoder decision.
|
||||
//!
|
||||
//! **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
|
||||
//! 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_*`
|
||||
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
||||
//! `MUTTER_VIRTUAL_PRIMARY`, `FORCE_SHM` (+ `GAMESCOPE_APP` on the launch path). Parsing these once would
|
||||
//! freeze them at startup and silently break session-following — they are NOT constant.
|
||||
//! - **Single-use local tuning** read exactly where it is used (no resolve-once benefit, and a parse with a
|
||||
//! call-site-local default/clamp): e.g. `FEC_PCT` (two *different* semantics — GameStream default-20 vs
|
||||
//! punktfunk/1 `Option`/clamp-90), `VIDEO_DROP`, `VBV_FRAMES`, `SPLIT_ENCODE`, `PACE_BURST_KB`, the
|
||||
//! `capture/dxgi.rs` timing knobs, the `*_LIVE` test gates.
|
||||
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
||||
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
||||
//!
|
||||
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
||||
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
||||
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostConfig {
|
||||
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
|
||||
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
|
||||
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
|
||||
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
|
||||
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
|
||||
pub idd_push: bool,
|
||||
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||
pub encoder_pref: String,
|
||||
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
|
||||
pub no_helper: bool,
|
||||
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
|
||||
pub force_helper: bool,
|
||||
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
|
||||
pub no_wgc: bool,
|
||||
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
|
||||
pub capture_backend: String,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
|
||||
pub render_adapter: Option<String>,
|
||||
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
|
||||
pub secure_dda: bool,
|
||||
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||
pub idd_depth: usize,
|
||||
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
||||
pub zerocopy: bool,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
pub video_source: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
/// `PUNKTFUNK_GAMEPAD` — client/operator virtual-pad backend preference (fed to `pick_gamepad`).
|
||||
pub gamepad: Option<String>,
|
||||
/// `PUNKTFUNK_VDISPLAY` — Windows virtual-display backend. The pf-vdisplay IddCx driver is now the only
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
fn from_env() -> Self {
|
||||
// Presence flag: set ⇒ true. Matches the original `var_os(k).is_some()` reads (and the few
|
||||
// `var(k).is_ok()` flag reads, which coincide for every real-world value).
|
||||
let flag = |k: &str| std::env::var_os(k).is_some();
|
||||
// String value: `var(k).ok()` — `Some` (possibly empty) when set with valid UTF-8, else `None`.
|
||||
let val = |k: &str| std::env::var(k).ok();
|
||||
Self {
|
||||
// Value-aware (not a bare presence flag): the shipped default `host.env` turns it ON, and an
|
||||
// operator turns it OFF with `PUNKTFUNK_IDD_PUSH=0` (a `var_os` presence check would read `=0`
|
||||
// as "on"). Unset ⇒ off (the dev / non-pf-driver default).
|
||||
idd_push: match std::env::var("PUNKTFUNK_IDD_PUSH") {
|
||||
Ok(v) => !matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "no" | "off"
|
||||
),
|
||||
Err(_) => false,
|
||||
},
|
||||
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
no_helper: flag("PUNKTFUNK_NO_HELPER"),
|
||||
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
|
||||
no_wgc: flag("PUNKTFUNK_NO_WGC"),
|
||||
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
|
||||
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
|
||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2),
|
||||
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide host configuration, parsed once on first access.
|
||||
pub fn config() -> &'static HostConfig {
|
||||
static CFG: OnceLock<HostConfig> = OnceLock::new();
|
||||
CFG.get_or_init(HostConfig::from_env)
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
//! 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
|
||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`].
|
||||
// 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::*).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::Result;
|
||||
@@ -49,14 +52,75 @@ impl Codec {
|
||||
Codec::Av1 => "av1_vaapi",
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
|
||||
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
|
||||
pub fn amf_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_amf",
|
||||
Codec::H265 => "hevc_amf",
|
||||
Codec::Av1 => "av1_amf",
|
||||
}
|
||||
}
|
||||
|
||||
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
|
||||
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
|
||||
pub fn qsv_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_qsv",
|
||||
Codec::H265 => "hevc_qsv",
|
||||
Codec::Av1 => "av1_qsv",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
|
||||
/// AMF/QSV always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers,
|
||||
/// as reported in a loss-recovery request) so the encoder re-references an older still-valid
|
||||
/// frame instead of emitting a full IDR. Returns `true` if a real reference invalidation was
|
||||
/// performed; `false` means the encoder couldn't (range older than the DPB, or the backend has
|
||||
/// no RFI) and the caller should fall back to [`request_keyframe`](Self::request_keyframe).
|
||||
/// Default: `false` — only the Windows direct-NVENC path implements true RFI; libavcodec
|
||||
/// (Linux NVENC) and VAAPI can't express `nvEncInvalidateRefFrames`, so they keyframe.
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
@@ -137,14 +201,12 @@ pub fn open_video(
|
||||
// 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
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let pref = crate::config::config().encoder_pref.as_str();
|
||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
};
|
||||
match pref.as_str() {
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
@@ -182,16 +244,15 @@ pub fn open_video(
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = cuda; // always false on Windows (no Cuda payload)
|
||||
let _ = bit_depth; // used by the NVENC path below; the software H.264 path is 8-bit only
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(pref.as_str(), "nvenc" | "hw" | "nvidia") {
|
||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||
// H.264. `auto` (the default) resolves from the DXGI adapter vendor.
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Nvenc => {
|
||||
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
|
||||
// FramePayload::D3d11 output under the same env var so capture + encode share textures.
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
let enc = nvenc::NvencD3d11Encoder::open(
|
||||
nvenc::NvencD3d11Encoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
@@ -199,32 +260,67 @@ pub fn open_video(
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
)?;
|
||||
return Ok(Box::new(enc) as Box<dyn Encoder>);
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"NVENC requested but this host was built without it — rebuild with \
|
||||
`--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
|
||||
);
|
||||
"NVENC requested/detected but this host was built without it — rebuild \
|
||||
with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
|
||||
)
|
||||
}
|
||||
}
|
||||
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
|
||||
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
{
|
||||
let vendor = if matches!(backend, WindowsBackend::Amf) {
|
||||
ffmpeg_win::WinVendor::Amf
|
||||
} else {
|
||||
ffmpeg_win::WinVendor::Qsv
|
||||
};
|
||||
ffmpeg_win::FfmpegWinEncoder::open(
|
||||
vendor,
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
}
|
||||
#[cfg(not(feature = "amf-qsv"))]
|
||||
{
|
||||
let _ = backend;
|
||||
anyhow::bail!(
|
||||
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
|
||||
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
|
||||
FFMPEG_DIR with the AMF/QSV encoders at build time)"
|
||||
)
|
||||
}
|
||||
}
|
||||
WindowsBackend::Software => {
|
||||
anyhow::ensure!(
|
||||
codec == Codec::H264,
|
||||
"the Windows software encoder supports H.264 only; client negotiated {codec:?} \
|
||||
(set PUNKTFUNK_ENCODER=nvenc for a GPU host, or request H264)"
|
||||
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
|
||||
);
|
||||
let _ = bit_depth; // the software H.264 path is 8-bit only
|
||||
// Software H.264 realistically caps far below the negotiated hardware rates.
|
||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||
let enc = sw::OpenH264Encoder::open(
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)?;
|
||||
Ok(Box::new(enc) as Box<dyn Encoder>)
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
@@ -309,11 +405,7 @@ fn nvidia_present() -> bool {
|
||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
match std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => false,
|
||||
"vaapi" | "amd" | "intel" => true,
|
||||
_ => !nvidia_present(),
|
||||
@@ -323,7 +415,7 @@ pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
/// Which codecs the active GPU can actually ENCODE. Used to build the GameStream codec
|
||||
/// advertisement so a client never negotiates a codec the GPU can't do (AV1 encode is narrow —
|
||||
/// Intel Arc/Xe2+, AMD RDNA3+/RDNA4 — so it must be probed, not assumed).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CodecSupport {
|
||||
pub h264: bool,
|
||||
@@ -354,13 +446,142 @@ pub fn vaapi_codec_support() -> CodecSupport {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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 DXGI adapter vendor.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum WindowsBackend {
|
||||
Nvenc,
|
||||
Amf,
|
||||
Qsv,
|
||||
Software,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum GpuVendor {
|
||||
Nvidia,
|
||||
Amd,
|
||||
Intel,
|
||||
}
|
||||
|
||||
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the DXGI adapter
|
||||
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
||||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
||||
"amf" | "amd" => WindowsBackend::Amf,
|
||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
||||
"sw" | "software" | "openh264" => WindowsBackend::Software,
|
||||
_ => match windows_gpu_vendor() {
|
||||
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
|
||||
Some(GpuVendor::Amd) => WindowsBackend::Amf,
|
||||
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
|
||||
None => WindowsBackend::Software,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
|
||||
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
|
||||
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn windows_backend_is_ffmpeg() -> bool {
|
||||
cfg!(feature = "amf-qsv")
|
||||
&& matches!(
|
||||
windows_resolved_backend(),
|
||||
WindowsBackend::Amf | WindowsBackend::Qsv
|
||||
)
|
||||
}
|
||||
|
||||
/// Detect the host GPU vendor from the first hardware DXGI adapter (Windows has no `/dev/nvidia*`
|
||||
/// probe). Cached. NVIDIA=0x10DE, AMD=0x1002, Intel=0x8086; the software/WARP adapter is skipped.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_gpu_vendor() -> Option<GpuVendor> {
|
||||
use std::sync::OnceLock;
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||
};
|
||||
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
|
||||
// SAFETY: `CreateDXGIFactory1` returns a fresh owned `IDXGIFactory1` COM object (refcounted by the
|
||||
// windows-rs wrapper, Released when the local drops); `.ok()?` bails on failure so `factory` is a
|
||||
// valid interface before any use. `EnumAdapters1(i)` hands back the i-th adapter as an owned
|
||||
// `IDXGIAdapter1` (or an error past the last adapter, which ends the loop). `GetDesc1()` returns the
|
||||
// `DXGI_ADAPTER_DESC1` by value (no out-pointer), so reading `desc.Flags`/`desc.VendorId` is plain
|
||||
// field access. Every call only touches COM objects this closure owns; the `OnceLock` runs the
|
||||
// closure once (no data race) and all interfaces are Released as the locals drop. No raw pointer is
|
||||
// dereferenced and nothing is aliased.
|
||||
*CACHE.get_or_init(|| unsafe {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut i = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(i) {
|
||||
i += 1;
|
||||
// windows-rs 0.62: GetDesc1 returns the desc by value (no out-param).
|
||||
let Ok(desc) = adapter.GetDesc1() else {
|
||||
continue;
|
||||
};
|
||||
if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0 {
|
||||
continue; // skip the Microsoft Basic Render / WARP adapter
|
||||
}
|
||||
match desc.VendorId {
|
||||
0x10DE => return Some(GpuVendor::Nvidia),
|
||||
0x1002 => return Some(GpuVendor::Amd),
|
||||
0x8086 => return Some(GpuVendor::Intel),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached; opens a tiny encoder
|
||||
/// per codec, once). Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`]
|
||||
/// is true. AV1 is narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||
pub fn windows_codec_support() -> CodecSupport {
|
||||
use std::sync::OnceLock;
|
||||
static CACHE: OnceLock<CodecSupport> = OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
let vendor = match windows_resolved_backend() {
|
||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
||||
_ => ffmpeg_win::WinVendor::Amf,
|
||||
};
|
||||
let caps = CodecSupport {
|
||||
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
|
||||
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
|
||||
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
|
||||
};
|
||||
tracing::info!(
|
||||
backend = ?vendor,
|
||||
h264 = caps.h264,
|
||||
h265 = caps.h265,
|
||||
av1 = caps.av1,
|
||||
"Windows AMF/QSV encode capabilities probed"
|
||||
);
|
||||
caps
|
||||
})
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
||||
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||
mod ffmpeg_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||
#[path = "encode/windows/nvenc.rs"]
|
||||
mod nvenc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "encode/windows/sw.rs"]
|
||||
mod sw;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "encode/linux/vaapi.rs"]
|
||||
mod vaapi;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+53
@@ -8,6 +8,8 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -79,6 +81,12 @@ impl CudaHw {
|
||||
|
||||
impl Drop for CudaHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
|
||||
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
|
||||
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
|
||||
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
|
||||
// device; refcounted, so the order is sound regardless).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
@@ -136,6 +144,13 @@ pub struct NvencEncoder {
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
|
||||
// already `Send` via ffmpeg-next; assert it for the CUDA fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
|
||||
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
|
||||
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
|
||||
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
|
||||
// ownership across threads is sound. This asserts `Send` (transfer) only, extending ffmpeg-next's
|
||||
// existing `Send` to the raw CUDA fields; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||
unsafe impl Send for NvencEncoder {}
|
||||
|
||||
impl NvencEncoder {
|
||||
@@ -162,6 +177,9 @@ impl NvencEncoder {
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
// is a valid level with no pointer args, and libav was just initialized by `ffmpeg::init()`
|
||||
// above — always sound.
|
||||
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
|
||||
}
|
||||
let name = codec.nvenc_name();
|
||||
@@ -195,6 +213,11 @@ impl NvencEncoder {
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
||||
.clamp(1.0, i32::MAX as f64);
|
||||
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
|
||||
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
|
||||
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
|
||||
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
|
||||
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||
}
|
||||
@@ -204,6 +227,9 @@ impl NvencEncoder {
|
||||
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
|
||||
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
|
||||
// This is the Moonlight/Sunshine low-latency model.
|
||||
// SAFETY: same `video` builder as above — a non-null, properly-aligned, sole-owned, not-yet-
|
||||
// opened `AVCodecContext`. We write the plain `gop_size` int field (= -1, infinite GOP) before
|
||||
// `open_with`, which ffmpeg-next has no setter for. No aliasing; synchronous scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).gop_size = -1;
|
||||
}
|
||||
@@ -214,6 +240,10 @@ impl NvencEncoder {
|
||||
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
|
||||
// Windows NV12 path's BT.709 limited-range signalling.
|
||||
if matches!(format, PixelFormat::Nv12) {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
// Characteristic` variants before `open_with`. Sole owner → no aliasing; synchronous writes.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
@@ -228,7 +258,17 @@ impl NvencEncoder {
|
||||
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
||||
let cuda_hw = if cuda {
|
||||
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
|
||||
// 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
|
||||
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
|
||||
// and `width`/`height` are the validated positive dims. It returns a RAII `CudaHw` wrapping
|
||||
// (not owning) `cu_ctx` and owning two `AVBufferRef`s freed on drop.
|
||||
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
|
||||
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, sole-owned, not-yet-opened
|
||||
// `AVCodecContext`. We set `pix_fmt = CUDA` and attach NEW refs (`av_buffer_ref`) of
|
||||
// `hw.device_ref`/`hw.frames_ref` — both non-null (`CudaHw::new` guarantees) and from the
|
||||
// live `hw`, which is moved into `NvencEncoder.cuda` next to `enc` and so outlives the
|
||||
// encoder. The context owns its own refs (freed when the context closes). No aliasing.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
@@ -428,6 +468,19 @@ impl NvencEncoder {
|
||||
// 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).
|
||||
crate::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
|
||||
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
|
||||
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
|
||||
// failure we free `f` and bail.
|
||||
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
|
||||
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
|
||||
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
|
||||
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
|
||||
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
|
||||
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
+148
-3
@@ -19,6 +19,8 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||
@@ -133,6 +135,14 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
|
||||
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
|
||||
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
|
||||
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
|
||||
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
|
||||
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
|
||||
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
|
||||
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||
unsafe {
|
||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
||||
@@ -224,6 +234,12 @@ impl VaapiHw {
|
||||
|
||||
impl Drop for VaapiHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
|
||||
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
|
||||
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
|
||||
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
|
||||
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
|
||||
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
@@ -252,7 +268,16 @@ impl CpuInner {
|
||||
) -> Result<Self> {
|
||||
let src_pixel = vaapi_sws_src(format)?;
|
||||
const POOL: c_int = 16;
|
||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
||||
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
||||
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
|
||||
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
|
||||
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
||||
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
|
||||
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
|
||||
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
|
||||
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
|
||||
let enc = unsafe {
|
||||
open_vaapi_encoder(
|
||||
codec,
|
||||
@@ -266,6 +291,12 @@ impl CpuInner {
|
||||
};
|
||||
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
||||
let src_av = pixel_to_av(src_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
|
||||
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
|
||||
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
|
||||
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
|
||||
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
@@ -283,10 +314,23 @@ impl CpuInner {
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→NV12) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
|
||||
// pointer into a libswscale static const coefficient table valid for the whole process, reused
|
||||
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
|
||||
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
|
||||
// synchronous call and no Rust memory is passed.
|
||||
unsafe {
|
||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
|
||||
// fresh pointer, so none of these writes alias anything.
|
||||
let nv12 = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
@@ -329,6 +373,18 @@ impl CpuInner {
|
||||
let h = self.height as usize;
|
||||
let src_row = w * self.src_format.bytes_per_pixel();
|
||||
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
||||
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
|
||||
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
|
||||
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
|
||||
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
|
||||
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
|
||||
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
|
||||
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
|
||||
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
|
||||
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
|
||||
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
|
||||
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
|
||||
unsafe {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
@@ -374,6 +430,12 @@ impl CpuInner {
|
||||
|
||||
impl Drop for CpuInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
|
||||
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
|
||||
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
|
||||
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
|
||||
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
|
||||
// irrelevant to soundness.
|
||||
unsafe {
|
||||
if !self.nv12.is_null() {
|
||||
ffi::av_frame_free(&mut self.nv12);
|
||||
@@ -417,6 +479,31 @@ impl DmabufInner {
|
||||
let drm_fourcc = crate::zerocopy::drm_fourcc(format)
|
||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
||||
let node = render_node();
|
||||
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
|
||||
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
|
||||
// such an object:
|
||||
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
|
||||
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
|
||||
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
|
||||
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
|
||||
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
|
||||
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
|
||||
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
|
||||
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
|
||||
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
|
||||
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
|
||||
// 'static C literals.
|
||||
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
|
||||
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
|
||||
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
|
||||
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
|
||||
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
|
||||
// live ref) and `av_buffer_ref`s both into the encoder.
|
||||
// Every early-error path unref's the allocated buffers and frees the graph in the right order
|
||||
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
|
||||
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
|
||||
// the final `?`.)
|
||||
unsafe {
|
||||
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
|
||||
// hwmap/scale_vaapi/the encoder.
|
||||
@@ -509,7 +596,12 @@ impl DmabufInner {
|
||||
num: 1,
|
||||
den: fps as c_int,
|
||||
};
|
||||
(*par).hw_frames_ctx = ffi::av_buffer_ref(drm_frames);
|
||||
// Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
|
||||
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
|
||||
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
|
||||
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
|
||||
// that extra ref every session (the persistent listener would accumulate them).
|
||||
(*par).hw_frames_ctx = drm_frames;
|
||||
let r = ffi::av_buffersrc_parameters_set(src, par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if r < 0 {
|
||||
@@ -564,7 +656,12 @@ impl DmabufInner {
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("filter sink has no VAAPI frames context");
|
||||
}
|
||||
let enc = open_vaapi_encoder(
|
||||
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
|
||||
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
|
||||
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
|
||||
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
|
||||
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
|
||||
let enc = match open_vaapi_encoder(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
@@ -572,7 +669,16 @@ impl DmabufInner {
|
||||
bitrate_bps,
|
||||
vaapi_device,
|
||||
nv12_ctx,
|
||||
)?;
|
||||
) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
@@ -600,6 +706,23 @@ impl DmabufInner {
|
||||
dmabuf.fourcc,
|
||||
self.fourcc
|
||||
);
|
||||
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
|
||||
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
|
||||
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
|
||||
// `Box` puts it on the heap with a unique owner.
|
||||
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
|
||||
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
|
||||
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
|
||||
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
|
||||
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
|
||||
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
@@ -626,6 +749,11 @@ impl DmabufInner {
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
// which outlives this call — the graph reads the surface before submit returns).
|
||||
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
||||
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
|
||||
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
|
||||
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
|
||||
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
|
||||
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
|
||||
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
||||
}
|
||||
(*drm).buf[0] = ffi::av_buffer_create(
|
||||
@@ -673,6 +801,13 @@ impl DmabufInner {
|
||||
|
||||
impl Drop for DmabufInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
|
||||
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
|
||||
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
|
||||
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
|
||||
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
|
||||
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
|
||||
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
|
||||
unsafe {
|
||||
ffi::avfilter_graph_free(&mut self.graph);
|
||||
ffi::av_buffer_unref(&mut self.drm_frames);
|
||||
@@ -703,6 +838,13 @@ pub struct VaapiEncoder {
|
||||
}
|
||||
|
||||
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
|
||||
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
|
||||
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
|
||||
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
|
||||
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
|
||||
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
|
||||
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
|
||||
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||
unsafe impl Send for VaapiEncoder {}
|
||||
|
||||
impl VaapiEncoder {
|
||||
@@ -720,6 +862,9 @@ impl VaapiEncoder {
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
// is a valid level and there are no pointer args. libav was just initialized by the
|
||||
// `ffmpeg::init()` above, so the call is always sound.
|
||||
unsafe { ffi::av_log_set_level(48) };
|
||||
}
|
||||
// Validate the codec/format up front so a bad request fails at open, not on the first frame.
|
||||
File diff suppressed because it is too large
Load Diff
+347
-10
@@ -13,7 +13,10 @@
|
||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but
|
||||
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -30,6 +33,11 @@ use nvidia_video_codec_sdk::ENCODE_API as API;
|
||||
// GPU-saturating game; this must be ≥ the helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
||||
const POOL: usize = 8;
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5 for H.264/HEVC). A deeper DPB
|
||||
/// lets an invalidated reference fall back to an older still-valid frame instead of a full IDR;
|
||||
/// `numRefL0 = 1` keeps each P-frame single-reference for low latency.
|
||||
const RFI_DPB: u32 = 5;
|
||||
|
||||
fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
match codec {
|
||||
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
|
||||
@@ -40,6 +48,7 @@ fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
|
||||
pub struct NvencD3d11Encoder {
|
||||
encoder: *mut c_void,
|
||||
codec: Codec,
|
||||
codec_guid: nv::GUID,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -52,6 +61,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||
hdr: bool,
|
||||
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
|
||||
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
|
||||
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
|
||||
/// [`Encoder::set_hdr_meta`], so a mid-session regrade is picked up on the next keyframe.
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// Registrations of the capturer's input textures, cached by texture raw pointer — NVENC encodes
|
||||
/// them in place (no per-frame copy). The cloned `ID3D11Texture2D` keeps each alive until we
|
||||
/// unregister it (the capturer may drop its copy on a device recreate before our teardown runs).
|
||||
@@ -63,13 +77,29 @@ pub struct NvencD3d11Encoder {
|
||||
frame_idx: i64,
|
||||
force_kf: bool,
|
||||
inited: bool,
|
||||
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring (Apollo's
|
||||
/// `get_encoder_cap`): gates 10-bit/custom-VBV/RFI on what this card actually supports instead
|
||||
/// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
|
||||
rfi_supported: bool,
|
||||
custom_vbv: bool,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
|
||||
/// loss event (the client resends until it sees recovery).
|
||||
last_rfi_range: Option<(i64, i64)>,
|
||||
/// Raw ptr of the D3D11 device this session was initialized with. The capturer recreates the
|
||||
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
|
||||
/// tear down and re-init NVENC against it.
|
||||
init_device: *mut c_void,
|
||||
}
|
||||
|
||||
// Raw NVENC handle + COM ptrs; confined to the single encode thread (like the Linux encoder).
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
|
||||
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
|
||||
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads. This
|
||||
// encoder is owned by exactly one thread: it is moved onto the host encode thread once at
|
||||
// construction, and every NVENC call and D3D11 access happens only from that thread thereafter
|
||||
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop` all run there, like the Linux encoder). Moving the
|
||||
// handles across that single ownership-transfer boundary is sound because no NVENC/D3D11 call is in
|
||||
// flight during the move and the session and its D3D11 immediate context are never shared (`&`) or
|
||||
// used concurrently — so `Send` introduces no data race on the non-`Send` fields.
|
||||
unsafe impl Send for NvencD3d11Encoder {}
|
||||
|
||||
impl NvencD3d11Encoder {
|
||||
@@ -84,6 +114,7 @@ impl NvencD3d11Encoder {
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
encoder: ptr::null_mut(),
|
||||
codec,
|
||||
codec_guid: codec_guid(codec),
|
||||
width,
|
||||
height,
|
||||
@@ -92,6 +123,7 @@ impl NvencD3d11Encoder {
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
bit_depth,
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
regs: HashMap::new(),
|
||||
next: 0,
|
||||
bitstreams: Vec::new(),
|
||||
@@ -99,6 +131,9 @@ impl NvencD3d11Encoder {
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
last_rfi_range: None,
|
||||
init_device: ptr::null_mut(),
|
||||
})
|
||||
}
|
||||
@@ -128,6 +163,88 @@ impl NvencD3d11Encoder {
|
||||
self.encoder = ptr::null_mut();
|
||||
self.inited = false;
|
||||
self.next = 0;
|
||||
// The new session starts with an empty DPB (its first frame is an IDR), so any prior
|
||||
// invalidation range is meaningless against it.
|
||||
self.last_rfi_range = None;
|
||||
}
|
||||
|
||||
/// Query one `NV_ENC_CAPS` value for this codec on an open session; 0 on any error (treat an
|
||||
/// unqueryable cap as "unsupported", the conservative choice).
|
||||
unsafe fn get_cap(&self, enc: *mut c_void, which: nv::NV_ENC_CAPS) -> i32 {
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: which,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
match (API.get_encode_caps)(enc, self.codec_guid, &mut param, &mut val)
|
||||
.result_without_string()
|
||||
{
|
||||
Ok(()) => val,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe this GPU's real capabilities once (Apollo's `get_encoder_cap`) before the bitrate-probe
|
||||
/// loop configures the session: opens a throwaway session, queries the codec's max dimensions +
|
||||
/// 10-bit / custom-VBV / ref-pic-invalidation support, destroys it. Rejects an out-of-range mode
|
||||
/// up front with a clear error, downgrades 10-bit→8-bit when unsupported, and records the
|
||||
/// RFI/custom-VBV flags the config + [`invalidate_ref_frames`](Encoder::invalidate_ref_frames)
|
||||
/// gate on. Without this, an unsupported config surfaces only as an opaque `InvalidParam` that
|
||||
/// the bitrate-clamp search misreads as "bitrate too high" and binary-searches into the floor.
|
||||
unsafe fn query_caps(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
|
||||
device: device.as_raw(),
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(API.open_encode_session_ex)(&mut params, &mut enc)
|
||||
.result_without_string()
|
||||
.map_err(|e| {
|
||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
||||
})?;
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
||||
let rfi = self.get_cap(
|
||||
enc,
|
||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_REF_PIC_INVALIDATION,
|
||||
);
|
||||
let custom_vbv = self.get_cap(
|
||||
enc,
|
||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
||||
);
|
||||
let _ = (API.destroy_encoder)(enc);
|
||||
|
||||
// Reject an over-range mode with a clear message instead of an opaque InvalidParam.
|
||||
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
||||
bail!(
|
||||
"this GPU's NVENC max encode size for {:?} is {wmax}x{hmax}; client requested \
|
||||
{}x{} (lower the client resolution or use a codec/GPU that supports it)",
|
||||
self.codec,
|
||||
self.width,
|
||||
self.height
|
||||
);
|
||||
}
|
||||
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
|
||||
if self.bit_depth >= 10 && ten_bit == 0 {
|
||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||
self.bit_depth = 8;
|
||||
self.hdr = false;
|
||||
}
|
||||
self.rfi_supported = rfi != 0;
|
||||
self.custom_vbv = custom_vbv != 0;
|
||||
tracing::info!(
|
||||
rfi = self.rfi_supported,
|
||||
custom_vbv = self.custom_vbv,
|
||||
max = %format!("{wmax}x{hmax}"),
|
||||
ten_bit = ten_bit != 0,
|
||||
"NVENC capabilities probed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
@@ -181,10 +298,13 @@ impl NvencD3d11Encoder {
|
||||
let bps = bitrate.min(u32::MAX as u64) as u32;
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling.
|
||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
||||
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
||||
if self.custom_vbv {
|
||||
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
|
||||
// HIGH tier + autoselect level. The codec's PER-LEVEL bitrate ceiling is otherwise the
|
||||
// MAIN-tier cap — for HEVC at 5K that's Level 6.2 Main ≈ 240 Mbps. HIGH tier lifts the HEVC
|
||||
@@ -200,16 +320,69 @@ impl NvencD3d11Encoder {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // 10 - 8
|
||||
}
|
||||
|
||||
// HDR colour signaling: BT.2020 primaries + SMPTE ST 2084 (PQ) in the HEVC VUI.
|
||||
// HDR colour signaling: BT.2020 primaries + SMPTE ST.2084 (PQ) transfer + BT.2020-NCL
|
||||
// matrix, limited (studio) range — NVENC's RGB→YUV default. HEVC/H.264 carry it in the VUI;
|
||||
// AV1 has NO VUI, so the SAME CICP code points go in the sequence-header colour config
|
||||
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`). Without
|
||||
// this a non-HEVC decoder assumes BT.709 SDR → washed-out / colour-shifted HDR.
|
||||
//
|
||||
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
|
||||
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
|
||||
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
|
||||
// sets Main10 above today).
|
||||
if self.hdr {
|
||||
let prim = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
|
||||
let trc =
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084;
|
||||
let mat = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0; // limited (studio) range — NVENC RGB→YUV default
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
|
||||
vui.transferCharacteristics =
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084;
|
||||
vui.colourMatrix = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0; // studio/limited swing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-frame invalidation: keep a deeper DPB so an invalidated reference can fall back
|
||||
// to an older still-valid frame instead of a full IDR, while `numRefL0 = 1` keeps each
|
||||
// P-frame single-reference for low latency. Only when this GPU supports RFI (else leave the
|
||||
// preset default — `invalidate_ref_frames` then returns false and the caller forces an IDR).
|
||||
if self.rfi_supported {
|
||||
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
|
||||
match self.codec {
|
||||
Codec::H264 => {
|
||||
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
|
||||
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
|
||||
}
|
||||
Codec::H265 => {
|
||||
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
|
||||
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
@@ -241,7 +414,22 @@ impl NvencD3d11Encoder {
|
||||
|
||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||
// SAFETY: every call below goes through a function pointer resolved once from the loaded
|
||||
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
|
||||
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
||||
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
|
||||
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
|
||||
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
|
||||
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
|
||||
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
|
||||
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
|
||||
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
|
||||
// escapes the encode thread.
|
||||
unsafe {
|
||||
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
|
||||
// gated on what this card supports and an out-of-range mode fails with a clear error
|
||||
// rather than being misread as a too-high bitrate by the clamp search.
|
||||
self.query_caps(device)?;
|
||||
// Bitrate clamp (see the search below): NVENC rejects `initialize_encoder` when the bitrate
|
||||
// exceeds the GPU's max codec level. We try the requested rate, then binary-search down to
|
||||
// the MAX the level accepts and clamp to it — so an over-asking client (e.g. 1 Gbps on HEVC)
|
||||
@@ -423,6 +611,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
new = format!("{}x{}", captured.width, captured.height),
|
||||
"NVENC: capture device/size/HDR changed — re-initializing session"
|
||||
);
|
||||
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
|
||||
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
|
||||
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
|
||||
// live session every cached resource was created against, and the previous frame's encode
|
||||
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
|
||||
unsafe { self.teardown() };
|
||||
}
|
||||
if !self.inited {
|
||||
@@ -443,7 +636,14 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bit_depth = 10;
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
}
|
||||
PixelFormat::Nv12 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
||||
PixelFormat::Nv12 => {
|
||||
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
|
||||
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
|
||||
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
|
||||
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
|
||||
self.bit_depth = 8;
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
||||
}
|
||||
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
};
|
||||
let device = frame.device.clone();
|
||||
@@ -452,6 +652,21 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
let slot = self.next % POOL;
|
||||
self.next += 1;
|
||||
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table
|
||||
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
|
||||
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
|
||||
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
|
||||
// `frame.device`, which is the SAME device the session was opened against (any device change
|
||||
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
|
||||
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
|
||||
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
|
||||
// in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
|
||||
// version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
|
||||
// pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
|
||||
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
|
||||
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
|
||||
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
|
||||
// sound because the encode loop is synchronous, as the module docs state.)
|
||||
unsafe {
|
||||
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
|
||||
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
|
||||
@@ -508,6 +723,51 @@ impl Encoder for NvencD3d11Encoder {
|
||||
encodePicFlags: flags as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// In-band HDR10 SEI on every IDR (a forced keyframe, or the first frame NVENC opens with):
|
||||
// `mastering_display_colour_volume` (ST.2086) + `content_light_level_info` (CEA-861.3),
|
||||
// built from the source display's metadata. Any decoder — incl. stock Moonlight — then
|
||||
// tone-maps from the real grade. HEVC/H.264 carry SEI; AV1 uses metadata OBUs (follow-up).
|
||||
// The scratch buffers must outlive `encode_picture`, so they live in this scope.
|
||||
let is_idr = flags != 0 || pts == 0;
|
||||
let mastering_sei = self
|
||||
.hdr_meta
|
||||
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
|
||||
let cll_sei = self
|
||||
.hdr_meta
|
||||
.map(|m| crate::hdr::hevc_content_light_level_sei(&m));
|
||||
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
||||
if is_idr && self.hdr {
|
||||
if let Some(p) = mastering_sei.as_ref() {
|
||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||
payloadSize: p.len() as u32,
|
||||
payloadType: crate::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||
payload: p.as_ptr() as *mut u8,
|
||||
});
|
||||
}
|
||||
if let Some(p) = cll_sei.as_ref() {
|
||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||
payloadSize: p.len() as u32,
|
||||
payloadType: crate::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
||||
payload: p.as_ptr() as *mut u8,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !sei.is_empty() {
|
||||
// Writing a union field is safe; the pointers/len are read during encode_picture.
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
pic.codecPicParams.hevcPicParams.seiPayloadArray = sei.as_mut_ptr();
|
||||
pic.codecPicParams.hevcPicParams.seiPayloadArrayCnt = sei.len() as u32;
|
||||
}
|
||||
Codec::H264 => {
|
||||
pic.codecPicParams.h264PicParams.seiPayloadArray = sei.as_mut_ptr();
|
||||
pic.codecPicParams.h264PicParams.seiPayloadArrayCnt = sei.len() as u32;
|
||||
}
|
||||
// AV1 mastering/CLL ride METADATA OBUs, not SEI — separate follow-up.
|
||||
Codec::Av1 => {}
|
||||
}
|
||||
}
|
||||
(API.encode_picture)(self.encoder, &mut pic)
|
||||
.result_without_string()
|
||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||
@@ -521,10 +781,82 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||||
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
||||
// frame; only changes when the source is regraded or HDR toggles.
|
||||
self.hdr_meta = meta;
|
||||
}
|
||||
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// No live session, the GPU can't invalidate, or a nonsense range → caller forces a full IDR.
|
||||
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
|
||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — nothing more to do, no IDR.
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// `frame_idx` is the NEXT timestamp to assign, so the last encoded frame is `frame_idx - 1`
|
||||
// and the DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`. A lost frame older than that
|
||||
// can't be invalidated, so the only correct recovery is an IDR.
|
||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return false;
|
||||
}
|
||||
// Clamp to frames we've actually encoded (don't invalidate a timestamp we never assigned).
|
||||
let last = last.min(self.frame_idx - 1);
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
|
||||
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame
|
||||
// range maps 1:1 onto the timestamps NVENC invalidates here.
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table.
|
||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
||||
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
||||
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
|
||||
// lifetime concern.
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (API.invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.result_without_string()
|
||||
.is_err()
|
||||
{
|
||||
return false; // any failure → fall back to IDR
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
||||
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
|
||||
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
|
||||
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
|
||||
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
|
||||
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
|
||||
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
|
||||
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
|
||||
// same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
|
||||
// `pending`) is unmapped here, after the encode completed, exactly once.
|
||||
unsafe {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
@@ -564,6 +896,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
|
||||
impl Drop for NvencD3d11Encoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
|
||||
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
|
||||
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
|
||||
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
|
||||
// pending was created against that live session. It runs exactly once (here).
|
||||
unsafe { self.teardown() };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user