Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0890cf3244 | ||
|
|
454fa2e0cb | ||
|
|
9f1f23eb40 | ||
|
|
d1c4cb18dd | ||
|
|
91aa684f0d | ||
|
|
e629606e39 | ||
|
|
ff5602361f | ||
|
|
5e319f3b77 | ||
|
|
34ad3cc611 | ||
|
|
857d7d7b6b | ||
|
|
f3c0ee47d7 | ||
|
|
80b4eccff9 | ||
|
|
63a4f583b9 | ||
|
|
290d760ea4 | ||
|
|
69f1db5ea9 | ||
|
|
7331be0a40 | ||
|
|
4bc7eecf05 | ||
|
|
dbc12dedcc | ||
|
|
2dfb7791a2 | ||
|
|
5e19a4611f | ||
|
|
6f54fcdd2d | ||
|
|
c6597cbeb5 | ||
|
|
2cfc82e96c | ||
|
|
e9a209ef61 | ||
|
|
a12f1f092c | ||
|
|
3055e29ebb | ||
|
|
7077b0a0df | ||
|
|
e5453aebb7 | ||
|
|
2c03290a5e | ||
|
|
b6a370a0fd | ||
|
|
7db83445b2 | ||
|
|
5582a6ea51 | ||
|
|
f7b85ec1fd | ||
|
|
327301e012 | ||
|
|
ab4cd06e86 | ||
|
|
3eab1e41df | ||
|
|
62573d2781 | ||
|
|
d383fa6103 | ||
|
|
93608980ae | ||
|
|
1feeff3ca6 | ||
|
|
1ae8b4d4ca | ||
|
|
33ecd8e1a5 | ||
|
|
48565c4e9e | ||
|
|
e9a7373c76 | ||
|
|
f7a8c2013d | ||
|
|
926e2ccbdd | ||
|
|
b8b38d082e | ||
|
|
9979489b56 | ||
|
|
14502769e0 | ||
|
|
db1faef9fb | ||
|
|
442ea12b96 | ||
|
|
1e56705b86 | ||
|
|
365caa23be | ||
|
|
f71bee917b | ||
|
|
6de78213ee | ||
|
|
aa3bcfd0d0 | ||
|
|
6b3c582eb1 | ||
|
|
e08474d96d | ||
|
|
f422ae3e38 | ||
|
|
e38e3c44c9 | ||
|
|
b1ac4d02de | ||
|
|
5f55fa874a | ||
|
|
8af6e2dd02 | ||
|
|
d839f4c2b6 | ||
|
|
0de161e29b | ||
|
|
b297542c4d | ||
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
b6acbd096e | ||
|
|
d63e913f52 | ||
|
|
362595b20f | ||
|
|
caa47e28e6 | ||
|
|
652abeb397 | ||
|
|
48511d1267 | ||
|
|
3e649d372e | ||
|
|
0d004c4680 | ||
|
|
8d7e273a96 | ||
|
|
e726542f96 | ||
|
|
e0427a3bb6 | ||
|
|
02a5bdb965 | ||
|
|
09b9ee8f53 | ||
|
|
43e3c7b69f |
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# 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`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -122,11 +173,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +259,21 @@ jobs:
|
||||
# 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.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- 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 "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
Generated
+32
-32
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2897,7 +2897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2936,7 +2936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2960,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2969,7 +2969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2981,7 +2981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2995,11 +2995,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3028,14 +3028,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3058,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3103,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3115,7 +3115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3323,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3334,7 +3334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3382,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3402,7 +3402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3434,7 +3434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3519,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+90
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.22.3"
|
||||
"version": "0.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2170,6 +2170,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -6238,6 +6283,50 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
|
||||
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
|
||||
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -72,6 +74,8 @@ private class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -401,8 +471,15 @@ private fun buildSettingsRows(
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
@@ -428,3 +505,62 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ data class SettingsOverlay(
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -76,6 +77,7 @@ data class SettingsOverlay(
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -110,6 +112,9 @@ data class SettingsOverlay(
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -136,6 +141,7 @@ data class SettingsOverlay(
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -159,6 +165,7 @@ data class SettingsOverlay(
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -190,6 +197,7 @@ data class SettingsOverlay(
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -205,7 +213,8 @@ data class SettingsOverlay(
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
@@ -227,6 +236,7 @@ data class SettingsOverlay(
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -34,6 +34,17 @@ data class Settings(
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all. Default on — that was the
|
||||
* unconditional behaviour before this became a setting.
|
||||
*
|
||||
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
|
||||
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
|
||||
* there gives the host two controllers for one pair of hands, and games read both. It also
|
||||
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -216,6 +227,7 @@ class SettingsStore(context: Context) {
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -262,6 +274,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -291,6 +304,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
|
||||
@@ -818,11 +818,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
@Composable
|
||||
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsGroup(footer = "Applies from the next session.") {
|
||||
// The master switch, above everything it governs. Profileable, so it shows in both
|
||||
// scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
ToggleRow(
|
||||
title = "Forward controllers",
|
||||
subtitle = "Send this device's controllers to the host. Turn it off when your " +
|
||||
"controller already reaches the host another way — USB passthrough such as " +
|
||||
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
|
||||
checked = s.gamepadForwarding,
|
||||
field = "gamepad_forwarding",
|
||||
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
|
||||
)
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
@@ -852,6 +864,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
|
||||
"trackpads, gyro and haptics directly",
|
||||
checked = s.sc2Capture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
@@ -861,6 +874,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
@@ -1013,6 +1027,7 @@ private fun <T> SettingDropdown(
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -1020,18 +1035,25 @@ private fun <T> SettingDropdown(
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = { if (enabled) expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -321,7 +321,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
@@ -442,7 +444,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||
activity?.stopSc2MenuNav()
|
||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
|
||||
Sc2Capture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
@@ -492,7 +498,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
|
||||
DsCapture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -33,7 +33,24 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val setting: Int,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
* default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
@@ -123,7 +140,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
@@ -136,7 +155,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
@@ -186,7 +207,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
if (forwarding) slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -221,24 +242,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -260,7 +283,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
@@ -317,7 +340,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
@@ -330,7 +353,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
@@ -342,11 +365,11 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -104,6 +100,7 @@ struct Counters {
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -126,10 +123,9 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
@@ -157,8 +153,10 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -173,21 +171,25 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -196,20 +198,15 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -408,10 +405,11 @@ fn decode_loop(
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
GamepadSettingsView(store: store)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ private struct ShotGamepadHome: View {
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
|
||||
var body: some View { GamepadSettingsView(store: store) }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
|
||||
@@ -672,7 +672,11 @@ final class SessionModel: ObservableObject {
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||||
// per-device catalog the detail strings are honest about).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -21,11 +29,16 @@ import CoreHaptics
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -51,6 +64,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||||
/// interface shows up live.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@@ -61,6 +78,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
@@ -71,7 +91,7 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
onBack: { back() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -79,7 +99,7 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -95,11 +115,7 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
@@ -137,6 +153,43 @@ struct GamepadSettingsView: View {
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||||
/// profile row it came from — then the screen itself.
|
||||
private func back() {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
@@ -163,7 +216,7 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -184,7 +237,7 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
@@ -218,6 +271,9 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -237,6 +293,9 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -323,8 +382,15 @@ struct GamepadSettingsView: View {
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
) { gamepads.preferredID = $0 },
|
||||
@@ -386,7 +452,98 @@ struct GamepadSettingsView: View {
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
// MARK: - Profiles (§5.2a)
|
||||
|
||||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||||
/// left/right is a boundary thud, not an editor.
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the picker's first row — the focus list's reconcile
|
||||
// follows this id when the row set swaps underneath it.
|
||||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||||
pinTarget = profile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return store.hosts.map { host in
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||||
return true
|
||||
},
|
||||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||||
private var profileDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||||
+ "one press connects with it."
|
||||
#else
|
||||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||||
private var emptyCatalogDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||||
+ "available on Apple TV yet."
|
||||
#else
|
||||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||||
+ "standard interface, then pin them here as one-press connect cards."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -122,6 +122,10 @@ enum SettingsFields {
|
||||
.init(name: "gamepad", key: DefaultsKey.gamepadType,
|
||||
overlay: \.gamepadType, effective: \.gamepadType)
|
||||
}
|
||||
static var gamepadForwarding: SettingsField<Bool> {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
@@ -181,6 +185,7 @@ extension SettingsView {
|
||||
base.micEnabled = micEnabled
|
||||
base.echoCancel = echoCancel
|
||||
base.gamepadType = gamepadType
|
||||
base.gamepadForwarding = gamepadForwarding
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
base.presentPriority = presentPriority
|
||||
|
||||
@@ -641,6 +641,15 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
// The master switch, above everything it governs. Profileable, so it renders in
|
||||
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
described("Sends controllers connected to this device to the host. Turn it off when "
|
||||
+ "your controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
|
||||
+ "two of them.",
|
||||
field: "gamepad_forwarding") {
|
||||
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
|
||||
}
|
||||
// Which physical pad this device forwards, and what its own haptics do, are facts
|
||||
// about THIS device (tier G) — only the virtual pad the host creates is profileable.
|
||||
if !inProfileScope {
|
||||
@@ -659,6 +668,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
}
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
@@ -669,6 +679,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
|
||||
@@ -49,6 +49,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
|
||||
@@ -3,28 +3,66 @@ import os
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// reads return silence until enough is buffered (at least the target, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime).
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
///
|
||||
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
|
||||
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
|
||||
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
|
||||
/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one
|
||||
/// audible blip". It is now the same two-stage scheme the Rust clients share
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
/// …and must stay there for this much consumed audio. Long, because a shed is the only thing
|
||||
/// here a listener could notice; it must never fire on a transient.
|
||||
private static let shedSustainMS = 2_000
|
||||
private static let crossfadeMS = 2
|
||||
/// Time constant of the depth average.
|
||||
private static let ewmaTauMS = 1_000
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private var emptyReads = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
|
||||
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
/// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames).
|
||||
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
|
||||
init(capacity: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
perMS = 48 * channels
|
||||
}
|
||||
|
||||
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
|
||||
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
buf[(writeIdx + i) % capacity] = samples[i]
|
||||
}
|
||||
writeIdx += count
|
||||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||||
// Shedding down to 2× prefill costs one audible blip instead.
|
||||
if writeIdx - readIdx > highWater {
|
||||
readIdx = writeIdx - prefill * 2
|
||||
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
|
||||
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
|
||||
defer { lock.unlock() }
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
|
||||
// Depth average, weighted by the callback size so its time constant is independent of the
|
||||
// device quantum.
|
||||
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
|
||||
depthAvg += (Double(available) - depthAvg) * alpha
|
||||
|
||||
if !primed {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
let n = min(available, count)
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
overRun += count
|
||||
if overRun >= Self.shedSustainMS * perMS {
|
||||
overRun = 0
|
||||
shedOneFrame()
|
||||
shedCount += 1
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
}
|
||||
} else {
|
||||
overRun = 0
|
||||
}
|
||||
|
||||
let n = min(writeIdx - readIdx, count)
|
||||
let capacity = buf.count
|
||||
for i in 0..<n {
|
||||
out[i] = buf[(readIdx + i) % capacity]
|
||||
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
|
||||
readIdx += n
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
primed = false // underrun — re-prime before resuming
|
||||
// De-prime only after a RUN of short reads: a single transient drain must not
|
||||
// manufacture a whole target's worth of fresh silence.
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter { primed = false }
|
||||
} else {
|
||||
emptyReads = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
// The tail of what we discard fades out into the head of what survives.
|
||||
for i in 0..<fade {
|
||||
let old = buf[(readIdx + drop - fade + i) % capacity]
|
||||
let new = buf[(readIdx + drop + i) % capacity]
|
||||
let t = Float(i + 1) / Float(fade + 1)
|
||||
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
|
||||
}
|
||||
}
|
||||
readIdx += drop
|
||||
}
|
||||
|
||||
/// Current buffered depth in milliseconds — for the stats overlay and the drain thread's
|
||||
/// periodic log.
|
||||
var bufferedMS: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (writeIdx - readIdx) / max(perMS, 1)
|
||||
}
|
||||
|
||||
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
|
||||
/// a log line describe the same instant. Mirrors what the three Rust clients report.
|
||||
struct Stats {
|
||||
let bufferedMS: Int
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Stats(
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
|
||||
@@ -317,10 +317,10 @@ public final class SessionAudio {
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = self.ring ?? AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
@@ -403,6 +403,7 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -421,6 +422,17 @@ public final class SessionAudio {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,27 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
/// forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
|
||||
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
|
||||
/// is the ONLY controller way out of a stream — a session that silently lost its exit
|
||||
/// because a forwarding preference was off would be a worse bug than the one this fixes.
|
||||
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
|
||||
/// open costs the host nothing and blocks no passthrough tool.
|
||||
public let forwarding: Bool
|
||||
|
||||
/// The connection, or nil while forwarding is off — every wire send goes through this, so
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -205,8 +223,8 @@ public final class GamepadCapture {
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
@@ -233,7 +251,7 @@ public final class GamepadCapture {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
wire?.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
@@ -275,7 +293,7 @@ public final class GamepadCapture {
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
@@ -288,7 +306,7 @@ public final class GamepadCapture {
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
@@ -302,7 +320,7 @@ public final class GamepadCapture {
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
@@ -365,13 +383,13 @@ public final class GamepadCapture {
|
||||
if lifted {
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
@@ -394,7 +412,7 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
@@ -432,15 +450,15 @@ public final class GamepadCapture {
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,56 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
//
|
||||
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
|
||||
// 1. the burst below, fired the instant the drop is observed — wins back a lock the system
|
||||
// is willing to return immediately (a transient drop that wasn't Escape at all);
|
||||
// 2. a CLICK into the video while still captured (`onPointerButton`) — the fallback for the
|
||||
// Escape case proper, where the platform declines during the moment right after its own
|
||||
// release gesture and the burst therefore expires having achieved nothing.
|
||||
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
|
||||
// other path would ever ask again, and the capture would spend the rest of its life on the
|
||||
// absolute pointer — clicking correctly, aiming not at all.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -260,7 +310,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -383,6 +433,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -401,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
// …and if we're captured but NOT locked, this click is also the recovery gesture for an
|
||||
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
|
||||
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
|
||||
// and give up while the capture is still wanted. Nothing else would ever re-ask —
|
||||
// setCaptured is the only other requester and a bare Esc never clears `captured` — so
|
||||
// without this the session stays on the absolute path for the rest of the capture:
|
||||
// clicks still land where you aim (absolute positions keep forwarding) but the game
|
||||
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
|
||||
// which is exactly what the platform wants before it will hand the lock back.
|
||||
//
|
||||
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
|
||||
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
|
||||
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
|
||||
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
|
||||
// already being in flight — a pending burst mutes absolute motion, so re-arming one on
|
||||
// every click of a menu the user is still aiming around would freeze the cursor between
|
||||
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
|
||||
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
|
||||
// may have just spent).
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
@@ -693,6 +773,24 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -704,7 +802,83 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -724,7 +898,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -32,6 +32,12 @@ public enum DefaultsKey {
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
/// Forward this device's controllers to the host at all (default true). Off is for a
|
||||
/// couch whose controller reaches the host another way — USB passthrough such as
|
||||
/// VirtualHere, or a pad plugged into the host — where forwarding as well would give the
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
|
||||
@@ -34,6 +34,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -93,6 +94,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -140,6 +142,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -110,6 +110,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -151,6 +152,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -184,6 +186,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -219,6 +222,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -271,6 +276,7 @@ public enum OverlayField {
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -306,6 +312,7 @@ public enum OverlayField {
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
|
||||
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
|
||||
// one client where the policy is hand-written in a second language rather than shared as code — so
|
||||
// a divergence from the Rust side shows up here rather than as a field report.
|
||||
//
|
||||
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
|
||||
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
|
||||
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once —
|
||||
// its own comment called that "one audible blip".
|
||||
|
||||
#if !os(tvOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRingDriftTests: XCTestCase {
|
||||
private let channels = 2
|
||||
private var perMS: Int { 48 * channels }
|
||||
|
||||
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
|
||||
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
|
||||
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Non-zero so a silent callback is distinguishable from real audio.
|
||||
let producer = [Float](repeating: 0.25, count: want + 8)
|
||||
var carry = 0, peak = 0, final = 0, silent = 0
|
||||
|
||||
for i in 0..<(ms / quantumMS) {
|
||||
carry += want * driftPPM
|
||||
let extra = carry / 1_000_000
|
||||
carry -= extra * 1_000_000
|
||||
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
|
||||
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
// Skip the priming window at the very start.
|
||||
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
|
||||
peak = max(peak, ring.bufferedMS)
|
||||
final = ring.bufferedMS
|
||||
}
|
||||
return (final, peak, silent)
|
||||
}
|
||||
|
||||
/// THE regression: with the host clock running fast, buffered latency must return to target
|
||||
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
|
||||
/// harsher than real hardware (tens of ppm).
|
||||
func testDriftDoesNotRatchetLatencyToTheCeiling() {
|
||||
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
|
||||
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
|
||||
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
|
||||
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
/// its target rather than oscillating prime → dropout → re-prime forever.
|
||||
func testLargeDeviceQuantumStillPlays() {
|
||||
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
|
||||
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
|
||||
}
|
||||
|
||||
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
|
||||
/// de-primes only after a RUN of short reads.
|
||||
func testSingleShortReadDoesNotDeprime() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime well past target.
|
||||
let big = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 },
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,51 @@
|
||||
# App Store copy
|
||||
|
||||
Source of truth for what goes into App Store Connect. Every character-limited field in here has
|
||||
been counted with `check-limits.py`; run it after any edit.
|
||||
|
||||
```sh
|
||||
python3 clients/apple/store/check-limits.py
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
|
||||
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
|
||||
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
|
||||
|
||||
German is primary throughout and uses the same informal "du" voice as the website
|
||||
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
|
||||
few lines diverge where the German idiom does not carry.
|
||||
|
||||
## Three things that contradicted the original brief
|
||||
|
||||
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
|
||||
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
|
||||
display, or encode backend. The macOS copy is client-only and says so explicitly.
|
||||
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
|
||||
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
|
||||
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
|
||||
the app. See `privacy-app-addendum.md` for the text to append.
|
||||
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
|
||||
template is 3919 and fits.
|
||||
|
||||
## Claims used, and where they come from
|
||||
|
||||
Everything asserted in the copy was checked against the source rather than the marketing site:
|
||||
|
||||
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
|
||||
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
|
||||
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
|
||||
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
|
||||
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
|
||||
symbols in `Sources/`, `URLSession` used only against the paired host
|
||||
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
|
||||
- Feature ship dates — `git tag --contains` on the relevant commits
|
||||
|
||||
## Not done here
|
||||
|
||||
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
|
||||
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
|
||||
alone because it is a code change, not copy.
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check every App Store copy block in this directory against its field limit.
|
||||
|
||||
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
|
||||
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
|
||||
so `len()` on a `str` is the right measure — do not switch this to a byte count.
|
||||
|
||||
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
|
||||
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
|
||||
|
||||
|
||||
def blocks(text: str):
|
||||
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
|
||||
heading = None
|
||||
buf: list[str] | None = None
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("#") and buf is None:
|
||||
heading = line.lstrip("#").strip()
|
||||
if line.strip() == "```":
|
||||
if buf is None:
|
||||
buf = []
|
||||
else:
|
||||
yield heading or "", "\n".join(buf)
|
||||
buf = None
|
||||
continue
|
||||
if buf is not None:
|
||||
buf.append(line)
|
||||
|
||||
|
||||
def kind_of(heading: str, body: str) -> str:
|
||||
low = heading.lower()
|
||||
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
|
||||
return "KW"
|
||||
if "template" in low:
|
||||
return "NOTES"
|
||||
return "DESC" if len(body) > 400 else "PROMO"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
here = pathlib.Path(__file__).parent
|
||||
failures = 0
|
||||
stale = 0
|
||||
for path in sorted(here.glob("*.md")):
|
||||
found = list(blocks(path.read_text(encoding="utf-8")))
|
||||
if not found:
|
||||
continue
|
||||
print(f"\n=== {path.name} ===")
|
||||
for heading, body in found:
|
||||
kind = kind_of(heading, body)
|
||||
limit = LIMITS[kind]
|
||||
n = len(body)
|
||||
over = n > limit
|
||||
failures += over
|
||||
# Headings carry the count in parentheses; flag any that drifted from the real length.
|
||||
claimed = re.search(r"\((\d+)\)\s*$", heading)
|
||||
drift = ""
|
||||
if claimed and int(claimed.group(1)) != n:
|
||||
drift = f" [heading claims {claimed.group(1)}]"
|
||||
stale += 1
|
||||
status = "OVER" if over else "ok"
|
||||
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{failures} block(s) OVER the limit")
|
||||
elif stale:
|
||||
print(f"\nAll within limits, but {stale} heading count(s) are stale")
|
||||
else:
|
||||
print("\nAll blocks within limits, all heading counts accurate")
|
||||
return 1 if failures or stale else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# iOS / iPadOS — App Store metadata
|
||||
|
||||
Existing, unchanged:
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
|
||||
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
|
||||
build or a review, so it is the right place for "what landed most recently".
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook, no "new" claim (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung – ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (161)
|
||||
|
||||
```
|
||||
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
|
||||
```
|
||||
|
||||
### Alternate C — leads on latency (153)
|
||||
|
||||
```
|
||||
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (152)
|
||||
|
||||
```
|
||||
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook (159)
|
||||
|
||||
```
|
||||
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes on the claims
|
||||
|
||||
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
|
||||
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
|
||||
drop the word once 0.25 ships something newer.
|
||||
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
|
||||
*mention* but should not be called "neu".
|
||||
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
|
||||
(`b297542c`), which is too niche to headline.
|
||||
@@ -0,0 +1,159 @@
|
||||
# macOS — App Store metadata
|
||||
|
||||
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
|
||||
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
|
||||
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
|
||||
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
|
||||
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
|
||||
> None of the copy below claims a Mac can host, and it should not until that ships.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (164)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streamt deinen Gaming-PC auf den Mac – in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
|
||||
|
||||
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
|
||||
|
||||
Für jede Verbindung legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox – H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
|
||||
|
||||
FÜR DEN MAC GEMACHT
|
||||
|
||||
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
|
||||
• Maus und Tastatur gehen vollständig durch – Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
|
||||
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
|
||||
• Mikrofon-Uplink mit Echounterdrückung – dein Mac wird zum Headset am PC
|
||||
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
|
||||
|
||||
DEINE BIBLIOTHEK, DEIN NETZWERK
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund – kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
|
||||
|
||||
MESSEN STATT GLAUBEN
|
||||
|
||||
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
|
||||
|
||||
YOUR MAC GETS A DISPLAY OF ITS OWN
|
||||
|
||||
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
|
||||
|
||||
BUILT FOR THE MAC
|
||||
|
||||
• In a window or full screen, on any attached display
|
||||
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
|
||||
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
|
||||
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
|
||||
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
|
||||
|
||||
YOUR LIBRARY, YOUR NETWORK
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
|
||||
|
||||
MEASURED, NOT PROMISED
|
||||
|
||||
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
|
||||
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
|
||||
absent — repeating them would waste characters.
|
||||
|
||||
### DE (97)
|
||||
|
||||
```
|
||||
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
|
||||
```
|
||||
|
||||
### EN (95)
|
||||
|
||||
```
|
||||
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
|
||||
```
|
||||
|
||||
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
|
||||
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
|
||||
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
|
||||
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
|
||||
Moonlight and mention Steam only as a factual statement about your own library.
|
||||
|
||||
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
|
||||
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Privacy — what to link from App Store Connect
|
||||
|
||||
## The situation
|
||||
|
||||
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
|
||||
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
|
||||
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
|
||||
self-hosted fonts. It does not mention the apps at all.
|
||||
|
||||
That is a problem for App Store Connect in two directions:
|
||||
|
||||
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
|
||||
the link finds a page about a website.
|
||||
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
|
||||
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
|
||||
Analytics" and has every reason to question the App Privacy answers.
|
||||
|
||||
**Recommendation:** keep the existing page and append an app-specific section to it (the text
|
||||
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
|
||||
works, but one URL is less to keep in sync.
|
||||
|
||||
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
|
||||
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
|
||||
code change.
|
||||
|
||||
## Confirming the "collects no data" framing
|
||||
|
||||
Checked against the source rather than taken on trust, and it holds:
|
||||
|
||||
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
|
||||
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
|
||||
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
|
||||
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
|
||||
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
|
||||
**from the paired host**, over a TLS session that pins the host's own certificate. The only
|
||||
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
|
||||
the source on `git.unom.io`, and the Discord invite.
|
||||
- **No account system.** Identity is a client keypair in the device keychain
|
||||
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
|
||||
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
|
||||
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
|
||||
entitlement.
|
||||
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
|
||||
|
||||
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
|
||||
stating in the policy text anyway, because they are true and pre-empt questions:
|
||||
|
||||
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
|
||||
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
|
||||
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
|
||||
is better than staying silent about a microphone permission.
|
||||
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
|
||||
Apple's processing, not yours, but naming it avoids looking like an omission.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — Deutsch
|
||||
|
||||
> ## Die Punktfunk-Apps
|
||||
>
|
||||
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
|
||||
> Android – im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
|
||||
>
|
||||
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
|
||||
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
|
||||
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
|
||||
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
|
||||
> übermittelt.
|
||||
>
|
||||
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
|
||||
> selbst betreiben – in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
|
||||
> Controller-Eingaben sowie – sofern Sie ihn einschalten – Ihr Mikrofon werden ausschließlich
|
||||
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
|
||||
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
|
||||
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
|
||||
>
|
||||
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
|
||||
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
|
||||
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
|
||||
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
|
||||
> wenn Sie die App entfernen.
|
||||
>
|
||||
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
|
||||
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und – nur
|
||||
> wenn Sie die Mikrofonübertragung nutzen – das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
|
||||
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
|
||||
> aufgezeichnet und nicht an uns gesendet.
|
||||
>
|
||||
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
|
||||
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
|
||||
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
|
||||
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
|
||||
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
|
||||
>
|
||||
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
|
||||
> Rechner betreiben. Welche Daten dabei anfallen – etwa lokale Protokolldateien –, bleibt
|
||||
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
|
||||
> git.unom.io/unom/punktfunk einsehbar.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — English
|
||||
|
||||
> ## The Punktfunk apps
|
||||
>
|
||||
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
|
||||
> Android — as distinct from the sections above, which concern this website.
|
||||
>
|
||||
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
|
||||
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
|
||||
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
|
||||
> data is transmitted to us or to any third party.
|
||||
>
|
||||
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
|
||||
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
|
||||
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
|
||||
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
|
||||
> streaming, and we have no access to the contents of a session at any point.
|
||||
>
|
||||
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
|
||||
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
|
||||
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
|
||||
> your device and is removed when you delete the app.
|
||||
>
|
||||
> **Permissions.** The app requests only the permissions it needs to work: access to the local
|
||||
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
|
||||
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
|
||||
> virtual microphone; it is not recorded and is not sent to us.
|
||||
>
|
||||
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
|
||||
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
|
||||
> and crash statistics). We have no influence over this, and the respective provider's privacy
|
||||
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
|
||||
> not allow any individual to be identified.
|
||||
>
|
||||
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
|
||||
> data it produces — local log files, for instance — remains entirely under your control, and none
|
||||
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
|
||||
|
||||
---
|
||||
|
||||
## Also update
|
||||
|
||||
- Bump **Stand: / Effective date:** on the page when you add this.
|
||||
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
|
||||
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# App Review notes
|
||||
|
||||
## The core problem, stated plainly
|
||||
|
||||
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
|
||||
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
|
||||
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
|
||||
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
|
||||
on their network sees an empty "On this network" list.
|
||||
|
||||
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
|
||||
attach **one** of:
|
||||
|
||||
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
|
||||
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
|
||||
can add a host by IP or hostname, so mDNS discovery is not required for this path.
|
||||
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
|
||||
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
|
||||
|
||||
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
|
||||
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
|
||||
|
||||
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
|
||||
> section with the video URL and say explicitly that no host can be provided.
|
||||
|
||||
---
|
||||
|
||||
## Notes template — paste into App Store Connect
|
||||
|
||||
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
|
||||
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
|
||||
longer than the values that replace it). If you add to it, re-check the count — an over-long note
|
||||
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
|
||||
live.
|
||||
|
||||
```
|
||||
WHAT THIS APP IS
|
||||
|
||||
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
|
||||
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
|
||||
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
|
||||
and this app has no purchases.
|
||||
|
||||
This app is the client half only: it renders video and audio from the user's own machine and
|
||||
sends input back. There is no content library and no server of ours in a session.
|
||||
|
||||
IMPORTANT: THIS APP NEEDS A HOST
|
||||
|
||||
With no reachable host, the app can only show its host list, the pairing screen and settings --
|
||||
inherent to what it is, not an incomplete build. We have provided a live host for review.
|
||||
|
||||
CONNECTING TO OUR DEMO HOST
|
||||
|
||||
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
|
||||
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
|
||||
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
|
||||
Name it anything, then confirm.
|
||||
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
|
||||
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
|
||||
4. The host's game library appears as a grid. Select any title to stream; video and audio start
|
||||
within a few seconds.
|
||||
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
|
||||
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
|
||||
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
|
||||
"Speed test" suggests a bitrate for the link.
|
||||
|
||||
The host stays reachable throughout review. If you cannot reach it, please contact
|
||||
[[FILL IN: contact email]] and we will restore it promptly.
|
||||
|
||||
WHY THE APP ASKS FOR WHAT IT ASKS FOR
|
||||
|
||||
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
|
||||
entire purpose.
|
||||
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
|
||||
there as a virtual microphone for voice chat. Never recorded, never sent to us.
|
||||
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
|
||||
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
|
||||
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
|
||||
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
|
||||
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
|
||||
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
|
||||
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
|
||||
without this, no video, audio or rumble arrives.
|
||||
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
|
||||
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
|
||||
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
|
||||
silence to stay alive, nor use the mode outside an audible session.
|
||||
|
||||
REGARDING BUILD 0.4.2 (3384)
|
||||
|
||||
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
|
||||
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
|
||||
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
|
||||
no temporary exception.
|
||||
|
||||
ACCOUNTS, PURCHASES, DATA
|
||||
|
||||
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
|
||||
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
|
||||
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
|
||||
|
||||
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before you submit — checklist
|
||||
|
||||
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
|
||||
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
|
||||
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
|
||||
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
|
||||
review window, and that pairing is left open (it is on-demand in the web console).
|
||||
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
|
||||
successful pairing looks like a broken app.
|
||||
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
|
||||
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
|
||||
tvOS rejection.
|
||||
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
|
||||
|
||||
## Separately worth checking: the privacy manifest
|
||||
|
||||
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
|
||||
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
|
||||
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
|
||||
missing a declaration typically get an automated **ITMS-91053** notice on upload.
|
||||
|
||||
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
|
||||
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
|
||||
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
|
||||
submission. Confirm the current reason codes against Apple's documentation rather than taking the
|
||||
code above on trust; the list has changed since it was introduced.
|
||||
@@ -0,0 +1,145 @@
|
||||
# tvOS — App Store metadata
|
||||
|
||||
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
|
||||
and which the copy therefore avoids claiming:
|
||||
|
||||
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
|
||||
headset" line does not transfer.
|
||||
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
|
||||
macOS only — tvOS uses the native focus engine instead.
|
||||
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
|
||||
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
|
||||
|
||||
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
|
||||
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
|
||||
best Apple TV feature, because it is what removes the trip to the other room.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (157)
|
||||
|
||||
```
|
||||
Dein Gaming-PC am großen Bildschirm – in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (148)
|
||||
|
||||
```
|
||||
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht – in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auch headless auf einem Rechner, an dem gar kein Monitor hängt.
|
||||
|
||||
VOM SOFA AUS, VON ANFANG BIS ENDE
|
||||
|
||||
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität – kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
|
||||
|
||||
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
|
||||
|
||||
Für den Apple TV legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller – die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
|
||||
|
||||
DEINE BIBLIOTHEK AUF DEM FERNSEHER
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display – der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
|
||||
|
||||
FROM THE COUCH, START TO FINISH
|
||||
|
||||
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
|
||||
|
||||
THE PICTURE YOUR TV CAN ACTUALLY SHOW
|
||||
|
||||
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
|
||||
|
||||
YOUR LIBRARY ON THE BIG SCREEN
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
### DE (93)
|
||||
|
||||
```
|
||||
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
|
||||
```
|
||||
|
||||
### EN (91)
|
||||
|
||||
```
|
||||
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
|
||||
```
|
||||
|
||||
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
|
||||
+10
-3
@@ -24,8 +24,15 @@ the panel looks and feels native to Gaming Mode.
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
|
||||
@@ -93,7 +100,7 @@ restart is required for an out-of-band install to appear.
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
|
||||
+152
-4
@@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do:
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
``set_settings`` MERGES onto the file: it is shared with the desktop client and the console.
|
||||
* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's
|
||||
device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``)
|
||||
and cached, since enumerating them costs a Vulkan + PipeWire init.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||
@@ -343,6 +347,9 @@ def _flatpak() -> str | None:
|
||||
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
|
||||
# home), so nothing else in this file has to care which one answered.
|
||||
NATIVE_BIN = "punktfunk-client"
|
||||
# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate
|
||||
# this device's GPUs and audio endpoints for the settings pickers.
|
||||
SESSION_BIN = "punktfunk-session"
|
||||
|
||||
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
|
||||
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
|
||||
@@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None:
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _session_argv() -> list[str] | None:
|
||||
"""The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there.
|
||||
|
||||
The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on
|
||||
`punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and
|
||||
shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The
|
||||
flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native
|
||||
install puts them in the same bindir, so the session is the client's sibling.
|
||||
"""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return None
|
||||
if prefix[0] == _flatpak():
|
||||
# `flatpak run --command=<bin> <app>` — the app id must stay LAST.
|
||||
return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]]
|
||||
sibling = Path(prefix[0]).with_name(SESSION_BIN)
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
def _client_is_flatpak() -> bool:
|
||||
"""Is the client this plugin actually drives the FLATPAK one?
|
||||
|
||||
@@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]:
|
||||
"""Split `punktfunk-session --list-audio` into ``(sinks, sources)``.
|
||||
|
||||
Its format is one endpoint per line, ``sink|source<TAB>node.name<TAB>description``. The
|
||||
node.name is what gets STORED (it is the stable id the client resolves against), so a line
|
||||
without one is unusable and dropped; a missing description falls back to the name rather than
|
||||
rendering a picker entry with no label. Anything else on the line is ignored, so an extra
|
||||
trailing column in a future client can't break this.
|
||||
"""
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3 or not parts[1].strip():
|
||||
continue
|
||||
kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
entry = {"name": name, "description": description or name}
|
||||
if kind == "sink":
|
||||
sinks.append(entry)
|
||||
elif kind == "source":
|
||||
sources.append(entry)
|
||||
return sinks, sources
|
||||
|
||||
|
||||
async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]:
|
||||
"""Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when
|
||||
it isn't installed or the call errors/times out.
|
||||
|
||||
Only ever used for the two read-only device enumerations — the launch path goes through the
|
||||
Steam shortcut and the wrapper script, never through here. The timeout is generous because
|
||||
`--list-adapters` initialises Vulkan on a cold flatpak."""
|
||||
prefix = _session_argv()
|
||||
if not prefix:
|
||||
return -1, ""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *session_args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return rc, (out or b"").decode("utf-8", "replace")
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("session %s timed out", " ".join(session_args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("session %s failed", " ".join(session_args))
|
||||
return -1, ""
|
||||
|
||||
|
||||
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||
@@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
_HOSTS_TTL_S = 12.0
|
||||
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||
|
||||
# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading
|
||||
# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices`
|
||||
# clears it for the user who just plugged a headset in.
|
||||
_devices_cache: dict = {"data": None}
|
||||
|
||||
|
||||
def _invalidate_hosts_cache() -> None:
|
||||
_hosts_cache["data"] = None
|
||||
@@ -1044,24 +1132,84 @@ class Plugin:
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad,
|
||||
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
|
||||
"gamepad_forwarding": True, "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
"stats_verbosity": "normal", "show_stats": True,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch.
|
||||
|
||||
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
|
||||
the desktop client and the console's settings screen, and it holds far more keys than
|
||||
this panel models (decoder, GPU, profiles, touch/mouse model…). The panel reads it once
|
||||
when it mounts, so a straight write would post a snapshot that predates anything those
|
||||
other editors stored in the meantime — silently reverting it.
|
||||
"""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
try:
|
||||
on_disk = json.loads(_settings_path().read_text())
|
||||
if not isinstance(on_disk, dict):
|
||||
on_disk = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
on_disk = {} # no file yet (or an unreadable one): this write creates it
|
||||
on_disk.update(settings)
|
||||
_settings_path().write_text(json.dumps(on_disk, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def list_devices(self) -> dict:
|
||||
"""GPUs + audio endpoints for the settings tab's device pickers.
|
||||
|
||||
Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the
|
||||
Decky session: hardware doesn't come and go often enough to justify paying that on every
|
||||
remount of the page, and a stale entry is harmless — a picked device that has since
|
||||
vanished falls back to the OS default in the client anyway. `refresh_devices` clears it.
|
||||
|
||||
Best-effort in the same way every other client call here is: no session binary (an old
|
||||
flatpak that predates the two-binary split, or a native install missing its sibling) just
|
||||
means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as
|
||||
"you have no devices".
|
||||
"""
|
||||
if _devices_cache["data"] is not None:
|
||||
return _devices_cache["data"]
|
||||
|
||||
adapters: list[str] = []
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
rc_a, out_a = await _run_session(["--list-adapters"])
|
||||
if rc_a == 0:
|
||||
adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()]
|
||||
rc_d, out_d = await _run_session(["--list-audio"])
|
||||
if rc_d == 0:
|
||||
sinks, sources = _parse_audio_endpoints(out_d)
|
||||
|
||||
result = {
|
||||
"ok": rc_a == 0 or rc_d == 0,
|
||||
"adapters": adapters,
|
||||
"sinks": sinks,
|
||||
"sources": sources,
|
||||
}
|
||||
# Only a run that actually answered is worth remembering — caching a failure would make
|
||||
# a client installed after the page was first opened stay invisible until a Decky restart.
|
||||
if result["ok"]:
|
||||
_devices_cache["data"] = result
|
||||
return result
|
||||
|
||||
async def refresh_devices(self) -> dict:
|
||||
"""Drop the cached enumeration and read it again (a headset was just plugged in)."""
|
||||
_devices_cache["data"] = None
|
||||
return await self.list_devices()
|
||||
|
||||
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||
|
||||
async def list_hosts(self, probe: bool = True) -> dict:
|
||||
|
||||
@@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{failures} check(s) FAILED")
|
||||
|
||||
@@ -101,24 +101,97 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||
// here would be a dead one. The desktop client's row still edits this same file.
|
||||
inhibit_shortcuts: boolean;
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -185,6 +258,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
|
||||
@@ -334,8 +334,14 @@ const HostsTab: FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
+608
-152
@@ -1,10 +1,59 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
import { RowActions } from "./ui";
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
@@ -17,50 +66,543 @@ const selectShell: CSSProperties = {
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
|
||||
// falls back from when its GPU can't encode it.
|
||||
const CODECS = ["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
hevc: "HEVC (H.265)",
|
||||
h264: "H.264 (AVC)",
|
||||
av1: "AV1",
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
@@ -74,128 +616,42 @@ export const SettingsSection: FC = () => {
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field
|
||||
label="Video codec"
|
||||
description="Preferred stream codec — the host falls back when its GPU can't encode it"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
|
||||
selectedOption={s.codec ?? "auto"}
|
||||
onChange={(o) => patch({ codec: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -156,6 +156,20 @@ mod index {
|
||||
pub fn gamepad(s: &Settings) -> u32 {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|&p| p == s.present_priority)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn smooth_buffer(s: &Settings) -> u32 {
|
||||
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
|
||||
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
||||
@@ -625,12 +639,27 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad") {
|
||||
o.gamepad = Some(values.gamepad.clone());
|
||||
}
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
if touched.has("fullscreen_on_stream") {
|
||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||
}
|
||||
if touched.has("present_priority") {
|
||||
o.present_priority = Some(values.present_priority.clone());
|
||||
}
|
||||
if touched.has("smooth_buffer") {
|
||||
o.smooth_buffer = Some(values.smooth_buffer);
|
||||
}
|
||||
if touched.has("vsync") {
|
||||
o.vsync = Some(values.vsync);
|
||||
}
|
||||
if touched.has("allow_vrr") {
|
||||
o.allow_vrr = Some(values.allow_vrr);
|
||||
}
|
||||
// Resets are not handled here: they clear the field and re-seed their row the moment the
|
||||
// user asks, so by the time this runs the catalog already reflects them and the row is no
|
||||
// longer marked touched.
|
||||
@@ -684,6 +713,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
|
||||
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
|
||||
/// touch/mouse rows.
|
||||
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
|
||||
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
|
||||
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
|
||||
"Each frame shows the moment the display can take it",
|
||||
"Buffers a little to even out network hiccups",
|
||||
];
|
||||
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
|
||||
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
|
||||
/// per frame, and the session's refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
|
||||
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
@@ -1213,6 +1256,50 @@ pub fn show_scoped(
|
||||
row
|
||||
});
|
||||
|
||||
// ---- Display: Presentation ----
|
||||
// The intent pair the Apple and Android clients already carry. The buffer row only
|
||||
// means anything under Smoothness, so it hides itself the rest of the time rather
|
||||
// than sitting there inert.
|
||||
let present_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Prioritize",
|
||||
PRESENT_PRIORITY_CAPTIONS[0],
|
||||
PRESENT_PRIORITY_LABELS,
|
||||
);
|
||||
let buffer_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Smoothness buffer",
|
||||
"Each frame held absorbs one refresh of hiccup and adds one of delay",
|
||||
SMOOTH_BUFFER_LABELS,
|
||||
);
|
||||
{
|
||||
let w = present_row.widget().clone();
|
||||
let buffer = buffer_row.widget().clone();
|
||||
present_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
|
||||
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
|
||||
});
|
||||
}
|
||||
let vsync_row = adw::SwitchRow::builder()
|
||||
.title("V-Sync")
|
||||
.subtitle(
|
||||
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
|
||||
lowest possible delay, at the cost of visible tearing. Not every driver \
|
||||
offers it; the stats overlay names the mode actually in use",
|
||||
)
|
||||
.build();
|
||||
let vrr_row = adw::SwitchRow::builder()
|
||||
.title("Follow variable refresh rate")
|
||||
.subtitle(
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
|
||||
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
|
||||
harmless on a fixed-refresh screen",
|
||||
)
|
||||
.build();
|
||||
|
||||
// ---- Display: Host output ----
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
@@ -1376,6 +1463,17 @@ pub fn show_scoped(
|
||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
|
||||
// instead of silently snapping back to Automatic.
|
||||
// Off = this device's controllers are not sent at all, because they reach the host
|
||||
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
|
||||
// It also stops the session OPENING the pad, which is what frees the device for a
|
||||
// passthrough tool to bind — so the two rows below have nothing to act on while it is
|
||||
// off, and are desensitised to say so.
|
||||
let pad_forward_row = adw::SwitchRow::builder()
|
||||
.title("Forward controllers")
|
||||
.subtitle(
|
||||
"Send this device's controllers to the host — off if it already has them another way",
|
||||
)
|
||||
.build();
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
|
||||
@@ -1444,6 +1542,18 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Both pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Seed from the effective settings for this scope ----
|
||||
{
|
||||
@@ -1454,6 +1564,7 @@ pub fn show_scoped(
|
||||
hz_row.set_selected(index::refresh(s));
|
||||
scale_row.set_selected(index::render_scale(s));
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
@@ -1479,6 +1590,19 @@ pub fn show_scoped(
|
||||
let codec_i = index::codec(s);
|
||||
codec_row.set_selected(codec_i);
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
|
||||
let present_i = index::present_priority(s);
|
||||
present_row.set_selected(present_i);
|
||||
set_row_subtitle(
|
||||
present_row.widget(),
|
||||
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
|
||||
);
|
||||
buffer_row.set_selected(index::smooth_buffer(s));
|
||||
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
|
||||
buffer_row
|
||||
.widget()
|
||||
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
|
||||
vsync_row.set_active(s.vsync);
|
||||
vrr_row.set_active(s.allow_vrr);
|
||||
}
|
||||
|
||||
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||
@@ -1671,6 +1795,26 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
o.gamepad_forwarding.is_some(),
|
||||
gamepad_forwarding
|
||||
);
|
||||
choice!(
|
||||
present_row,
|
||||
"present_priority",
|
||||
o.present_priority.is_some(),
|
||||
index::present_priority
|
||||
);
|
||||
choice!(
|
||||
buffer_row,
|
||||
"smooth_buffer",
|
||||
o.smooth_buffer.is_some(),
|
||||
index::smooth_buffer
|
||||
);
|
||||
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
|
||||
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||
toggle!(
|
||||
@@ -1775,6 +1919,11 @@ pub fn show_scoped(
|
||||
if let (Some(r), false) = (&gpu_row, profile_mode) {
|
||||
quality_group.add(r.widget());
|
||||
}
|
||||
let presentation_group = group("Presentation", "");
|
||||
presentation_group.add(present_row.widget());
|
||||
presentation_group.add(buffer_row.widget());
|
||||
presentation_group.add(&vsync_row);
|
||||
presentation_group.add(&vrr_row);
|
||||
// The one form-level note (deliberately not repeated on every row).
|
||||
let output_group = group(
|
||||
"Host output",
|
||||
@@ -1783,6 +1932,7 @@ pub fn show_scoped(
|
||||
output_group.add(compositor_row.widget());
|
||||
display.add(&resolution_group);
|
||||
display.add(&quality_group);
|
||||
display.add(&presentation_group);
|
||||
display.add(&output_group);
|
||||
|
||||
let input = page("Input", "input-keyboard-symbolic");
|
||||
@@ -1843,6 +1993,10 @@ pub fn show_scoped(
|
||||
controllers_group.add(&row);
|
||||
}
|
||||
}
|
||||
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
|
||||
// which of THIS device's pads goes first: a "Work" profile can decline to forward
|
||||
// controllers to a host that a "Game" profile forwards them to.
|
||||
controllers_group.add(&pad_forward_row);
|
||||
if !profile_mode {
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
@@ -1915,6 +2069,7 @@ pub fn show_scoped(
|
||||
s.auto_wake = wake_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.gamepad_forwarding = pad_forward_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
@@ -1925,6 +2080,14 @@ pub fn show_scoped(
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.present_priority = PRESENT_PRIORITIES
|
||||
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
|
||||
.to_string();
|
||||
// The index IS the value (0 = Automatic).
|
||||
s.smooth_buffer =
|
||||
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
|
||||
s.vsync = vsync_row.is_active();
|
||||
s.allow_vrr = vrr_row.is_active();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
|
||||
|
||||
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
|
||||
// the probe accepts a mid-session shard change (and jumbo growth) up to the
|
||||
// receive ceiling — and it's exactly the tool to measure both.
|
||||
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
|
||||
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
|
||||
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
let label = row.name.clone();
|
||||
if k.is_none() {
|
||||
seed = Some(row.clone());
|
||||
}
|
||||
if row.paired {
|
||||
(ConsoleEntry::Library(row), Some(label))
|
||||
(ConsoleEntry::Library(Box::new(row)), Some(label))
|
||||
} else {
|
||||
(ConsoleEntry::Home, Some(label))
|
||||
}
|
||||
}
|
||||
None if fake => {
|
||||
let row = fake_host_row();
|
||||
(ConsoleEntry::Library(row), None)
|
||||
(ConsoleEntry::Library(Box::new(row)), None)
|
||||
}
|
||||
None => (ConsoleEntry::Home, None),
|
||||
};
|
||||
@@ -169,6 +171,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
||||
// Presentation-tier like the rows above: latched at console start, a per-host
|
||||
// profile cannot move it in this mode (the documented P4 gap).
|
||||
present_priority: settings_at_start.present_priority(),
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
@@ -202,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
profile,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
@@ -216,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// have changed the defaults since the last stream, and the host may carry
|
||||
// a profile binding. Console (and therefore Decky, which spawns this
|
||||
// binary) honors bindings with no console-side work — the resolver is the
|
||||
// same one `--connect` goes through. No one-off here: picking a profile is
|
||||
// a desktop-shell affordance in v1, pinned cards are the console's.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
||||
// same one `--connect` goes through. A pinned card's connect arrives as a
|
||||
// one-off profile id; the resolver prefers it over the binding, and a
|
||||
// dangling id falls back to the defaults without blocking the connect.
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile.as_deref());
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
@@ -298,6 +308,8 @@ fn fake_host_row() -> HostRow {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +513,38 @@ impl ServiceState {
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
pin,
|
||||
} => {
|
||||
// Presentation only (design §5.2a): order = card order, appended at the
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if pin && !h.pinned_profiles.contains(&profile_id) {
|
||||
h.pinned_profiles.push(profile_id);
|
||||
} else if !pin {
|
||||
h.pinned_profiles.retain(|id| *id != profile_id);
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,12 +583,21 @@ impl ServiceState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The console home's rows: saved hosts (most recent first), then
|
||||
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
|
||||
/// The console home's rows: saved hosts (most recent first) — each followed by its
|
||||
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
|
||||
/// still-uncovered `--browse` seed.
|
||||
fn rows(&self) -> Vec<HostRow> {
|
||||
let known = trust::KnownHosts::load();
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let probed = self.probed.lock().unwrap();
|
||||
let mut rows: Vec<HostRow> = known
|
||||
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
|
||||
id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
accent: p.accent.clone(),
|
||||
};
|
||||
// Primary rows paired with their pinned cards, so the sort below can order hosts
|
||||
// while every host's cards stay glued behind its primary tile.
|
||||
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
@@ -558,8 +611,8 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
HostRow {
|
||||
key,
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
@@ -576,10 +629,34 @@ impl ServiceState {
|
||||
.filter(|d| !d.os.is_empty())
|
||||
.map(|d| d.os.clone())
|
||||
.unwrap_or_else(|| h.os.clone()),
|
||||
}
|
||||
pin: None,
|
||||
bound_profile: h
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.and_then(|id| catalog.find_by_id(id))
|
||||
.map(chip),
|
||||
};
|
||||
// A pinned card shares the primary tile's live state; its key rides the
|
||||
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
|
||||
// so cursor-follow and the wake path address the card itself.
|
||||
let pins = h
|
||||
.resolved_pins(&catalog)
|
||||
.into_iter()
|
||||
.map(|p| HostRow {
|
||||
key: format!("{key}\0{}", p.id),
|
||||
pin: Some(chip(p)),
|
||||
bound_profile: None,
|
||||
..row.clone()
|
||||
})
|
||||
.collect();
|
||||
(row, pins)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
let mut rows: Vec<HostRow> = saved
|
||||
.into_iter()
|
||||
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
|
||||
.collect();
|
||||
|
||||
let mut extra: Vec<HostRow> = self
|
||||
.discovered
|
||||
@@ -607,6 +684,8 @@ impl ServiceState {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
})
|
||||
.collect();
|
||||
extra.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
@@ -188,6 +188,12 @@ mod session_main {
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
|
||||
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
|
||||
// reuses one service across launches, so a stream that follows one with it off must put
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -617,6 +623,9 @@ mod session_main {
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
inhibit_shortcuts: settings.inhibit_shortcuts,
|
||||
present_priority: settings.present_priority(),
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
actions.push(
|
||||
icon_btn("Settings", Symbol::Setting)
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Re-base the settings snapshot on the file before the page
|
||||
// renders — this process is not its only writer (see
|
||||
// settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! Settings).
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
use super::{AppCtx, Screen};
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0).
|
||||
@@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!(
|
||||
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
|
||||
// as the hosts page's Settings button (see settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
});
|
||||
|
||||
let app_card = card(
|
||||
|
||||
@@ -172,6 +172,10 @@ pub(crate) struct Shared {
|
||||
|
||||
pub struct AppCtx {
|
||||
pub(crate) identity: (String, String),
|
||||
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
|
||||
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
|
||||
/// inside every `commit` — this process is not the file's only writer (session resize,
|
||||
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
|
||||
pub(crate) settings: Mutex<Settings>,
|
||||
pub(crate) gamepad: GamepadService,
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
@@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_settings_rev,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
|
||||
Screen::Help => help::help_page(&set_screen),
|
||||
Screen::Pair => component(pair::pair_page, svc),
|
||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||
|
||||
@@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
|
||||
/// Apple and Android clients share, so one profile means the same thing everywhere.
|
||||
const PRESENT_PRIORITIES: &[(&str, &str)] =
|
||||
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
|
||||
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
|
||||
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -411,7 +424,16 @@ fn commit(
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
|
||||
// effective settings derive from, and the snapshot is not the file — another process
|
||||
// (session resize, console UI, Decky) may have moved a global under us. The historical
|
||||
// rebase fix ("settings saves stop reverting each other") covered the whole-file
|
||||
// writers but missed this arm.
|
||||
let base = {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
*s = Settings::load();
|
||||
s.clone()
|
||||
};
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
|
||||
return; // deleted from under us; the next render falls back to the defaults scope
|
||||
};
|
||||
@@ -425,6 +447,17 @@ fn commit(
|
||||
rev.1.call(rev.0 + 1);
|
||||
}
|
||||
|
||||
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
|
||||
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
|
||||
/// process start and this process is not the file's only writer (a spawned session persists
|
||||
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
|
||||
/// family), so without this the page opens showing values another process already replaced,
|
||||
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
|
||||
/// file in. The field report this fixes: a codec setting that "changed by itself".
|
||||
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
|
||||
*ctx.settings.lock().unwrap() = Settings::load();
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
@@ -445,8 +478,13 @@ struct OverrideFlags {
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
gamepad_forwarding: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
smooth_buffer: bool,
|
||||
vsync: bool,
|
||||
allow_vrr: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
@@ -473,8 +511,13 @@ impl OverrideFlags {
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
gamepad_forwarding: o.gamepad_forwarding.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
smooth_buffer: o.smooth_buffer.is_some(),
|
||||
vsync: o.vsync.is_some(),
|
||||
allow_vrr: o.allow_vrr.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +894,32 @@ pub(crate) fn settings_page(
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
|
||||
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
|
||||
// intent re-renders the section and the row appears/disappears with it.
|
||||
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
|
||||
let present_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
present_names,
|
||||
present_i,
|
||||
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
|
||||
);
|
||||
let smoothing = s.present_priority == "smooth";
|
||||
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
|
||||
let buffer_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
buffer_names,
|
||||
buffer_i,
|
||||
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
|
||||
);
|
||||
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
|
||||
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
|
||||
s.allow_vrr = on
|
||||
});
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -898,6 +967,10 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let pad_forward_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
|
||||
s.gamepad_forwarding = on
|
||||
});
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
@@ -972,6 +1045,21 @@ pub(crate) fn settings_page(
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
// The client log's home — the file every "check the client log" message means, which until
|
||||
// this row had no way in from the UI at all. The folder rather than the file so the rotated
|
||||
// `.old` generation is in reach too.
|
||||
//
|
||||
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
|
||||
// container: handed a path the package redirection keeps from ever existing, it silently
|
||||
// opens the user's Documents folder instead of failing, which is precisely what this button
|
||||
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
|
||||
// comes back wrong, the click does nothing rather than landing somewhere misleading.
|
||||
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
|
||||
let logs_button = button("Open log folder").on_click(|| {
|
||||
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
|
||||
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
|
||||
}
|
||||
});
|
||||
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
|
||||
s.library_enabled = on
|
||||
});
|
||||
@@ -1065,8 +1153,9 @@ pub(crate) fn settings_page(
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
),
|
||||
// Wording shared with the GTK client (its chroma_row) — same setting,
|
||||
// same constraints.
|
||||
// First sentence shared with the GTK client (its chroma_row); the
|
||||
// constraint sentence names the real gate (host: PyroWave || NVENC) —
|
||||
// "where the host can encode it" cost field users the discovery time.
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
@@ -1075,7 +1164,8 @@ pub(crate) fn settings_page(
|
||||
over.enable_444,
|
||||
chroma_toggle,
|
||||
"Full-colour video: crisp small text and thin lines, at more \
|
||||
bandwidth. HEVC only, and only where the host can encode it.",
|
||||
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
|
||||
codec \u{2014} other encoders stream 4:2:0.",
|
||||
),
|
||||
],
|
||||
None,
|
||||
@@ -1105,6 +1195,60 @@ pub(crate) fn settings_page(
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Presentation"),
|
||||
{
|
||||
let mut fields = vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"present_priority",
|
||||
"Prioritize",
|
||||
over.present_priority,
|
||||
present_combo,
|
||||
"Lowest latency shows each frame the moment the display can take \
|
||||
it \u{2014} a network hiccup becomes an occasional repeated or \
|
||||
skipped frame. Smoothness buffers a little to even those out.",
|
||||
)];
|
||||
if smoothing {
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"smooth_buffer",
|
||||
"Smoothness buffer",
|
||||
over.smooth_buffer,
|
||||
buffer_combo,
|
||||
"Frames held back before showing. Each one absorbs about a \
|
||||
refresh of network hiccup and adds a refresh of delay. \
|
||||
Automatic holds two.",
|
||||
));
|
||||
}
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"vsync",
|
||||
"V-Sync",
|
||||
over.vsync,
|
||||
vsync_toggle,
|
||||
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
|
||||
refresh \u{2014} the lowest possible delay, at the cost of visible \
|
||||
tearing. Not every driver offers it; the stats overlay names the \
|
||||
mode actually in use.",
|
||||
));
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"allow_vrr",
|
||||
"Follow variable refresh rate",
|
||||
over.allow_vrr,
|
||||
vrr_toggle,
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
|
||||
the stream instead of on a fixed cadence. Applies to fullscreen \
|
||||
sessions; harmless on a fixed-refresh screen.",
|
||||
));
|
||||
fields
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described_overridable(
|
||||
@@ -1223,6 +1367,23 @@ pub(crate) fn settings_page(
|
||||
"Plug in or pair a controller and it appears here.",
|
||||
)
|
||||
}),
|
||||
// Whether ANY controller is forwarded — profileable, so it renders in
|
||||
// both scopes (a "Work" profile can decline what "Game" forwards),
|
||||
// unlike the device-fact picker below it.
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad_forwarding",
|
||||
"Forward controllers",
|
||||
over.gamepad_forwarding,
|
||||
pad_forward_toggle,
|
||||
"Sends controllers connected to this PC to the host. Turn it off when \
|
||||
your controller already reaches the host another way \u{2014} USB \
|
||||
passthrough such as VirtualHere, or a pad plugged into the host \
|
||||
itself \u{2014} so games don't see two of them. Off, this PC never \
|
||||
opens the controller at all, which is what leaves it free for a \
|
||||
passthrough tool to claim.",
|
||||
)),
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
@@ -1325,7 +1486,16 @@ pub(crate) fn settings_page(
|
||||
"About",
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
vec![
|
||||
about_identity.into(),
|
||||
described_labeled(
|
||||
"Diagnostics",
|
||||
logs_button,
|
||||
"The client log (client.log, plus the session\u{2019}s whole \
|
||||
receive/decode/present trail) \u{2014} attach it to a bug report.",
|
||||
),
|
||||
licenses_button.into(),
|
||||
],
|
||||
None,
|
||||
),
|
||||
),
|
||||
@@ -1727,5 +1897,26 @@ mod tests {
|
||||
let f3 = OverrideFlags::of(Some(&p3));
|
||||
assert!(f3.echo_cancel);
|
||||
assert!(!f3.mic_enabled);
|
||||
|
||||
// The presentation pair, likewise independent: pinning the intent doesn't claim
|
||||
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
|
||||
let mut p4 = StreamProfile::new("t4".to_string());
|
||||
p4.overrides = SettingsOverlay {
|
||||
present_priority: Some("smooth".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f4 = OverrideFlags::of(Some(&p4));
|
||||
assert!(f4.present_priority);
|
||||
assert!(!f4.smooth_buffer);
|
||||
|
||||
// V-Sync and VRR are independent of each other and of the intent pair.
|
||||
let mut p5 = StreamProfile::new("t5".to_string());
|
||||
p5.overrides = SettingsOverlay {
|
||||
vsync: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let f5 = OverrideFlags::of(Some(&p5));
|
||||
assert!(f5.vsync);
|
||||
assert!(!f5.allow_vrr && !f5.present_priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
//!
|
||||
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
|
||||
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
|
||||
//! or hand to Explorer.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
@@ -21,13 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
|
||||
///
|
||||
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
|
||||
/// Anything shown to a user or handed to another process wants that one instead.
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
|
||||
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
|
||||
///
|
||||
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
|
||||
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
|
||||
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
|
||||
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
|
||||
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
|
||||
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
|
||||
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
|
||||
/// directory for real, which is why this only ever showed up in the field.
|
||||
///
|
||||
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
|
||||
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
|
||||
pub(crate) fn real_dir() -> Option<PathBuf> {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
|
||||
}
|
||||
|
||||
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
|
||||
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
|
||||
/// and it is noise in a line a user is meant to read and act on.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
use std::path::{Component, Prefix};
|
||||
|
||||
// Scoped so the borrow ends before the `return p` below can move it.
|
||||
let head = match p.components().next() {
|
||||
Some(Component::Prefix(pre)) => match pre.kind() {
|
||||
// `\\?\C:\…` → `C:\…`
|
||||
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
|
||||
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
|
||||
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
|
||||
// separator logic to the bare `\\` and mangle it.
|
||||
Prefix::VerbatimUNC(server, share) => {
|
||||
let mut unc = std::ffi::OsString::from(r"\\");
|
||||
unc.push(server);
|
||||
unc.push(r"\");
|
||||
unc.push(share);
|
||||
Some(PathBuf::from(unc))
|
||||
}
|
||||
// Already a plain path — nothing to undo.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let Some(mut out) = head else { return p };
|
||||
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
|
||||
out.extend(
|
||||
p.components()
|
||||
.skip(1)
|
||||
.filter(|c| !matches!(c, Component::RootDir)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
|
||||
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
Some(real_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
@@ -96,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
|
||||
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off() {
|
||||
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
|
||||
let p = PathBuf::from(
|
||||
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(
|
||||
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
|
||||
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
|
||||
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
|
||||
#[test]
|
||||
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
|
||||
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
|
||||
#[test]
|
||||
fn plain_path_is_untouched() {
|
||||
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(strip_verbatim(p.clone()), p);
|
||||
}
|
||||
|
||||
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
|
||||
/// carries no verbatim prefix. This is the button's actual precondition.
|
||||
#[test]
|
||||
fn real_dir_is_an_openable_directory() {
|
||||
let Some(dir) = real_dir() else {
|
||||
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
|
||||
};
|
||||
assert!(dir.is_dir(), "{} is not a directory", dir.display());
|
||||
assert!(
|
||||
!dir.to_string_lossy().starts_with(r"\\?\"),
|
||||
"{} kept its verbatim prefix",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
/// connect that silently drops back to the host list.
|
||||
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
(code != 0 && code != -1).then(|| {
|
||||
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||
// Name the log's actual location — "check the client log" without a path is a
|
||||
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
|
||||
let log = crate::logfile::path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "the client log".into());
|
||||
format!(
|
||||
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -301,6 +306,10 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -316,7 +325,8 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -328,6 +338,7 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -335,6 +346,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -350,6 +362,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
if !note_cursor_id(cursor, id) {
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -367,9 +399,44 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1670,6 +1670,22 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
// One ETW read serves both evidence fields: the prose summary spans the gap plus
|
||||
// the same 300 ms lead-in the report's OS-event correlation uses (the disturbance
|
||||
// that CAUSED the hole lands just before it), while the discriminator counts span
|
||||
// the GAP ONLY — no lead-in: presents from the healthy flow right before the hole
|
||||
// would falsely acquit the content (the stall-ending frame's own present lands at
|
||||
// the window edge and stays well under the acquit bar). Both halves must come from
|
||||
// the same ring snapshot under the same clock anchor, or the prose and the verdict
|
||||
// can disagree about the same hole.
|
||||
let (etw, etw_counts) = self
|
||||
.etw
|
||||
.as_ref()
|
||||
.and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_report(from, now, Duration::from_millis(300)))
|
||||
})
|
||||
.unzip();
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
@@ -1682,24 +1698,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
// The probe + ETW reads span the same window the report's OS-event correlation
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
// The probe read spans the same window the report's OS-event correlation uses
|
||||
// (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.zip(self.probes.as_deref())
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||
// healthy flow right before the hole would falsely acquit the content. The
|
||||
// stall-ending frame's own present lands at the window edge and stays well
|
||||
// under the acquit bar.
|
||||
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_counts(from, now))
|
||||
}),
|
||||
etw,
|
||||
etw_counts,
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
@@ -2453,6 +2459,18 @@ mod tests {
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// A LIVE witness (history true = it demonstrably worked just before the hole) reading
|
||||
// an exact zero is the strongest content conviction — the zero is a measurement, not
|
||||
// an absence.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(0, 0))
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||
// classes — it only refines compose-silence.
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! ([`EtwWatch::window_report`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||
@@ -48,7 +48,8 @@ use windows::Win32::System::Diagnostics::Etw::{
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
|
||||
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
|
||||
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
PROCESS_TRACE_MODE_RAW_TIMESTAMP, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION,
|
||||
WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
use windows::Win32::System::Threading::{
|
||||
@@ -122,8 +123,13 @@ fn qpc_freq() -> i64 {
|
||||
})
|
||||
}
|
||||
|
||||
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
|
||||
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
|
||||
/// The consumer's per-event callback — record id + timestamp + pid into the ring and return;
|
||||
/// runs on the consumer thread. `TimeStamp` is a raw QPC value only because BOTH halves of the
|
||||
/// clock contract hold: `ClientContext = 1` makes QPC the session clock, and the consumer is
|
||||
/// opened with `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, which is what stops ProcessTrace converting
|
||||
/// every event's timestamp to FILETIME (100 ns units since 1601) on delivery. Without the flag
|
||||
/// the conversion happens REGARDLESS of the session clock, and every `ts <= to_q` comparison
|
||||
/// downstream is against the wrong clock — never true, a witness that silently reads empty.
|
||||
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
if record.is_null() {
|
||||
return;
|
||||
@@ -150,10 +156,10 @@ pub(super) struct EtwWatch {
|
||||
}
|
||||
|
||||
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
|
||||
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
|
||||
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
// operation on them (window_report reads the static ring; Drop stops/closes) is thread-safe by
|
||||
// the ETW API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
unsafe impl Send for EtwWatch {}
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `window_report` (static-ring reads).
|
||||
unsafe impl Sync for EtwWatch {}
|
||||
|
||||
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
|
||||
@@ -201,8 +207,10 @@ impl EtwWatch {
|
||||
let mut session = CONTROLTRACE_HANDLE::default();
|
||||
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
|
||||
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
|
||||
// what makes event timestamps comparable to our probe windows.
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 selects QPC as
|
||||
// the SESSION clock — necessary but not sufficient for QPC comparisons: ProcessTrace
|
||||
// still converts every event's timestamp to FILETIME on delivery unless the consumer is
|
||||
// opened with PROCESS_TRACE_MODE_RAW_TIMESTAMP (set below).
|
||||
let rc = unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
@@ -224,6 +232,11 @@ impl EtwWatch {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A fresh session gets a fresh ring: the static [`RING`] outlives any `EtwWatch`, so
|
||||
// whatever is in it belongs to a DEAD session — leaking it forward would let a previous
|
||||
// session's presents pose as this session's witness history. Race-free here: the
|
||||
// consumer thread that repopulates it is spawned below.
|
||||
RING.lock().unwrap().clear();
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||
@@ -240,7 +253,7 @@ impl EtwWatch {
|
||||
return None;
|
||||
}
|
||||
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||
// refusal only costs the per-process present counts — `window_report` then reports
|
||||
// no present history and classification stays honest (Unattributed, never a guess).
|
||||
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||
tracing::debug!(
|
||||
@@ -252,8 +265,12 @@ impl EtwWatch {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
..Default::default()
|
||||
};
|
||||
log.Anonymous1.ProcessTraceMode =
|
||||
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
|
||||
// RAW_TIMESTAMP is load-bearing: it stops ProcessTrace converting `EVENT_HEADER.TimeStamp`
|
||||
// to FILETIME on delivery, so events arrive stamped in the session clock (QPC, per the
|
||||
// ClientContext above) — the only clock the window edges are computed in.
|
||||
log.Anonymous1.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME
|
||||
| PROCESS_TRACE_MODE_EVENT_RECORD
|
||||
| PROCESS_TRACE_MODE_RAW_TIMESTAMP;
|
||||
log.Anonymous2.EventRecordCallback = Some(on_event);
|
||||
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
|
||||
// what it needs before returning).
|
||||
@@ -303,14 +320,34 @@ impl EtwWatch {
|
||||
Some(Self { session, consumer })
|
||||
}
|
||||
|
||||
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
|
||||
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). `"none"` when the window is clean.
|
||||
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
|
||||
// Instant → QPC: anchor both clocks now and offset backwards.
|
||||
/// One stall window's ETW evidence, both halves from a SINGLE ring snapshot under a SINGLE
|
||||
/// `(Instant::now(), qpc_now())` anchor: the DDI/present prose summary a stall report
|
||||
/// carries, and the structured discriminator counts the classifier folds in. The summary
|
||||
/// covers `[hole_from - lead_in, hole_to]` — the disturbance that CAUSED a hole lands just
|
||||
/// before DWM stops delivering, so the prose needs the lead-in. The counts cover
|
||||
/// `[hole_from, hole_to]` ONLY — presents from the healthy flow inside the lead-in would
|
||||
/// falsely acquit the content. Two separate reads (two locks, two anchors, syscalls in
|
||||
/// between) would let events arriving between them make the prose and the verdict disagree
|
||||
/// about the same hole — hence one method returning both.
|
||||
///
|
||||
/// Brackets that merely SPAN the summary window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). The summary reads `"none"` when the window
|
||||
/// is clean.
|
||||
pub(super) fn window_report(
|
||||
&self,
|
||||
hole_from: Instant,
|
||||
hole_to: Instant,
|
||||
lead_in: Duration,
|
||||
) -> (String, EtwWindowCounts) {
|
||||
// Instant → QPC: anchor both clocks once and offset backwards; every window edge below
|
||||
// derives from this one anchor.
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_from), freq);
|
||||
let summary_from_q = from_q - duration_qpc(lead_in, freq);
|
||||
// One snapshot, then the lock drops: everything below — including the OpenProcess
|
||||
// syscalls behind `process_name` — runs off the copy, so the consumer callback never
|
||||
// queues behind a stall report.
|
||||
let events: Vec<(i64, u16, u32)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter()
|
||||
@@ -318,6 +355,7 @@ impl EtwWatch {
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
@@ -335,7 +373,7 @@ impl EtwWatch {
|
||||
} else if id == stop_id {
|
||||
if let Some(s) = open.take() {
|
||||
// The bracket [s, ts] counts when it intersects the window.
|
||||
if s <= to_q && ts >= from_q {
|
||||
if s <= to_q && ts >= summary_from_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(ts - s));
|
||||
}
|
||||
@@ -362,43 +400,34 @@ impl EtwWatch {
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= summary_from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||
// inside the summary window plus the top presenters, NAMED — the line that splits a
|
||||
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||
// but the window is empty — silence is a finding, not an absence.
|
||||
// the queue starves). "Present×0" is printed explicitly when the witness was LIVE
|
||||
// before the hole ([`LOOKBACK`]) but the window is empty — silence is a finding, not
|
||||
// an absence; a dead witness's window prints nothing rather than a fake zero.
|
||||
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||
let mut have_present_history = false;
|
||||
let (mut adds, mut completes) = (0u32, 0u32);
|
||||
let mut have_queue_history = false;
|
||||
for &(ts, id, pid) in &events {
|
||||
if ts < summary_from_q || ts > to_q {
|
||||
continue;
|
||||
}
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
have_present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||
have_queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
if id == BLT_ADD_ID {
|
||||
adds += 1;
|
||||
} else {
|
||||
completes += 1;
|
||||
}
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => adds += 1,
|
||||
BLT_COMPLETE_ID => completes += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -415,61 +444,80 @@ impl EtwWatch {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("Present×{total}({top})"));
|
||||
} else if have_present_history {
|
||||
} else if counts.present_history {
|
||||
parts.push("Present×0".to_string());
|
||||
}
|
||||
if have_queue_history {
|
||||
if counts.queue_history || adds > 0 || completes > 0 {
|
||||
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
let summary = if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let ring = RING.lock().unwrap();
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in ring.iter() {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
(summary, counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||
/// Witness-liveness lookback: the [`EtwWindowCounts`] history flags are true only when the
|
||||
/// stream produced at least one event inside the `LOOKBACK` window ENDING at the hole's start.
|
||||
/// "Ever produced an event" would be wrong in both directions: an event that arrived only AFTER
|
||||
/// the hole (the resume burst, the stall-ending frame) proves nothing about whether the witness
|
||||
/// was working DURING it, and a provider that died mid-session (or whose events aged out of the
|
||||
/// ring) would keep flying a stale known-working flag forever. Demonstrated life immediately
|
||||
/// BEFORE the hole is the claim the classifier actually needs; 5 s is far longer than any
|
||||
/// pre-stall active-flow gate, so a genuinely working witness cannot blink false across a
|
||||
/// frame-time lull.
|
||||
const LOOKBACK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The discriminator's windowing math, factored pure (plain i64 QPC-tick arithmetic, no ETW,
|
||||
/// no clock reads) so the ring→counts contract is unit-testable without a session: presents
|
||||
/// (DXGI 42/55, any process) and queue entries (`BltQueueAddEntry`) inside `[from_q, to_q]`,
|
||||
/// witness liveness from `[from_q - lookback_q, from_q]` (see [`LOOKBACK`]). A
|
||||
/// `BltQueueCompleteIndirectPresent` proves the queue witness works exactly as an add does —
|
||||
/// both ride the same provider enable — so either satisfies `queue_history`.
|
||||
fn count_window(
|
||||
events: &[(i64, u16, u32)],
|
||||
from_q: i64,
|
||||
to_q: i64,
|
||||
lookback_q: i64,
|
||||
) -> EtwWindowCounts {
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in events {
|
||||
let in_window = ts >= from_q && ts <= to_q;
|
||||
let in_lookback = ts >= from_q.saturating_sub(lookback_q) && ts <= from_q;
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history |= in_lookback;
|
||||
if in_window {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history |= in_lookback;
|
||||
if in_window {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
BLT_COMPLETE_ID => out.queue_history |= in_lookback,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_report`]'s structured half: the compose-silence discriminator's evidence.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct EtwWindowCounts {
|
||||
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||
pub(super) presents: u32,
|
||||
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||
pub(super) queue_adds: u32,
|
||||
/// The present stream has produced at least one event EVER (witness known-working).
|
||||
/// The present stream demonstrated liveness inside [`LOOKBACK`] BEFORE the hole opened — a
|
||||
/// working witness whose in-window zero is a reading, not a dead one whose zero is noise.
|
||||
pub(super) present_history: bool,
|
||||
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
|
||||
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
|
||||
pub(super) queue_history: bool,
|
||||
}
|
||||
|
||||
@@ -561,3 +609,72 @@ impl Drop for EtwWatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The module only compiles on Windows (lib.rs gates `mod windows`), so plain `cfg(test)` here
|
||||
// already means "Windows tests" — and [`count_window`] itself is pure tick math, no session.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// [`count_window`]'s contract: counts come from the hole window `[from, to]`; liveness
|
||||
/// comes ONLY from the lookback window ending at the hole's start. An event after the hole
|
||||
/// (the resume burst) or older than the lookback (a dead provider's leftovers) must not fly
|
||||
/// the known-working flag — those are exactly the shapes that used to convict every
|
||||
/// compose-silence hole as content.
|
||||
#[test]
|
||||
fn count_window_liveness_and_windowing() {
|
||||
// Hole [1000, 2000], lookback 500 → liveness window [500, 1000]. Plain ticks.
|
||||
let (from, to, lb) = (1_000i64, 2_000i64, 500i64);
|
||||
let ev = |ts: i64, id: u16| (ts, id, 42u32);
|
||||
|
||||
// The healthy shape: liveness demonstrated before the hole, activity inside it.
|
||||
let events = [
|
||||
ev(600, DXGI_PRESENT_ID), // lookback → present witness live
|
||||
ev(700, BLT_COMPLETE_ID), // lookback → queue witness live (completes count)
|
||||
ev(1_100, DXGI_PRESENT_ID), // in-window present
|
||||
ev(1_200, DXGI_PRESENT_MPO_ID), // in-window present (MPO path)
|
||||
ev(1_300, BLT_ADD_ID), // in-window queue add
|
||||
ev(1_400, 430), // non-witness id: never counted here
|
||||
];
|
||||
assert_eq!(
|
||||
count_window(&events, from, to, lb),
|
||||
EtwWindowCounts {
|
||||
presents: 2,
|
||||
queue_adds: 1,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
}
|
||||
);
|
||||
|
||||
// In-window events count but do NOT confer liveness — the witness must have worked
|
||||
// BEFORE the hole for its zeros elsewhere to mean anything.
|
||||
let window_only = [ev(1_500, DXGI_PRESENT_ID), ev(1_600, BLT_ADD_ID)];
|
||||
let c = count_window(&window_only, from, to, lb);
|
||||
assert_eq!((c.presents, c.queue_adds), (1, 1));
|
||||
assert!(!c.present_history && !c.queue_history);
|
||||
|
||||
// An event only AFTER the hole proves nothing about the witness during it.
|
||||
let after_only = [ev(2_100, DXGI_PRESENT_ID), ev(2_200, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&after_only, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Events that aged past the lookback (a previous session's leftovers) don't either.
|
||||
let stale = [ev(499, DXGI_PRESENT_ID), ev(1, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&stale, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Both lookback edges are inclusive; the hole-start event is both liveness and count.
|
||||
let edges = [ev(500, DXGI_PRESENT_ID), ev(1_000, BLT_ADD_ID)];
|
||||
let c = count_window(&edges, from, to, lb);
|
||||
assert!(c.present_history && c.queue_history);
|
||||
assert_eq!((c.presents, c.queue_adds), (0, 1));
|
||||
|
||||
// A lookback reaching below tick 0 saturates instead of wrapping.
|
||||
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
|
||||
assert!(c.present_history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) struct StallEvidence {
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_report`]) —
|
||||
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
|
||||
@@ -168,9 +168,18 @@ struct PlayerData {
|
||||
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
|
||||
recycle: SyncSender<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
|
||||
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
|
||||
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
|
||||
/// one transient drain manufactured a whole target's worth of fresh silence.
|
||||
policy: punktfunk_core::audio::JitterPolicy,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
channels: usize,
|
||||
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
|
||||
/// client log, so a latency or dropout report had nothing to go on.
|
||||
underruns: u64,
|
||||
sheds: u64,
|
||||
callbacks: u64,
|
||||
}
|
||||
|
||||
fn pw_thread(
|
||||
@@ -223,8 +232,14 @@ fn pw_thread(
|
||||
rx: pcm_rx,
|
||||
recycle: recycle_tx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
policy: punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::PIPEWIRE,
|
||||
channels as u8,
|
||||
),
|
||||
channels,
|
||||
underruns: 0,
|
||||
sheds: 0,
|
||||
callbacks: 0,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
@@ -252,23 +267,29 @@ fn pw_thread(
|
||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||
let want = want_frames * ud.channels;
|
||||
|
||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||
// genuine drain.
|
||||
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||
while ud.ring.len() > target.max(want) + want {
|
||||
ud.ring.pop_front();
|
||||
}
|
||||
if !ud.primed && ud.ring.len() >= target {
|
||||
ud.primed = true;
|
||||
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
|
||||
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
|
||||
// and a hard cap as the backstop.
|
||||
let step = ud.policy.step(ud.ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
ud.sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ud.ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
|
||||
let mut ran_short = false;
|
||||
let n_frames = if let Some(slice) = data.data() {
|
||||
for k in 0..want {
|
||||
let s = if ud.primed {
|
||||
ud.ring.pop_front().unwrap_or(0.0)
|
||||
} else {
|
||||
let s = if step.silence {
|
||||
0.0
|
||||
} else {
|
||||
ud.ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
})
|
||||
};
|
||||
let off = k * 4;
|
||||
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
|
||||
@@ -277,8 +298,21 @@ fn pw_thread(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ud.ring.is_empty() {
|
||||
ud.primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
|
||||
// is never miscounted as an underrun.
|
||||
ud.policy.note_read(ran_short);
|
||||
ud.underruns += u64::from(ran_short);
|
||||
ud.callbacks += 1;
|
||||
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
|
||||
// plane stops being invisible.
|
||||
if ud.callbacks % 2_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = ud.policy.avg_depth_ms(),
|
||||
target_ms = ud.policy.target_ms(),
|
||||
underruns = ud.underruns,
|
||||
drift_sheds = ud.sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//!
|
||||
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
|
||||
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
|
||||
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
|
||||
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
|
||||
//! built-in streaming path is deleted).
|
||||
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
|
||||
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
|
||||
//! so this is now the only WASAPI client ring.
|
||||
//!
|
||||
//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session
|
||||
//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread
|
||||
//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before
|
||||
//! producing, cap the ring so latency stays bounded, re-prime after a real drain.
|
||||
//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI
|
||||
//! render thread pulls whole event-driven quanta on the device clock. The depth policy between
|
||||
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
|
||||
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
|
||||
//! the same way and none of them can ratchet latency upward.
|
||||
//!
|
||||
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated
|
||||
//! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag
|
||||
@@ -250,10 +251,20 @@ fn render_thread(
|
||||
audio_client.start_stream().context("start render stream")?;
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||
let mut primed = false;
|
||||
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
|
||||
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
|
||||
// helper with the other three clients).
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
|
||||
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
|
||||
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
|
||||
// target's worth of fresh silence.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::WASAPI,
|
||||
channels,
|
||||
);
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
@@ -262,9 +273,7 @@ fn render_thread(
|
||||
// Drain everything the pump has queued into the ring, returning each drained
|
||||
// Vec to the pool (a full/closed pool drops it).
|
||||
while let Ok(mut chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk.iter() {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
ring.extend(chunk.iter().copied());
|
||||
chunk.clear();
|
||||
let _ = recycle_tx.try_send(chunk);
|
||||
}
|
||||
@@ -274,28 +283,40 @@ fn render_thread(
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
let want = avail_frames * channels as usize;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
let cap = target.max(want_bytes) + want_bytes;
|
||||
if ring.len() > cap {
|
||||
ring.drain(..ring.len() - cap);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
|
||||
}
|
||||
|
||||
out.clear();
|
||||
out.resize(want_bytes, 0);
|
||||
if primed {
|
||||
let n = ring.len().min(want_bytes);
|
||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||
*dst = b;
|
||||
out.resize(avail_frames * block_align, 0);
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
|
||||
for dst in out.chunks_exact_mut(4) {
|
||||
let s = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
dst.copy_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
|
||||
// never miscounted as an underrun.
|
||||
policy.note_read(ran_short);
|
||||
underruns += u64::from(ran_short);
|
||||
callbacks += 1;
|
||||
if callbacks % 1_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = policy.avg_depth_ms(),
|
||||
target_ms = policy.target_ms(),
|
||||
underruns,
|
||||
drift_sheds = sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
|
||||
@@ -336,6 +336,7 @@ enum Ctl {
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -482,6 +483,26 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
|
||||
/// default on). Off is for a couch whose pad reaches the host another way — a USB
|
||||
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
|
||||
/// where forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
|
||||
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
|
||||
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
|
||||
/// It follows that the escape chord, which only listens on forwarded pads, is not
|
||||
/// available while off; the keyboard chord and the client's own UI still end a session.
|
||||
///
|
||||
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
|
||||
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
|
||||
/// it again, so the pad is free for the whole time a stream is up.
|
||||
///
|
||||
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
|
||||
pub fn set_forwarding(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -721,6 +742,10 @@ struct Worker {
|
||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||
pinned: Option<String>,
|
||||
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
|
||||
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
|
||||
/// being that the hardware stays ungrabbed for a USB passthrough tool.
|
||||
forwarding: bool,
|
||||
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
@@ -815,6 +840,11 @@ impl Worker {
|
||||
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
|
||||
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
|
||||
fn forwarded_ids(&self) -> Vec<u32> {
|
||||
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
|
||||
// free for whatever route the user's controller actually takes to the host.
|
||||
if !self.forwarding {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(key) = &self.pinned {
|
||||
if let Some(id) = self
|
||||
.order
|
||||
@@ -1243,10 +1273,16 @@ impl Worker {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
self.attached = Some(c);
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
set_valve_hidapi(true);
|
||||
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
// Not with forwarding off: this session opens no slot, and the drivers'
|
||||
// mere enumeration both kills the Deck's trackpad-mouse and is the
|
||||
// opposite of leaving the hardware alone for a passthrough tool.
|
||||
if self.forwarding {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
@@ -1269,6 +1305,31 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::Forwarding(on)) => {
|
||||
if self.forwarding == on {
|
||||
continue;
|
||||
}
|
||||
self.forwarding = on;
|
||||
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
|
||||
|
||||
// Applied live rather than at attach only, so a mid-session flip (an
|
||||
// in-stream settings screen) takes effect on the pad in your hands.
|
||||
//
|
||||
// The Valve HIDAPI drivers are an in-session-only thing (see
|
||||
// set_valve_hidapi), and forwarding off is — for their purpose — not in
|
||||
// session. Order matters and differs by direction: ON must enable them
|
||||
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
|
||||
// identity; OFF must disable them AFTER, so no slot outlives the driver
|
||||
// that opened it.
|
||||
let attached = self.attached.is_some();
|
||||
if on && attached {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
if !on && attached {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1608,6 +1669,7 @@ impl Worker {
|
||||
menu_open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
forwarding: true,
|
||||
kind_override: GamepadPref::Auto,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
|
||||
@@ -982,6 +982,10 @@ mod tests {
|
||||
height: 1440,
|
||||
bitrate_kbps: 55000,
|
||||
codec: "av1".into(),
|
||||
present_priority: "smooth".into(),
|
||||
smooth_buffer: 2,
|
||||
vsync: false,
|
||||
allow_vrr: false,
|
||||
..Default::default()
|
||||
},
|
||||
clipboard: true,
|
||||
|
||||
@@ -74,9 +74,23 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// The presentation cluster — the keys the Apple client already writes into this
|
||||
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
|
||||
/// Android carries the first two). First-class here so a profile authored on any
|
||||
/// client applies on all of them instead of riding `extra` unapplied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub present_priority: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smooth_buffer: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_vrr: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
@@ -142,6 +156,9 @@ impl SettingsOverlay {
|
||||
if let Some(v) = &self.gamepad {
|
||||
s.gamepad = v.clone();
|
||||
}
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -150,6 +167,18 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.fullscreen_on_stream {
|
||||
s.fullscreen_on_stream = v;
|
||||
}
|
||||
if let Some(v) = &self.present_priority {
|
||||
s.present_priority = v.clone();
|
||||
}
|
||||
if let Some(v) = self.smooth_buffer {
|
||||
s.smooth_buffer = v;
|
||||
}
|
||||
if let Some(v) = self.vsync {
|
||||
s.vsync = v;
|
||||
}
|
||||
if let Some(v) = self.allow_vrr {
|
||||
s.allow_vrr = v;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -220,12 +249,27 @@ impl SettingsOverlay {
|
||||
if after.gamepad != before.gamepad {
|
||||
self.gamepad = Some(after.gamepad.clone());
|
||||
}
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||
}
|
||||
if after.present_priority != before.present_priority {
|
||||
self.present_priority = Some(after.present_priority.clone());
|
||||
}
|
||||
if after.smooth_buffer != before.smooth_buffer {
|
||||
self.smooth_buffer = Some(after.smooth_buffer);
|
||||
}
|
||||
if after.vsync != before.vsync {
|
||||
self.vsync = Some(after.vsync);
|
||||
}
|
||||
if after.allow_vrr != before.allow_vrr {
|
||||
self.allow_vrr = Some(after.allow_vrr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||
@@ -257,8 +301,13 @@ impl SettingsOverlay {
|
||||
"invert_scroll" => self.invert_scroll = None,
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
"smooth_buffer" => self.smooth_buffer = None,
|
||||
"vsync" => self.vsync = None,
|
||||
"allow_vrr" => self.allow_vrr = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -433,6 +482,10 @@ mod tests {
|
||||
assert_eq!((out.width, out.height), (1920, 1080));
|
||||
assert_eq!(out.bitrate_kbps, 20000);
|
||||
assert_eq!(out.codec, "hevc");
|
||||
assert!(
|
||||
out.gamepad_forwarding,
|
||||
"default on, and an empty overlay leaves it alone"
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let overlay = SettingsOverlay {
|
||||
@@ -452,9 +505,14 @@ mod tests {
|
||||
invert_scroll: Some(true),
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
present_priority: Some("smooth".into()),
|
||||
smooth_buffer: Some(3),
|
||||
vsync: Some(false),
|
||||
allow_vrr: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!overlay.is_empty());
|
||||
@@ -473,9 +531,14 @@ mod tests {
|
||||
assert!(out.invert_scroll);
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
assert_eq!(out.present_priority, "smooth");
|
||||
assert_eq!(out.smooth_buffer, 3);
|
||||
assert!(!out.vsync);
|
||||
assert!(!out.allow_vrr);
|
||||
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
|
||||
// stays coherent with it.
|
||||
assert!(out.show_stats);
|
||||
@@ -573,6 +636,59 @@ mod tests {
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
/// The presentation cluster is first-class, not `extra` passengers: it applies,
|
||||
/// absorbs, clears, and serialises under the exact keys the Apple client already
|
||||
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
|
||||
/// has to round-trip through every platform, and a mismatched key would be carried
|
||||
/// but never applied.
|
||||
#[test]
|
||||
fn presentation_cluster_is_first_class() {
|
||||
let base = Settings::default();
|
||||
let mut o = SettingsOverlay::default();
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.present_priority = "smooth".into();
|
||||
o.absorb(&before, &after);
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.smooth_buffer = 1;
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
|
||||
assert_eq!(o.smooth_buffer, Some(1));
|
||||
assert!(
|
||||
o.extra.is_empty(),
|
||||
"modelled fields must never land in the passthrough"
|
||||
);
|
||||
let out = o.apply(&base);
|
||||
assert_eq!(
|
||||
out.present_priority(),
|
||||
crate::trust::PresentPriority::Smooth { buffer: 1 }
|
||||
);
|
||||
|
||||
// Serialised under the shared keys, and read back from a foreign client's file.
|
||||
let text = serde_json::to_string(&o).unwrap();
|
||||
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
|
||||
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
|
||||
let from_apple: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
|
||||
assert_eq!(from_apple.smooth_buffer, Some(2));
|
||||
assert_eq!(from_apple.vsync, Some(true));
|
||||
assert_eq!(from_apple.allow_vrr, Some(false));
|
||||
assert!(from_apple.extra.is_empty());
|
||||
|
||||
assert!(o.clear("present_priority"));
|
||||
assert!(o.clear("smooth_buffer"));
|
||||
assert_eq!(o.present_priority, None);
|
||||
assert!(o.is_empty());
|
||||
let mut vrr = from_apple;
|
||||
assert!(vrr.clear("vsync"));
|
||||
assert!(vrr.clear("allow_vrr"));
|
||||
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
|
||||
}
|
||||
|
||||
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||
#[test]
|
||||
fn clear_drops_one_override() {
|
||||
@@ -591,6 +707,29 @@ mod tests {
|
||||
assert!(!o.clear("no_such_field"));
|
||||
}
|
||||
|
||||
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
|
||||
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
|
||||
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
|
||||
/// one every client's reset button sends.
|
||||
#[test]
|
||||
fn gamepad_forwarding_overrides_off_and_resets_back() {
|
||||
let base = Settings::default();
|
||||
assert!(base.gamepad_forwarding, "the shipped default");
|
||||
|
||||
let mut o = SettingsOverlay::default();
|
||||
let mut after = base.clone();
|
||||
after.gamepad_forwarding = false;
|
||||
o.absorb(&base, &after);
|
||||
assert_eq!(o.gamepad_forwarding, Some(false));
|
||||
assert!(!o.apply(&base).gamepad_forwarding);
|
||||
|
||||
assert!(o.clear("gamepad_forwarding"));
|
||||
assert_eq!(o.gamepad_forwarding, None);
|
||||
assert!(o.is_empty());
|
||||
// Back to inheriting: the global's live value, not a remembered false.
|
||||
assert!(o.apply(&base).gamepad_forwarding);
|
||||
}
|
||||
|
||||
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
||||
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
||||
#[test]
|
||||
|
||||
@@ -424,11 +424,31 @@ fn pump(
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/vaapi/software).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
// The WIRE codec is the negotiated truth; the FFmpeg id is meaningful only where
|
||||
// FFmpeg decodes it. `ffmpeg_codec_id`'s fallthrough maps every unknown wire bit —
|
||||
// PyroWave included — to HEVC, so logging it unconditionally claimed
|
||||
// `codec_id=HEVC` for wavelet sessions that never touch FFmpeg at all.
|
||||
let codec = match connector.codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H264",
|
||||
punktfunk_core::quic::CODEC_HEVC => "HEVC",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "unknown",
|
||||
};
|
||||
if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
tracing::info!(
|
||||
codec,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
codec,
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
}
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
|
||||
@@ -14,6 +14,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::quic::endpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn config_dir() -> Result<PathBuf> {
|
||||
@@ -787,6 +788,45 @@ impl MouseMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation intent — what the presenter optimizes for
|
||||
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
|
||||
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
|
||||
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
|
||||
/// [`PresentPriority::resolve`], whose rules match the Android reference
|
||||
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
|
||||
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PresentPriority {
|
||||
/// Every frame presents the moment the display can take it; a network hiccup is an
|
||||
/// occasional repeated or skipped frame. The default.
|
||||
Latency,
|
||||
/// A small frame buffer (1–3 frames) evens out network/decode jitter, at the
|
||||
/// buffer's worth of added display latency.
|
||||
Smooth { buffer: u8 },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
|
||||
/// a foreign profile's values mean.
|
||||
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
|
||||
if name == "smooth" {
|
||||
PresentPriority::Smooth {
|
||||
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
|
||||
}
|
||||
} else {
|
||||
PresentPriority::Latency
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
|
||||
pub fn fifo_capacity(self) -> u8 {
|
||||
match self {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -808,6 +848,21 @@ pub struct Settings {
|
||||
/// container `#[serde(default)]`.
|
||||
pub render_scale: f64,
|
||||
pub gamepad: String,
|
||||
/// Forward this device's controllers to the host at all. Default ON — that was the
|
||||
/// unconditional behaviour before this became a setting.
|
||||
///
|
||||
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
|
||||
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
|
||||
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
|
||||
/// hands, and games read both.
|
||||
///
|
||||
/// It is deliberately stronger than "send no input": with it off the client never
|
||||
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
|
||||
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
|
||||
/// navigation in the launcher still opens the active pad, and the session releases it;
|
||||
/// see [`crate::gamepad::GamepadService::set_forwarding`].
|
||||
#[serde(default = "default_true")]
|
||||
pub gamepad_forwarding: bool,
|
||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
@@ -874,6 +929,32 @@ pub struct Settings {
|
||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||
#[serde(default = "default_true")]
|
||||
pub hdr_enabled: bool,
|
||||
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
|
||||
/// clients' shared `present_priority` profile key, resolved with
|
||||
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
|
||||
/// unknown reads as latency, so a newer client's future value degrades safely.
|
||||
#[serde(default = "default_present_priority")]
|
||||
pub present_priority: String,
|
||||
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 1–3.
|
||||
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
|
||||
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
|
||||
/// refresh of display latency.
|
||||
#[serde(default)]
|
||||
pub smooth_buffer: u8,
|
||||
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
|
||||
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
|
||||
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
|
||||
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
|
||||
/// key; the desktop default differs from macOS's (`false` there) deliberately —
|
||||
/// sync-off means something different on each platform, the key is the contract.
|
||||
#[serde(default = "default_true")]
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence: prefers the present
|
||||
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
|
||||
/// displays (detection is measured from on-glass timestamps, not queried). The
|
||||
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_vrr: bool,
|
||||
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||
@@ -925,6 +1006,14 @@ pub struct Settings {
|
||||
/// the user will be looking at. `0` = never stored → the 1280×720 default.
|
||||
pub last_window_w: u32,
|
||||
pub last_window_h: u32,
|
||||
/// Settings keys this build doesn't model (a newer client's field), carried through a
|
||||
/// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra`
|
||||
/// pattern extended to the globals. Without it, every whole-file writer of this store
|
||||
/// (two shells, the console settings screen, the session's resize callback, Decky)
|
||||
/// running as an OLDER binary silently drops what a newer one persisted. Empty on
|
||||
/// every existing store, and an empty map serializes to nothing, so files don't churn.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -939,6 +1028,10 @@ fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_present_priority() -> String {
|
||||
"latency".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -970,6 +1063,12 @@ impl Settings {
|
||||
MouseMode::from_name(&self.mouse_mode)
|
||||
}
|
||||
|
||||
/// The presentation intent for this session (the resolved
|
||||
/// `present_priority` × `smooth_buffer` pair).
|
||||
pub fn present_priority(&self) -> PresentPriority {
|
||||
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -994,6 +1093,7 @@ impl Default for Settings {
|
||||
bitrate_kbps: 0,
|
||||
render_scale: 1.0,
|
||||
gamepad: "auto".into(),
|
||||
gamepad_forwarding: true,
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
@@ -1007,6 +1107,10 @@ impl Default for Settings {
|
||||
adapter: String::new(),
|
||||
enable_444: false,
|
||||
hdr_enabled: true,
|
||||
present_priority: "latency".into(),
|
||||
smooth_buffer: 0,
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
show_stats: true,
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
@@ -1018,6 +1122,7 @@ impl Default for Settings {
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1144,6 +1249,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file predating the presentation cluster loads with the shipped
|
||||
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
|
||||
/// resolution rules match the Apple/Android reference: anything but an explicit
|
||||
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
|
||||
#[test]
|
||||
fn settings_presentation_defaults_and_resolution() {
|
||||
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.present_priority, "latency");
|
||||
assert_eq!(s.smooth_buffer, 0);
|
||||
assert!(s.vsync);
|
||||
assert!(s.allow_vrr);
|
||||
assert_eq!(s.present_priority(), PresentPriority::Latency);
|
||||
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 0),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"Automatic resolves to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 3),
|
||||
PresentPriority::Smooth { buffer: 3 }
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 9),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"out-of-range pins to the Automatic resolution"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("balanced-from-the-future", 2),
|
||||
PresentPriority::Latency,
|
||||
"unknown intents degrade to latency"
|
||||
);
|
||||
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
|
||||
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
|
||||
}
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
@@ -1192,6 +1334,28 @@ mod tests {
|
||||
assert!(s.echo_cancel);
|
||||
}
|
||||
|
||||
/// A key this build doesn't model (a newer client's setting) survives a load→save
|
||||
/// round trip instead of being dropped by the next whole-file write — the same
|
||||
/// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown
|
||||
/// keys, the flatten map adds nothing, so existing files don't churn.
|
||||
#[test]
|
||||
fn settings_unknown_keys_survive_round_trip() {
|
||||
let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#;
|
||||
let s: Settings = serde_json::from_str(newer).unwrap();
|
||||
assert_eq!((s.width, s.height), (1920, 1080));
|
||||
assert_eq!(
|
||||
s.extra.get("frob_mode").and_then(|v| v.as_str()),
|
||||
Some("fancy")
|
||||
);
|
||||
let out = serde_json::to_string(&s).unwrap();
|
||||
assert!(out.contains(r#""frob_mode":"fancy""#), "{out}");
|
||||
assert!(out.contains(r#""frob_level":3"#), "{out}");
|
||||
// No unknown keys → no artifact of the passthrough field in the file.
|
||||
let plain = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert!(!plain.contains("extra"), "{plain}");
|
||||
assert!(!plain.contains("frob"), "{plain}");
|
||||
}
|
||||
|
||||
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||
|
||||
@@ -321,6 +321,88 @@ pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a decoder for `codec_id` that can actually drive `hw_pix_fmt` through
|
||||
/// `hw_device_ctx` — the open-time capability check every hardware backend needs.
|
||||
///
|
||||
/// `avcodec_find_decoder(id)` is NOT that: it returns the registry's FIRST decoder for
|
||||
/// the id, and upstream orders the native `av1` decoder LAST on purpose ("hwaccel hooks
|
||||
/// only, so prefer external decoders" — allcodecs.c), behind libdav1d/libaom. The ID
|
||||
/// lookup therefore hands every AV1 session a pure software decoder that silently
|
||||
/// ignores `hw_device_ctx` and never calls `get_format`; each frame then fails the
|
||||
/// backend's hw-format guard and the session burns the demotion ladder MID-STREAM
|
||||
/// (~1 s per rung — field-logged as 68 Vulkan fails → D3D11VA → 102 fails → software,
|
||||
/// ~3 s of black) instead of failing here at open in milliseconds. H.264/HEVC never hit
|
||||
/// this only because their native decoders happen to be registered first.
|
||||
///
|
||||
/// The walk mirrors what `avcodec_find_decoder` would do, restricted to decoders whose
|
||||
/// `avcodec_get_hw_config` advertises the wanted surface via
|
||||
/// `AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX` — registry order still wins among those,
|
||||
/// so H.264/HEVC keep selecting exactly the decoder they always did. The error names
|
||||
/// the decoders that WERE found, so a log reader can tell "this build has no AV1
|
||||
/// hwaccel at all" from "no AV1 decoder exists, period".
|
||||
pub(crate) fn find_hw_decoder(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
hw_pix_fmt: ffmpeg::ffi::AVPixelFormat,
|
||||
) -> Result<*const ffmpeg::ffi::AVCodec> {
|
||||
use ffmpeg::ffi;
|
||||
let want: ffi::AVCodecID = codec_id.into();
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
// SAFETY: `av_codec_iterate` walks libav's static codec registry (`opaque` is its
|
||||
// cursor) and returns static `AVCodec`s; `avcodec_get_hw_config` only reads the
|
||||
// codec's own static hw-config table, NULL-terminated by returning null past the end.
|
||||
unsafe {
|
||||
let mut opaque = std::ptr::null_mut();
|
||||
loop {
|
||||
let codec = ffi::av_codec_iterate(&mut opaque);
|
||||
if codec.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*codec).id != want || ffi::av_codec_is_decoder(codec) == 0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0.. {
|
||||
let cfg = ffi::avcodec_get_hw_config(codec, i);
|
||||
if cfg.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*cfg).methods & ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32 != 0
|
||||
&& (*cfg).pix_fmt == hw_pix_fmt
|
||||
{
|
||||
return Ok(codec);
|
||||
}
|
||||
}
|
||||
found.push(
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
bail!("no {codec_id:?} decoder in this FFmpeg build");
|
||||
}
|
||||
bail!(
|
||||
"no {codec_id:?} decoder in this FFmpeg build can drive {hw_pix_fmt:?} via \
|
||||
hw_device_ctx (found: {})",
|
||||
found.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The name of a registry `AVCodec` (`(*codec).name`), owned — the field every decode
|
||||
/// log carries so `decoder="av1"` vs `decoder="libdav1d"` is one glance, not a debugger.
|
||||
///
|
||||
/// # Safety
|
||||
/// `codec` must point to a registered `AVCodec` (their `name` is a static NUL-terminated
|
||||
/// string, valid for the process).
|
||||
pub(crate) unsafe fn codec_name(codec: *const ffmpeg::ffi::AVCodec) -> String {
|
||||
// SAFETY: caller guarantees a registered AVCodec; `name` is its static C string.
|
||||
unsafe {
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
@@ -435,7 +517,11 @@ impl Decoder {
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -470,6 +556,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -490,6 +577,7 @@ impl Decoder {
|
||||
Ok(v) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video hardware decode active (presenter-shared device)"
|
||||
);
|
||||
return done(Backend::Vulkan(v));
|
||||
@@ -520,7 +608,11 @@ impl Decoder {
|
||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -548,6 +640,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -724,6 +817,7 @@ impl Decoder {
|
||||
match VaapiDecoder::new(self.codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
@@ -745,6 +839,7 @@ impl Decoder {
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = d.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to D3D11VA");
|
||||
self.backend = Backend::D3d11va(d);
|
||||
self.vaapi_fails = 0;
|
||||
|
||||
@@ -552,6 +552,10 @@ pub(crate) struct D3d11vaDecoder {
|
||||
/// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR
|
||||
/// pass-through ring; without it they keep the tonemap-to-sRGB ring.
|
||||
hdr10_out: bool,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the libav pointers are this decoder's own allocations (freed once in `Drop`) and the COM
|
||||
@@ -609,10 +613,16 @@ impl D3d11vaDecoder {
|
||||
if !d3d11va_decode_supported(hw_device.as_ptr()) {
|
||||
bail!("GPU can't create the D3D11VA decode surface pool");
|
||||
}
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's D3D11-format guard mid-stream,
|
||||
// even when the DXVA profile + pool probes above all passed. Select by
|
||||
// capability instead: the first decoder that can drive AV_PIX_FMT_D3D11
|
||||
// via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_D3D11)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
@@ -638,10 +648,16 @@ impl D3d11vaDecoder {
|
||||
video_context1,
|
||||
ring: None,
|
||||
hdr10_out,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<D3d11Frame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -830,6 +846,7 @@ impl D3d11vaDecoder {
|
||||
src_desc.Height,
|
||||
index,
|
||||
color.is_pq(),
|
||||
&self.name,
|
||||
);
|
||||
Ok(D3d11Frame {
|
||||
width,
|
||||
@@ -883,7 +900,15 @@ impl Drop for D3d11vaDecoder {
|
||||
/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver.
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
||||
fn log_layout_once(
|
||||
width: u32,
|
||||
height: u32,
|
||||
tex_w: u32,
|
||||
tex_h: u32,
|
||||
index: u32,
|
||||
pq: bool,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
@@ -894,6 +919,7 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
|
||||
tex_h,
|
||||
slice = index,
|
||||
pq,
|
||||
decoder,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ impl SoftwareDecoder {
|
||||
(*raw).thread_count = 0; // auto
|
||||
}
|
||||
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||
// Every construction site (session open, preference, mid-stream demotion) says
|
||||
// which decoder actually opened: for AV1 the ID lookup means libdav1d here —
|
||||
// deliberately (fastest CPU path; the native `av1` decoder has no software
|
||||
// path at all) — and the name in the log is what keeps that distinguishable
|
||||
// from the hardware lanes' capability-selected decoders.
|
||||
tracing::info!(?codec_id, decoder = codec.name(), "software decoder opened");
|
||||
Ok(SoftwareDecoder { decoder, sws: None })
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct VaapiDecoder {
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the three raw pointers (`ctx`, `packet`, `frame`) are allocations this decoder makes in
|
||||
@@ -80,11 +84,15 @@ impl VaapiDecoder {
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's VAAPI-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VAAPI via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VAAPI)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vaapi);
|
||||
@@ -109,10 +117,16 @@ impl VaapiDecoder {
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -207,7 +221,7 @@ impl VaapiDecoder {
|
||||
// a single modifier for the texture.
|
||||
let modifier = d.objects[0].format_modifier;
|
||||
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier);
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier, &self.name);
|
||||
|
||||
Ok(DmabufFrame {
|
||||
width: (*self.frame).width as u32,
|
||||
@@ -233,6 +247,7 @@ fn log_descriptor_once(
|
||||
sw: ffmpeg_next::ffi::AVPixelFormat,
|
||||
fourcc: u32,
|
||||
modifier: u64,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -250,6 +265,7 @@ fn log_descriptor_once(
|
||||
nb_layers = d.nb_layers,
|
||||
?layers,
|
||||
modifier = format_args!("{:#018x}", modifier),
|
||||
decoder,
|
||||
"VAAPI dmabuf descriptor layout (first frame)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ pub(crate) struct VulkanDecoder {
|
||||
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||
vk_device: pf_ffvk::VkDevice,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||
@@ -245,10 +249,15 @@ impl VulkanDecoder {
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's Vulkan-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VULKAN via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VULKAN)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
@@ -270,11 +279,17 @@ impl VulkanDecoder {
|
||||
frame: ffi::av_frame_alloc(),
|
||||
wait_semaphores,
|
||||
vk_device,
|
||||
name,
|
||||
_ctx_storage: store,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<VkVideoFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -388,6 +403,7 @@ impl VulkanDecoder {
|
||||
(*fc).width,
|
||||
(*fc).height,
|
||||
sw,
|
||||
&self.name,
|
||||
);
|
||||
Ok(VkVideoFrame {
|
||||
vkframe: vkf as usize,
|
||||
@@ -423,6 +439,7 @@ fn log_layout_once(
|
||||
pool_w: i32,
|
||||
pool_h: i32,
|
||||
sw: ffmpeg::ffi::AVPixelFormat,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -433,6 +450,7 @@ fn log_layout_once(
|
||||
pool_w,
|
||||
pool_h,
|
||||
?sw,
|
||||
decoder,
|
||||
"Vulkan Video first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ mod widgets;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
pub use model::{
|
||||
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
|
||||
};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use shell::ConsoleOptions;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
|
||||
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
|
||||
/// thread resolves these against the catalog; the shell never opens the profiles file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProfileChip {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
|
||||
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
|
||||
/// the shell renders it verbatim.
|
||||
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
|
||||
/// the service thread; the shell renders it verbatim.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HostRow {
|
||||
/// Stable identity across refreshes: the pinned fingerprint when known, else
|
||||
@@ -35,6 +46,14 @@ pub struct HostRow {
|
||||
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
|
||||
/// follow-up — the Skia glyph set doesn't exist yet.
|
||||
pub os: String,
|
||||
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
|
||||
/// after its host's primary tile, sharing its live state, that connects with THIS
|
||||
/// profile. `None` = the host's primary tile.
|
||||
pub pin: Option<ProfileChip>,
|
||||
/// The primary tile's default-profile chip: the profile bound as this host's default
|
||||
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
|
||||
/// Always `None` on pinned rows — there the profile IS `pin`.
|
||||
pub bound_profile: Option<ProfileChip>,
|
||||
}
|
||||
|
||||
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
|
||||
@@ -143,6 +162,16 @@ pub enum ConsoleCmd {
|
||||
CancelWake,
|
||||
/// Sweep reachability now (the home screen refreshes its presence pips).
|
||||
Probe,
|
||||
/// Pin (or unpin) a profile as an extra connect card on a saved host
|
||||
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
|
||||
/// (fingerprint or `addr:port`); presentation only — never touches the host's
|
||||
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
|
||||
/// (or unpinning an absent one) is a no-op.
|
||||
SetPin {
|
||||
key: String,
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
@@ -184,6 +213,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
shared.set_hosts(vec![row.clone()]);
|
||||
let g1 = shared.hosts_gen();
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod add_host;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod pair;
|
||||
pub(crate) mod pin_hosts;
|
||||
pub(crate) mod settings;
|
||||
|
||||
use crate::glyphs::Hint;
|
||||
@@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent {
|
||||
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||
/// binary parks on a long budget and persists the host as paired once let in.
|
||||
pub request_access: bool,
|
||||
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
|
||||
/// keeps the host's default binding.
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) enum Nav {
|
||||
@@ -91,6 +95,7 @@ pub(crate) enum Screen {
|
||||
Settings(settings::SettingsScreen),
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
@@ -106,6 +111,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.menu(ev, ctx, fx),
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ impl Screen {
|
||||
Screen::Settings(_) => "Settings".into(),
|
||||
Screen::AddHost(_) => "Add Host".into(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +169,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.hints(ctx),
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +191,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +94,19 @@ impl HomeScreen {
|
||||
Some(h) => {
|
||||
// Dial-first even when the presence pips say offline — a
|
||||
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
|
||||
// A pinned card connects with ITS profile (one-off, §5.2a);
|
||||
// the primary tile keeps the host's default binding.
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
|
||||
let max_w = f64::from(rect.width()) - 2.0 * pad;
|
||||
let sub_base = f64::from(rect.bottom) - pad;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
match (&h.pin, &h.bound_profile) {
|
||||
// A pinned card: the profile name IS the subtitle, tinted with its accent —
|
||||
// the card's whole point is "this host, with these settings" (§5.2a).
|
||||
(Some(p), _) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&p.name,
|
||||
l,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(p.accent.as_deref()),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
// The primary tile says which profile a plain press uses, after the address.
|
||||
(None, Some(b)) => {
|
||||
let addr = format!("{}:{}", h.addr, h.port);
|
||||
let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k));
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&addr,
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
let x = l + addr_w + 8.0 * k;
|
||||
if x < l + max_w {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("· {}", b.name),
|
||||
x,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(b.accent.as_deref()),
|
||||
l + max_w - x,
|
||||
);
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
}
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&h.name,
|
||||
@@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
|
||||
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
|
||||
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
|
||||
let Some(hex) = accent
|
||||
.and_then(|a| a.strip_prefix('#'))
|
||||
.filter(|h| h.len() == 6)
|
||||
else {
|
||||
return BRAND;
|
||||
};
|
||||
let Ok(v) = u32::from_str_radix(hex, 16) else {
|
||||
return BRAND;
|
||||
};
|
||||
skia_safe::Color4f::new(
|
||||
((v >> 16) & 0xff) as f32 / 255.0,
|
||||
((v >> 8) & 0xff) as f32 / 255.0,
|
||||
(v & 0xff) as f32 / 255.0,
|
||||
1.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
@@ -484,6 +556,8 @@ mod tests {
|
||||
can_wake,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +625,38 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
fn pinned_card_connects_with_its_profile() {
|
||||
let mut settings = ctx_settings();
|
||||
let mut pinned = host("ab\0p1", true, true, false);
|
||||
pinned.name = "Tower".into();
|
||||
pinned.pin = Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
});
|
||||
let hosts = [pinned];
|
||||
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
let intent = fx.connect.expect("a pinned card connects");
|
||||
assert_eq!(intent.profile.as_deref(), Some("p1"));
|
||||
assert_eq!(intent.title, "Tower · Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tile_is_always_last() {
|
||||
let mut settings = ctx_settings();
|
||||
|
||||
@@ -120,6 +120,8 @@ impl LibraryScreen {
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
request_access: false,
|
||||
// Game launches follow the host's default binding.
|
||||
profile: None,
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ impl PairScreen {
|
||||
launch: None,
|
||||
title: self.host_name.clone(),
|
||||
request_access: true,
|
||||
profile: None,
|
||||
});
|
||||
fx.pop();
|
||||
}
|
||||
@@ -430,6 +431,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card
|
||||
//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's
|
||||
//! Profiles section. One toggle row per saved host; a toggle rides
|
||||
//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles`
|
||||
//! and refreshes the rows — the row's shown state follows the model, so what the list
|
||||
//! says is always what the store holds (and what Decky's host list will render).
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
pub(crate) struct PinHostsScreen {
|
||||
profile_id: String,
|
||||
profile_name: String,
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the
|
||||
/// OUTPUT of this screen, not a row in it), in the model's carousel order.
|
||||
fn host_indices(ctx: &Ctx) -> Vec<usize> {
|
||||
ctx.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.saved && h.pin.is_none())
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl PinHostsScreen {
|
||||
pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen {
|
||||
PinHostsScreen {
|
||||
profile_id,
|
||||
profile_name,
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn profile_name(&self) -> &str {
|
||||
&self.profile_name
|
||||
}
|
||||
|
||||
/// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from
|
||||
/// the model — the pinned card's row IS the state, so the toggle can never disagree
|
||||
/// with what the carousel shows.
|
||||
fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool {
|
||||
let host = &ctx.hosts[host_idx];
|
||||
ctx.hosts.iter().any(|r| {
|
||||
r.addr == host.addr
|
||||
&& r.port == host.port
|
||||
&& r.pin.as_ref().is_some_and(|p| p.id == self.profile_id)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let indices = host_indices(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, indices.len());
|
||||
let Some(&host_idx) = indices.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Toggle semantics shared with the settings rows: left = unpin, right = pin,
|
||||
// A flips; asking for the state it's already in is a boundary thud.
|
||||
let target = match msg {
|
||||
ListMsg::Adjust(delta) => delta > 0,
|
||||
ListMsg::Activate => !self.pinned(ctx, host_idx),
|
||||
ListMsg::None => return pulse,
|
||||
};
|
||||
if self.pinned(ctx, host_idx) == target {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
fx.cmds.push(ConsoleCmd::SetPin {
|
||||
key: ctx.hosts[host_idx].key.clone(),
|
||||
profile_id: self.profile_id.clone(),
|
||||
pin: target,
|
||||
});
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if host_indices(ctx).is_empty() {
|
||||
return vec![Hint::new(HintKey::Back, "Done")];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Pin / Unpin"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let indices = host_indices(ctx);
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
if indices.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No saved hosts yet — pair with a host first, then pin this profile to it.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
f64::from(rect.width()) * 0.7,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The explainer band under the list, like the settings screen's detail text.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = indices
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let h = &ctx.hosts[i];
|
||||
let pinned = self.pinned(ctx, i);
|
||||
RowSpec {
|
||||
header: None,
|
||||
label: h.name.clone(),
|
||||
value: Some(if pinned {
|
||||
"Pinned".into()
|
||||
} else {
|
||||
"Off".into()
|
||||
}),
|
||||
value_dim: !pinned,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"A pinned profile appears as its own card on the host — one press connects with it.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{HostRow, ProfileChip};
|
||||
use crate::screens::Outbox;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow {
|
||||
HostRow {
|
||||
key: key.into(),
|
||||
name: key.into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: key.into(),
|
||||
paired: true,
|
||||
saved,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: pin.map(|id| ProfileChip {
|
||||
id: id.into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_sends_set_pin_for_the_focused_host() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host("aa", true, None), host("bb", true, None)];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: true,
|
||||
}]
|
||||
);
|
||||
|
||||
// Left on an unpinned host = already off = boundary, no command.
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reads_from_the_models_pinned_rows() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// Host "aa" already carries a pinned card for p1; its primary row toggles OFF.
|
||||
let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
// Only the primary row is a toggle row.
|
||||
assert_eq!(host_indices(&ctx).len(), 1);
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! read the same file, so values round-trip freely.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
@@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
/// index when the pad list under the "Use controller" row churns.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RowId {
|
||||
/// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens
|
||||
/// the pin-to-hosts screen. The console never edits profiles (design §5.4).
|
||||
Profile(usize),
|
||||
/// The Profiles section's placeholder while the catalog is empty.
|
||||
NoProfiles,
|
||||
Resolution,
|
||||
Refresh,
|
||||
RenderScale,
|
||||
@@ -26,9 +31,14 @@ enum RowId {
|
||||
Decoder,
|
||||
Hdr,
|
||||
Chroma444,
|
||||
PresentPriority,
|
||||
SmoothBuffer,
|
||||
Vsync,
|
||||
AllowVrr,
|
||||
Audio,
|
||||
Mic,
|
||||
EchoCancel,
|
||||
PadForward,
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
@@ -45,8 +55,9 @@ enum RowId {
|
||||
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
const ROWS: [RowId; 22] = [
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 27] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
@@ -56,9 +67,14 @@ const ROWS: [RowId; 22] = [
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::EchoCancel,
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
@@ -117,6 +133,17 @@ const DECODERS: [(&str, &str); 4] = [
|
||||
("software", "Software"),
|
||||
];
|
||||
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
|
||||
/// Presentation intent — the `present_priority` key shared with the Apple and Android
|
||||
/// clients, so one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [(&str, &str); 2] =
|
||||
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("auto", "Automatic"),
|
||||
("xbox360", "Xbox 360"),
|
||||
@@ -128,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub(crate) fn new() -> SettingsScreen {
|
||||
Self::with_profiles(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full row list: the fixed settings rows, then the Profiles section — one row
|
||||
/// per catalog profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
let mut ids = ROWS.to_vec();
|
||||
if self.profiles.is_empty() {
|
||||
ids.push(RowId::NoProfiles);
|
||||
} else {
|
||||
ids.extend((0..self.profiles.len()).map(RowId::Profile));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
@@ -147,7 +201,31 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ROWS.len());
|
||||
let ids = self.row_ids();
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let (id, name) = self.profiles[i].clone();
|
||||
fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new(
|
||||
id, name,
|
||||
)));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
|
||||
// screen is one of the settings file's several whole-file writers (profiles.rs
|
||||
// documents the no-merge debt), and adjusting a stale snapshot would silently
|
||||
@@ -159,7 +237,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -169,7 +247,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -179,11 +257,18 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
match self.row_ids()[self.list.cursor] {
|
||||
RowId::Profile(_) => vec![
|
||||
Hint::new(HintKey::Confirm, "Pin to hosts…"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
|
||||
_ => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
@@ -203,10 +288,14 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
|
||||
let ids = self.row_ids();
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ROWS[self.list.cursor]);
|
||||
let detail = detail(ids[self.list.cursor]);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -220,11 +309,51 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
match id {
|
||||
RowId::Profile(i) => {
|
||||
let (pid, name) = &profiles[i];
|
||||
let pins = ctx
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
|
||||
.count();
|
||||
return RowSpec {
|
||||
header: (i == 0).then_some("Profiles"),
|
||||
label: name.clone(),
|
||||
value: Some(match pins {
|
||||
0 => "Not pinned".into(),
|
||||
1 => "Pinned to 1 host".into(),
|
||||
n => format!("Pinned to {n} hosts"),
|
||||
}),
|
||||
value_dim: pins == 0,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
let mut row = RowSpec::action("No profiles yet", false);
|
||||
row.header = Some("Profiles");
|
||||
return row;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
|
||||
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
|
||||
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
||||
// move everything under the cursor).
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
@@ -279,6 +408,22 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
RowId::PresentPriority => (
|
||||
Some("Presentation"),
|
||||
"Prioritize",
|
||||
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
|
||||
),
|
||||
RowId::SmoothBuffer => (
|
||||
None,
|
||||
"Smoothness buffer",
|
||||
SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.find(|(v, _)| *v == s.smooth_buffer)
|
||||
.map_or("Automatic", |(_, l)| l)
|
||||
.into(),
|
||||
),
|
||||
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
|
||||
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
"Audio channels",
|
||||
@@ -290,8 +435,13 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
|
||||
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
|
||||
RowId::Pad => (
|
||||
RowId::PadForward => (
|
||||
Some("Controller"),
|
||||
"Forward controllers",
|
||||
on_off(s.gamepad_forwarding).into(),
|
||||
),
|
||||
RowId::Pad => (
|
||||
None,
|
||||
"Use controller",
|
||||
if s.forward_pad.is_empty() {
|
||||
"Automatic".into()
|
||||
@@ -331,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
|
||||
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
|
||||
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
|
||||
};
|
||||
RowSpec {
|
||||
header,
|
||||
@@ -365,7 +516,26 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
RowId::Chroma444 => {
|
||||
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
|
||||
HEVC only, and only where the host can encode it."
|
||||
Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \
|
||||
stream 4:2:0 and the session falls back silently."
|
||||
}
|
||||
RowId::PresentPriority => {
|
||||
"Lowest latency shows each frame the moment the display can take it — a \
|
||||
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
|
||||
buffers a little to even those out."
|
||||
}
|
||||
RowId::SmoothBuffer => {
|
||||
"Frames held back before showing. Each one absorbs about a refresh of network \
|
||||
hiccup and adds a refresh of delay. Automatic holds two."
|
||||
}
|
||||
RowId::Vsync => {
|
||||
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
|
||||
possible delay, at the cost of visible tearing. Not every driver offers it; \
|
||||
the stats overlay names the mode actually in use."
|
||||
}
|
||||
RowId::AllowVrr => {
|
||||
"On a VRR screen, let the panel refresh in step with the stream instead of on \
|
||||
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => {
|
||||
@@ -376,6 +546,11 @@ fn detail(id: RowId) -> &'static str {
|
||||
"Stops the host's audio, playing from this device's speakers, being picked up \
|
||||
and sent back. Turn it off if your microphone already runs its own processing."
|
||||
}
|
||||
RowId::PadForward => {
|
||||
"Send controllers connected to this device to the host. Turn it off when your \
|
||||
controller already reaches the host another way — USB passthrough such as \
|
||||
VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
}
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::Touch => {
|
||||
@@ -402,6 +577,16 @@ fn detail(id: RowId) -> &'static str {
|
||||
reached over a VPN, where the wake wait only adds delay."
|
||||
}
|
||||
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
|
||||
RowId::Profile(_) => {
|
||||
"Pin this profile to a host and it appears as its own card — one press \
|
||||
connects with these settings. Profiles are created and edited in the \
|
||||
Punktfunk desktop app."
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
"Profiles bundle stream settings for different uses (a low-latency one, a \
|
||||
quality one…). Create them in the Punktfunk desktop app, then pin them \
|
||||
here as one-press connect cards."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +647,27 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
|
||||
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
|
||||
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
|
||||
RowId::PresentPriority => {
|
||||
let cur = PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.present_priority);
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.smooth_buffer);
|
||||
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
|
||||
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
|
||||
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
|
||||
RowId::Audio => {
|
||||
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
|
||||
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
|
||||
@@ -475,7 +681,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
None
|
||||
}
|
||||
}
|
||||
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
|
||||
RowId::Pad => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
// Automatic first, then every connected pad by stable key.
|
||||
let keys: Vec<String> = std::iter::once(String::new())
|
||||
.chain(ctx.pads.iter().map(|p| p.key.clone()))
|
||||
@@ -483,7 +693,12 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let cur = keys.iter().position(|c| *c == s.forward_pad);
|
||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||
}
|
||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||
RowId::PadType => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
@@ -506,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
// Navigation rows, handled before the settings path in `menu` — never a value edit.
|
||||
RowId::Profile(_) | RowId::NoProfiles => None,
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
@@ -631,7 +848,7 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::EchoCancel, -1, false, &mut ctx),
|
||||
"mic off = thud"
|
||||
@@ -640,13 +857,52 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel, "and nothing was written");
|
||||
|
||||
ctx.settings.mic_enabled = true;
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx));
|
||||
assert!(!ctx.settings.echo_cancel);
|
||||
assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx));
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
@@ -720,4 +976,110 @@ mod tests {
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
/// value counts the pinned cards in the live model, activating opens the pin screen,
|
||||
/// and left/right (which edits every other row) is a boundary — a profile row
|
||||
/// navigates, it must never fall into the settings save path.
|
||||
#[test]
|
||||
fn profile_rows_navigate_instead_of_editing() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut pinned = crate::model::HostRow {
|
||||
key: "aa\0p1".into(),
|
||||
name: "Tower".into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
paired: true,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
};
|
||||
let hosts = [pinned.clone(), {
|
||||
pinned.key = "aa".into();
|
||||
pinned.pin = None;
|
||||
pinned
|
||||
}];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(vec![
|
||||
("p1".into(), "Work".into()),
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(ids.len(), ROWS.len() + 2);
|
||||
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert_eq!(spec.label, "Work");
|
||||
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
|
||||
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, None, "only the first row carries the header");
|
||||
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
|
||||
|
||||
s.list.cursor = ROWS.len(); // onto "Work"
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(b))
|
||||
if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")),
|
||||
"A on a profile row opens its pin screen"
|
||||
);
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// An empty catalog shows the explainer placeholder — present, inert, and dimmed —
|
||||
/// so the section still tells the user where profiles come from.
|
||||
#[test]
|
||||
fn empty_catalog_shows_the_placeholder() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert!(!spec.enabled);
|
||||
|
||||
s.list.cursor = ids.len() - 1;
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +239,14 @@ impl Shell {
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
// A wake started from a pinned card carries its profile
|
||||
// through to the connect (the row's key found it again).
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
})
|
||||
});
|
||||
self.bus.send(ConsoleCmd::CancelWake);
|
||||
@@ -269,6 +275,7 @@ impl Shell {
|
||||
launch: intent.launch,
|
||||
title: intent.title,
|
||||
request_access: intent.request_access,
|
||||
profile: intent.profile,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ fn hosts() -> Vec<HostRow> {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
vec![
|
||||
HostRow {
|
||||
|
||||
@@ -88,8 +88,9 @@ pub enum ConsoleEntry {
|
||||
/// The host list (bare `--browse`).
|
||||
Home,
|
||||
/// Home with this host's library already pushed (`--browse host` — the Decky
|
||||
/// per-host launch; B backs out to Home).
|
||||
Library(HostRow),
|
||||
/// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless
|
||||
/// `Home` variant when it learned its profile chips.
|
||||
Library(Box<HostRow>),
|
||||
}
|
||||
|
||||
/// The binary's ends of the console: models to write, commands to serve.
|
||||
|
||||
@@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where desktop audio should be audible — which decides the render endpoint the loopback captures.
|
||||
///
|
||||
/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`,
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env`
|
||||
/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an
|
||||
/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when
|
||||
/// the only way to express it is an undocumented environment variable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AudioOutputMode {
|
||||
/// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not
|
||||
/// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry
|
||||
/// the mix without narrowing it — otherwise real hardware wins anyway.
|
||||
#[default]
|
||||
ClientOnly,
|
||||
/// Prefer real hardware: audio plays on the host as well as the client. The old
|
||||
/// `PUNKTFUNK_HOST_AUDIO=1`.
|
||||
HostAndClient,
|
||||
/// Touch nothing — capture whatever the operator's own default playback device is, and never
|
||||
/// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`.
|
||||
FollowDefault,
|
||||
}
|
||||
|
||||
impl AudioOutputMode {
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default`
|
||||
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
|
||||
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
|
||||
fn from_env() -> AudioOutputMode {
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
|
||||
if !raw.trim().is_empty() {
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
return AudioOutputMode::FollowDefault;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() {
|
||||
return AudioOutputMode::HostAndClient;
|
||||
}
|
||||
AudioOutputMode::ClientOnly
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<AudioOutputMode> {
|
||||
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"client_only" | "client" => Some(AudioOutputMode::ClientOnly),
|
||||
"host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient),
|
||||
"follow_default" | "follow" => Some(AudioOutputMode::FollowDefault),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AudioOutputMode::ClientOnly => "client_only",
|
||||
AudioOutputMode::HostAndClient => "host_and_client",
|
||||
AudioOutputMode::FollowDefault => "follow_default",
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback plan should prefer real hardware over a silent sink.
|
||||
pub fn prefers_host_hardware(self) -> bool {
|
||||
matches!(self, AudioOutputMode::HostAndClient)
|
||||
}
|
||||
|
||||
/// Leave the operator's default playback/recording devices completely alone.
|
||||
pub fn keeps_default(self) -> bool {
|
||||
matches!(self, AudioOutputMode::FollowDefault)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -99,6 +175,24 @@ pub struct HostConfig {
|
||||
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
|
||||
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
|
||||
pub chacha20: bool,
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which
|
||||
/// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`).
|
||||
///
|
||||
/// A first-class setting because the 2026-08-03 field report needed one: the default
|
||||
/// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual
|
||||
/// endpoint for 25 sessions, and the only way to change it was an undocumented environment
|
||||
/// variable. See [`AudioOutputMode`].
|
||||
pub audio_output_mode: AudioOutputMode,
|
||||
/// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default
|
||||
/// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and
|
||||
/// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it
|
||||
/// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than
|
||||
/// silently downgrading someone's audio.
|
||||
pub audio_quality: Option<String>,
|
||||
/// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None`
|
||||
/// (the default) = automatic: sent only to a client that asked for it, and only while the link
|
||||
/// is actually losing packets.
|
||||
pub audio_redundancy: Option<bool>,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a
|
||||
@@ -246,6 +340,9 @@ impl HostConfig {
|
||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
audio_output_mode: AudioOutputMode::from_env(),
|
||||
audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()),
|
||||
audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
// Default ON while the interval-stutter field program runs (see the field doc).
|
||||
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
|
||||
@@ -348,4 +445,50 @@ mod tests {
|
||||
// An invalid rate stays invalid rather than being laundered into a real one.
|
||||
assert_eq!(c.game_fps(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_output_mode_parses_its_spellings() {
|
||||
for (s, want) in [
|
||||
("client_only", AudioOutputMode::ClientOnly),
|
||||
("client-only", AudioOutputMode::ClientOnly),
|
||||
(" CLIENT ", AudioOutputMode::ClientOnly),
|
||||
("host_and_client", AudioOutputMode::HostAndClient),
|
||||
("both", AudioOutputMode::HostAndClient),
|
||||
("follow_default", AudioOutputMode::FollowDefault),
|
||||
("follow", AudioOutputMode::FollowDefault),
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}");
|
||||
}
|
||||
// Unknown spellings are rejected so the caller can say so, not silently re-routed.
|
||||
for s in ["", "silent", "off", "true"] {
|
||||
assert_eq!(AudioOutputMode::parse(s), None, "{s:?}");
|
||||
}
|
||||
// Round-trip through the canonical spelling.
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// The two predicates are what the wiring plan and the capture loop actually branch on, and
|
||||
/// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different
|
||||
/// promises, and conflating them would either silence the host or stomp the operator's devices.
|
||||
#[test]
|
||||
fn audio_output_mode_predicates_are_disjoint() {
|
||||
assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly);
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}");
|
||||
}
|
||||
assert!(AudioOutputMode::HostAndClient.prefers_host_hardware());
|
||||
assert!(AudioOutputMode::FollowDefault.keeps_default());
|
||||
assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware());
|
||||
assert!(!AudioOutputMode::ClientOnly.keeps_default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +669,11 @@ impl GamepadManager {
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the uinput node would outlive the controller. The swept
|
||||
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
|
||||
// rumble internally); if that ever changes, consume it like the other two backends do.
|
||||
self.slots.reap();
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
|
||||
@@ -62,15 +62,30 @@ impl<P> PadSlots<P> {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
|
||||
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
|
||||
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
|
||||
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
|
||||
/// clear for a few frames and returns re-arms nothing.
|
||||
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
|
||||
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
|
||||
/// per-index sibling state; an index another manager owns is `None` here, so it is never
|
||||
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
|
||||
/// frames and returns re-arms nothing.
|
||||
///
|
||||
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
|
||||
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
|
||||
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
|
||||
/// called `sweep` would keep the detached pad alive for the rest of the session.
|
||||
pub fn sweep(&mut self, active_mask: u16) -> u16 {
|
||||
self.sweep_at(active_mask, Instant::now())
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
|
||||
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
|
||||
///
|
||||
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
|
||||
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
|
||||
/// That is what makes it safe to call from a hot pump loop.
|
||||
pub fn reap(&mut self) -> u16 {
|
||||
self.reap_at(Instant::now())
|
||||
}
|
||||
|
||||
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
|
||||
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
|
||||
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
|
||||
@@ -81,26 +96,37 @@ impl<P> PadSlots<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
|
||||
/// each slot's clock from the mask, then reap whatever has already run out.
|
||||
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
for i in 0..MAX_PADS {
|
||||
if active_mask & (1 << i) != 0 {
|
||||
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
|
||||
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
|
||||
self.inactive_since[i] = Some(now); // newly inactive — start the grace
|
||||
}
|
||||
}
|
||||
self.reap_at(now)
|
||||
}
|
||||
|
||||
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
|
||||
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
|
||||
/// and cannot be dropped here.
|
||||
fn reap_at(&mut self, now: Instant) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(since) = self.inactive_since[i] else {
|
||||
continue; // active, or never went clear — nothing to complete
|
||||
};
|
||||
if self.pads[i].is_none() {
|
||||
self.inactive_since[i] = None; // the slot went away by some other route
|
||||
continue;
|
||||
}
|
||||
if slot.is_none() {
|
||||
continue;
|
||||
}
|
||||
match self.inactive_since[i] {
|
||||
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
|
||||
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
*slot = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
Some(_) => {} // inside the grace — hold
|
||||
if now.duration_since(since) >= SWEEP_GRACE {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
self.pads[i] = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
}
|
||||
swept
|
||||
@@ -161,6 +187,56 @@ mod tests {
|
||||
PadSlots::new("Test", "test pad", "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_frame_plus_a_reap_completes_the_unplug() {
|
||||
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
|
||||
// with no further frame. Before the arm/reap split the pad survived here forever.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(2, |i| Ok(i as u32)));
|
||||
assert_eq!(
|
||||
s.sweep(0b0),
|
||||
0,
|
||||
"a frame arms the grace but cannot itself drop"
|
||||
);
|
||||
assert!(s.get(2).is_some());
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
|
||||
assert!(s.get(2).is_none());
|
||||
assert_eq!(s.reap(), 0, "nothing left to reap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
|
||||
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
|
||||
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
for _ in 0..10 {
|
||||
assert_eq!(s.reap(), 0);
|
||||
s.expire_grace();
|
||||
}
|
||||
assert!(
|
||||
s.get(0).is_some(),
|
||||
"reap dropped a pad that never went inactive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
|
||||
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
|
||||
// comes back must not churn a PnP devnode.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
|
||||
for _ in 0..5 {
|
||||
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
|
||||
}
|
||||
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
|
||||
assert!(s.get(0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_once_and_reports_freshness() {
|
||||
let mut s = slots();
|
||||
|
||||
@@ -35,10 +35,14 @@ pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at by
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date); bytes 44..46
|
||||
// = update version, kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value
|
||||
// makes PlayStation Accessories and libScePad titles demand a firmware update the virtual pad
|
||||
// cannot take ("can't complete the update"), and ≥ 0x0224 is what puts writers on the
|
||||
// COMPATIBLE_VIBRATION2 convention parse_ds_output accepts alongside flag0.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
@@ -494,10 +498,13 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
|
||||
@@ -217,13 +217,10 @@ impl<B: PadProto> UhidManager<B> {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump` tick — this frame is the only one the producer sends).
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
self.reset_swept(swept);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
@@ -282,6 +279,12 @@ impl<B: PadProto> UhidManager<B> {
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
// Finish any unplug whose removal frame only armed the grace. The producer emits that
|
||||
// frame exactly once, so without this a detached pad — the single-pad session being the
|
||||
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
|
||||
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
@@ -360,6 +363,18 @@ impl<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
|
||||
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
|
||||
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
|
||||
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
|
||||
/// (re)connect starts from scratch and is always forwarded.
|
||||
fn reset_pad(&mut self, idx: usize) {
|
||||
@@ -494,18 +509,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_frame_never_recreates_the_pad_it_swept() {
|
||||
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
|
||||
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
|
||||
// the bit still being set — so the teardown has to finish on the periodic pump. The
|
||||
// previous version of this test hand-fed a SECOND removal frame, which is what let the
|
||||
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(1, 0b10, 0));
|
||||
assert!(m.slots.get(1).is_some());
|
||||
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
|
||||
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
|
||||
// The one removal frame: arms the devnode-churn grace, drops nothing.
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
|
||||
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
|
||||
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_some(),
|
||||
"a tick inside the grace dropped it"
|
||||
);
|
||||
// Grace elapsed: the next tick completes the unplug, with no further frame.
|
||||
m.slots.expire_grace();
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"the pump tick never completed the unplug"
|
||||
);
|
||||
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_none());
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"a cleared-mask frame recreated the pad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -551,10 +584,15 @@ mod tests {
|
||||
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again.
|
||||
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
|
||||
// on a PUMP tick, not on a second frame — that is all production ever sends.
|
||||
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
|
||||
m.slots.expire_grace();
|
||||
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
|
||||
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
|
||||
assert!(
|
||||
m.slots.get(0).is_none(),
|
||||
"the pump tick completed the unplug"
|
||||
);
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
|
||||
@@ -318,14 +318,10 @@ impl GamepadManager {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
self.reset_swept(swept);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -345,10 +341,25 @@ impl GamepadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
|
||||
/// unplug clear the same things.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the XUSB devnode would outlive the controller.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
|
||||
@@ -52,6 +52,8 @@ pub mod keymap_sdl;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod overlay;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod present_pace;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod touch;
|
||||
|
||||
@@ -84,6 +84,11 @@ pub enum OverlayAction {
|
||||
fp_hex: String,
|
||||
launch: Option<String>,
|
||||
title: String,
|
||||
/// One-off settings-profile override for THIS launch (a profile id — a pinned
|
||||
/// card's connect). `None` resolves the host's default binding as before; the
|
||||
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
|
||||
/// falls back to the defaults and never blocks the connect.
|
||||
profile: Option<String>,
|
||||
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||||
/// open a connect the host PARKS until the operator approves this device in its
|
||||
/// console (a long connect budget), then persist it as paired. `false` = an
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
|
||||
//! store, clock, and gate the run loop composes into the two intents.
|
||||
//!
|
||||
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
|
||||
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
|
||||
//! three clients agree on what the intents mean.
|
||||
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
|
||||
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
|
||||
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
|
||||
//! last submit on the mode's refresh period.
|
||||
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
|
||||
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
|
||||
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
|
||||
//! queue and never needs it.
|
||||
//!
|
||||
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
|
||||
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
|
||||
//! owns all clocks and Vulkan calls, which is what keeps this testable.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Stale-present force-open: an undisplayed present older than this is presumed lost
|
||||
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
|
||||
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
|
||||
const STALE_REOPEN_NS: u64 = 100_000_000;
|
||||
|
||||
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
|
||||
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
|
||||
/// when measured misses demand it).
|
||||
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
|
||||
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
|
||||
|
||||
/// The decoded-frame store between the wake channel and the present call.
|
||||
///
|
||||
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
|
||||
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
|
||||
/// an underflow after preroll re-arms the preroll (the previous frame persists on
|
||||
/// glass — a repeat by omission) while headroom rebuilds.
|
||||
pub(crate) struct FrameStore<T> {
|
||||
capacity: usize,
|
||||
frames: VecDeque<T>,
|
||||
prerolled: bool,
|
||||
/// Newest-wins displacements (normal operation under latency, not a fault signal).
|
||||
replaced: u32,
|
||||
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
|
||||
overflow_drops: u32,
|
||||
/// FIFO dry-after-preroll events — `qDry`.
|
||||
underflows: u32,
|
||||
}
|
||||
|
||||
impl<T> FrameStore<T> {
|
||||
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
|
||||
FrameStore {
|
||||
capacity,
|
||||
frames: VecDeque::with_capacity(capacity.max(1) + 1),
|
||||
prerolled: false,
|
||||
replaced: 0,
|
||||
overflow_drops: 0,
|
||||
underflows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_smoothing(&self) -> bool {
|
||||
self.capacity > 0
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn submit(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.pop_front().is_some() {
|
||||
self.replaced += 1;
|
||||
}
|
||||
self.frames.push_back(f);
|
||||
} else {
|
||||
self.frames.push_back(f);
|
||||
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
|
||||
// flowing. Also trims a transient capacity+1 a put_back left behind.
|
||||
while self.frames.len() > self.capacity {
|
||||
self.frames.pop_front();
|
||||
self.overflow_drops += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> Option<T> {
|
||||
if self.capacity == 0 {
|
||||
return self.frames.pop_front();
|
||||
}
|
||||
if !self.prerolled {
|
||||
// Preroll gate: without it a steady stream drains every frame on arrival
|
||||
// and jitter headroom never builds (the Apple store's lesson).
|
||||
if self.frames.len() < self.capacity {
|
||||
return None;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
match self.frames.pop_front() {
|
||||
Some(f) => Some(f),
|
||||
None => {
|
||||
self.underflows += 1;
|
||||
self.prerolled = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame taken but not presented (gate closed, present failed before consuming
|
||||
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
|
||||
/// FIFO puts it back at the front (it is the oldest).
|
||||
pub(crate) fn put_back(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.is_empty() {
|
||||
self.frames.push_back(f);
|
||||
}
|
||||
} else {
|
||||
self.frames.push_front(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
|
||||
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
|
||||
/// frames make buffering pointless anyway).
|
||||
///
|
||||
/// Gated with its only caller: the power-user build (`--no-default-features`, which
|
||||
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
|
||||
/// is dead code there.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
pub(crate) fn force_latency(&mut self) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
self.capacity = 0;
|
||||
self.prerolled = false;
|
||||
while self.frames.len() > 1 {
|
||||
self.frames.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
|
||||
let c = (self.replaced, self.overflow_drops, self.underflows);
|
||||
self.replaced = 0;
|
||||
self.overflow_drops = 0;
|
||||
self.underflows = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
|
||||
/// forward for slot targeting.
|
||||
///
|
||||
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
|
||||
/// rule. An earlier version of this clock capped the learned period at the display
|
||||
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
|
||||
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
|
||||
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
|
||||
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
|
||||
/// compositor running its own rate) a downward-only learner pins a grid that never
|
||||
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
|
||||
/// narrowing at once, widening only after eight consecutive agreeing observations and
|
||||
/// then to the narrowest of them.
|
||||
///
|
||||
/// What is fed to it is still the window's MIN spacing: within one window that resists
|
||||
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
|
||||
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
|
||||
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
|
||||
pub(crate) struct LatchClock {
|
||||
anchor_ns: u64,
|
||||
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
|
||||
/// every pass, so a "batch" is very often a single stamp — computing spacings only
|
||||
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
|
||||
/// silently ran on its seed forever.
|
||||
last_ns: u64,
|
||||
/// Narrowest spacing seen since the last handoff to the grid, and how many have
|
||||
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
|
||||
/// observations are the spacing of OUR presents, which is k×period whenever the
|
||||
/// stream runs below panel rate, and the min over a run is the best available
|
||||
/// estimate of the true grid step.
|
||||
pending_min_ns: u64,
|
||||
pending_count: u32,
|
||||
grid: punktfunk_core::phase::PanelGrid,
|
||||
fallback_period_ns: u64,
|
||||
}
|
||||
|
||||
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
|
||||
/// mode change is picked up in well under a second at any sane frame rate.
|
||||
const GRID_OBSERVE_EVERY: u32 = 16;
|
||||
|
||||
impl LatchClock {
|
||||
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
|
||||
LatchClock {
|
||||
anchor_ns: 0,
|
||||
last_ns: 0,
|
||||
pending_min_ns: 0,
|
||||
pending_count: 0,
|
||||
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
|
||||
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
|
||||
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
|
||||
/// the learner.
|
||||
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
|
||||
for &s in stamps {
|
||||
if self.last_ns != 0 && s > self.last_ns {
|
||||
let d = s - self.last_ns;
|
||||
// < 1 ms apart = a queued pair, not a grid step.
|
||||
if d > 1_000_000 {
|
||||
self.pending_min_ns = if self.pending_min_ns == 0 {
|
||||
d
|
||||
} else {
|
||||
self.pending_min_ns.min(d)
|
||||
};
|
||||
self.pending_count += 1;
|
||||
if self.pending_count >= GRID_OBSERVE_EVERY {
|
||||
self.grid.observe(self.pending_min_ns as i64);
|
||||
self.pending_min_ns = 0;
|
||||
self.pending_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_ns = s;
|
||||
}
|
||||
if let Some(&last) = stamps.last() {
|
||||
self.anchor_ns = last;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn period_ns(&self) -> u64 {
|
||||
let learned = self.grid.period_ns();
|
||||
if learned > 0 {
|
||||
learned as u64
|
||||
} else {
|
||||
self.fallback_period_ns
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn anchor_ns(&self) -> u64 {
|
||||
self.anchor_ns
|
||||
}
|
||||
|
||||
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
|
||||
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
|
||||
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
|
||||
let p = self.period_ns();
|
||||
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
|
||||
return after_ns.saturating_add(p);
|
||||
}
|
||||
let k = (after_ns - self.anchor_ns) / p + 1;
|
||||
self.anchor_ns + k * p
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the panel is refreshing on a fixed grid or following our cadence.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum Cadence {
|
||||
/// Not enough evidence yet — say nothing rather than guess.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
|
||||
Fixed,
|
||||
/// On-glass instants track our present spacing instead: variable refresh is live.
|
||||
Variable,
|
||||
}
|
||||
|
||||
impl Cadence {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Cadence::Unknown => "",
|
||||
Cadence::Fixed => "no",
|
||||
Cadence::Variable => "yes",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is variable refresh actually live? **Measured, never queried** — no portable query
|
||||
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
|
||||
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
|
||||
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
|
||||
///
|
||||
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
|
||||
/// lands on the vblank grid, so the spacing between consecutive presents is always
|
||||
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
|
||||
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
|
||||
/// follows our own cadence and sits wherever it likes relative to the grid.
|
||||
///
|
||||
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
|
||||
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
|
||||
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
|
||||
/// harmless — at that rate VRR has nothing to do.
|
||||
pub(crate) struct CadenceProbe {
|
||||
/// Off-grid distances as a fraction of the period, in thousandths.
|
||||
off_grid_milli: Vec<u32>,
|
||||
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
|
||||
/// live drain hands over one sample at a time.
|
||||
last_ns: u64,
|
||||
/// The last round's raw reading and how many rounds have agreed — a verdict is only
|
||||
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
|
||||
candidate: Cadence,
|
||||
agree_rounds: u8,
|
||||
verdict: Cadence,
|
||||
}
|
||||
|
||||
/// Enough deltas to distinguish jitter from a real off-grid cadence.
|
||||
const CADENCE_MIN_SAMPLES: usize = 24;
|
||||
/// Consecutive agreeing rounds before a verdict is published.
|
||||
///
|
||||
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
|
||||
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
|
||||
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
|
||||
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
|
||||
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
|
||||
/// therefore only ever say "presents are not landing on the grid", so it demands
|
||||
/// agreement across rounds and refuses evidence from a distressed window (see
|
||||
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
|
||||
const CADENCE_STABLE_ROUNDS: u8 = 2;
|
||||
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
|
||||
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
|
||||
/// this is deliberately loose — the two regimes differ by far more than this in practice.
|
||||
const CADENCE_FIXED_MILLI: u32 = 150;
|
||||
|
||||
impl CadenceProbe {
|
||||
pub(crate) fn new() -> CadenceProbe {
|
||||
CadenceProbe {
|
||||
off_grid_milli: Vec::with_capacity(64),
|
||||
last_ns: 0,
|
||||
candidate: Cadence::Unknown,
|
||||
agree_rounds: 0,
|
||||
verdict: Cadence::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps against the learned panel period. Spacings are measured
|
||||
/// against the previous stamp whatever the batching.
|
||||
///
|
||||
/// `healthy` is the caller's statement that this window's presents were flowing
|
||||
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
|
||||
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
|
||||
/// continuity is still advanced, it simply does not count as a sample.
|
||||
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
|
||||
if period_ns == 0 || !healthy {
|
||||
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
|
||||
return;
|
||||
}
|
||||
for &s in stamps {
|
||||
let prev = std::mem::replace(&mut self.last_ns, s);
|
||||
if prev == 0 || s <= prev {
|
||||
continue;
|
||||
}
|
||||
let delta = s - prev;
|
||||
let rem = delta % period_ns;
|
||||
// Distance to the NEAREST multiple, so a delta just under k×period reads as
|
||||
// close to the grid rather than a whole period away from k-1.
|
||||
let off = rem.min(period_ns - rem);
|
||||
self.off_grid_milli
|
||||
.push((off.saturating_mul(1000) / period_ns) as u32);
|
||||
// A round closes on the SAMPLE count, inside the loop — not once per call.
|
||||
// Evaluating per call would make the verdict depend on how the caller happens
|
||||
// to batch its stamps (one big batch = one round, forever short of the
|
||||
// agreement requirement), and the live drain and the tests batch differently.
|
||||
self.close_round_if_ready();
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a verdict once a round's worth of spacings agree with the previous round.
|
||||
fn close_round_if_ready(&mut self) {
|
||||
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
|
||||
self.off_grid_milli.sort_unstable();
|
||||
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
|
||||
let round = if median <= CADENCE_FIXED_MILLI {
|
||||
Cadence::Fixed
|
||||
} else {
|
||||
Cadence::Variable
|
||||
};
|
||||
if round == self.candidate {
|
||||
self.agree_rounds = self.agree_rounds.saturating_add(1);
|
||||
} else {
|
||||
self.candidate = round;
|
||||
self.agree_rounds = 1;
|
||||
}
|
||||
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
|
||||
self.verdict = round;
|
||||
}
|
||||
self.off_grid_milli.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verdict(&self) -> Cadence {
|
||||
self.verdict
|
||||
}
|
||||
|
||||
/// A mode switch / display change invalidates the evidence.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.off_grid_milli.clear();
|
||||
self.last_ns = 0;
|
||||
self.candidate = Cadence::Unknown;
|
||||
self.agree_rounds = 0;
|
||||
self.verdict = Cadence::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
|
||||
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
|
||||
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
|
||||
/// then exactly the shipped arrival pacing).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PresentGate {
|
||||
/// Submit stamp of the newest tracked present; 0 = none yet.
|
||||
last_present_ns: u64,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
impl PresentGate {
|
||||
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
|
||||
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
|
||||
/// stream survives, counted as `forced`.
|
||||
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
|
||||
if outstanding == 0 {
|
||||
return true;
|
||||
}
|
||||
if self.last_present_ns != 0
|
||||
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
|
||||
{
|
||||
self.forced += 1;
|
||||
return true;
|
||||
}
|
||||
self.gated += 1;
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn note_present(&mut self, now_ns: u64) {
|
||||
self.last_present_ns = now_ns;
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(gated, forced)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
|
||||
let c = (self.gated, self.forced);
|
||||
self.gated = 0;
|
||||
self.forced = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
|
||||
#[test]
|
||||
fn newest_wins_replaces_and_putback_never_clobbers() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(0);
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives");
|
||||
assert_eq!(s.take(), None);
|
||||
// A taken-but-unpresented frame returns — unless a fresher one arrived.
|
||||
s.submit(4);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(4));
|
||||
let f = s.take();
|
||||
assert_eq!(f, None);
|
||||
s.submit(5);
|
||||
let f = s.take().unwrap();
|
||||
s.submit(6);
|
||||
s.put_back(f); // 6 arrived while 5 was out — 6 wins
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(
|
||||
s.take_counters(),
|
||||
(2, 0, 0),
|
||||
"two displacements, no fifo counters"
|
||||
);
|
||||
}
|
||||
|
||||
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
|
||||
#[test]
|
||||
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
assert!(s.is_smoothing());
|
||||
s.submit(1);
|
||||
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
|
||||
s.submit(2);
|
||||
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
|
||||
assert_eq!(
|
||||
s.take(),
|
||||
Some(2),
|
||||
"once prerolled the buffer drains normally"
|
||||
);
|
||||
// Dry after preroll = one underflow, preroll re-arms.
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), None, "re-armed preroll holds again");
|
||||
s.submit(4);
|
||||
assert_eq!(s.take(), Some(3));
|
||||
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
|
||||
s.submit(5);
|
||||
s.submit(6);
|
||||
s.submit(7);
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(s.take(), Some(7));
|
||||
let (replaced, drops, dry) = s.take_counters();
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
|
||||
assert_eq!(dry, 1);
|
||||
}
|
||||
|
||||
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
|
||||
/// capacity+1 is trimmed by the next submit.
|
||||
#[test]
|
||||
fn fifo_putback_restores_order() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
|
||||
}
|
||||
|
||||
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn force_latency_collapses_to_one_slot() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(3);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
s.force_latency();
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
|
||||
s.submit(4);
|
||||
s.submit(5);
|
||||
assert_eq!(s.take(), Some(5));
|
||||
}
|
||||
|
||||
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
|
||||
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
|
||||
/// double-present) never become the period.
|
||||
#[test]
|
||||
fn latch_clock_learns_and_extrapolates() {
|
||||
const P: u64 = 16_666_666; // 60 Hz
|
||||
let mut c = LatchClock::new(60);
|
||||
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
|
||||
// No anchor: a usable deadline one period out.
|
||||
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
|
||||
|
||||
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
|
||||
let next = c.next_slot_after(c.anchor_ns());
|
||||
assert_eq!(next, 1_000_000_000 + 3 * P);
|
||||
// Mid-slot query lands on the same boundary; a later one steps whole periods.
|
||||
assert_eq!(c.next_slot_after(next - 1), next);
|
||||
assert_eq!(c.next_slot_after(next), next + P);
|
||||
|
||||
// A queued pair (< 1 ms apart) must not poison the period.
|
||||
c.note_batch(&[2_000_000_000, 2_000_000_500]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
|
||||
|
||||
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
|
||||
// such window must NOT move the grid — the shared learner needs a streak before
|
||||
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
|
||||
// panel while still allowing a genuinely slower display to be discovered.
|
||||
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
|
||||
|
||||
// A single stamp re-anchors without touching the period.
|
||||
c.note_batch(&[5_000_000_000]);
|
||||
assert_eq!(c.anchor_ns(), 5_000_000_000);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
|
||||
// A faster panel learns its own finer grid.
|
||||
let mut fast = LatchClock::new(120);
|
||||
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
|
||||
assert_eq!(fast.period_ns(), 8_333_333);
|
||||
}
|
||||
|
||||
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
|
||||
/// time. Measuring spacings only within a batch meant the learner observed nothing on
|
||||
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
|
||||
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
|
||||
/// wrong the moment the mode lies).
|
||||
#[test]
|
||||
fn latch_clock_learns_from_one_sample_at_a_time() {
|
||||
const REAL: u64 = 16_666_666;
|
||||
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]); // ONE stamp per call — the live shape
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"single-stamp batches must still feed the grid learner"
|
||||
);
|
||||
assert_eq!(c.anchor_ns(), t);
|
||||
}
|
||||
|
||||
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
|
||||
/// compositor running its own rate leaves the seed too fast. The old downward-only
|
||||
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
|
||||
/// shared learner climbs back out once the evidence is consistent.
|
||||
#[test]
|
||||
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
|
||||
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
|
||||
let mut c = LatchClock::new(120); // …but the mode claimed 120
|
||||
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
|
||||
|
||||
// Consistent 60 Hz evidence. The grid is fed the MIN of every
|
||||
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
|
||||
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
|
||||
// deliberate cost of not letting one slow patch redefine the panel.
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]);
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"a sustained slower grid is adopted instead of aimed past forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
|
||||
/// landing wherever our own cadence puts them read Variable — including the case that
|
||||
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
|
||||
/// to a larger whole multiple.
|
||||
#[test]
|
||||
fn cadence_probe_separates_grid_locked_from_variable() {
|
||||
const P: u64 = 8_333_333; // 120 Hz
|
||||
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
|
||||
// only once consecutive rounds agree (on glass a single round FLAPPED).
|
||||
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
// Fixed panel, stream at panel rate: every delta is exactly one period.
|
||||
let mut probe = CadenceProbe::new();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed);
|
||||
|
||||
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Fixed,
|
||||
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
|
||||
);
|
||||
|
||||
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
|
||||
// must not read as variable.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
|
||||
let stamps: Vec<u64> = (0..ROUNDS as usize)
|
||||
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
|
||||
|
||||
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
|
||||
// 8.33 ms, so every present sits off the grid.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Variable);
|
||||
|
||||
// A display change throws the evidence away rather than carrying a stale verdict.
|
||||
probe.reset();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// Below the sample floor nothing is claimed.
|
||||
let mut probe = CadenceProbe::new();
|
||||
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
|
||||
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
|
||||
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
|
||||
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
|
||||
let mut probe = CadenceProbe::new();
|
||||
for i in 0..ROUNDS {
|
||||
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
|
||||
}
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Variable,
|
||||
"one-sample batches must still yield spacings"
|
||||
);
|
||||
|
||||
// A period we never learned can't discriminate anything.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, 0, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
}
|
||||
|
||||
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
|
||||
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
|
||||
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
|
||||
/// call boundaries silently made the two disagree.
|
||||
#[test]
|
||||
fn cadence_verdict_is_independent_of_batching() {
|
||||
const P: u64 = 8_333_333;
|
||||
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
|
||||
let mut bulk = CadenceProbe::new();
|
||||
bulk.note(&stamps, P, true);
|
||||
|
||||
let mut drip = CadenceProbe::new();
|
||||
for s in &stamps {
|
||||
drip.note(&[*s], P, true);
|
||||
}
|
||||
|
||||
assert_eq!(bulk.verdict(), Cadence::Fixed);
|
||||
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
|
||||
}
|
||||
|
||||
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
|
||||
#[test]
|
||||
fn gate_budgets_one_undisplayed_present() {
|
||||
let mut g = PresentGate::default();
|
||||
let t0 = 1_000_000_000u64;
|
||||
assert!(g.open(0, t0));
|
||||
g.note_present(t0);
|
||||
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
|
||||
assert!(
|
||||
g.open(1, t0 + STALE_REOPEN_NS + 1),
|
||||
"stale in-flight present force-opens"
|
||||
);
|
||||
let (gated, forced) = g.take_counters();
|
||||
assert_eq!((gated, forced), (1, 1));
|
||||
assert_eq!(g.take_counters(), (0, 0), "counters drain");
|
||||
}
|
||||
}
|
||||
+572
-53
@@ -18,12 +18,15 @@
|
||||
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -63,6 +66,20 @@ pub struct SessionOpts {
|
||||
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
|
||||
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
|
||||
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
|
||||
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
|
||||
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
|
||||
/// the whole engine back to the legacy drain for field A/B without a rebuild.
|
||||
pub present_priority: PresentPriority,
|
||||
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
|
||||
/// present mode for the lowest possible latch — best-effort, and the mode that
|
||||
/// actually took is named in the stats line.
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
|
||||
/// default on) — prefers the present mode that drives VRR panels directly when the
|
||||
/// session starts fullscreen.
|
||||
pub allow_vrr: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -204,12 +221,56 @@ struct StreamState {
|
||||
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||
hdr: bool,
|
||||
/// The presented lane was the CPU/software one, where a PQ stream is shown RAW — the
|
||||
/// software path has no tone-map pass at all (the presenter uploads swscale RGBA
|
||||
/// as-is; the CSC mode-1 tonemap is hardware-lane only) — so the OSD badge reads
|
||||
/// `HDR→SDR (raw)` there instead of claiming a tone-map that never ran.
|
||||
hdr_untonemapped: bool,
|
||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
win_disp_us: Vec<u64>,
|
||||
/// The display stage's two halves (present-timing sessions only): decoded→submit and
|
||||
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
|
||||
win_pace_us: Vec<u64>,
|
||||
win_latch_us: Vec<u64>,
|
||||
win_start: Instant,
|
||||
presented: PresentedWindow,
|
||||
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
|
||||
/// store between the wake channel and the present call — a newest-wins slot under
|
||||
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
|
||||
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
|
||||
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
|
||||
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
|
||||
store: FrameStore<DecodedFrame>,
|
||||
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
|
||||
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
|
||||
clock: LatchClock,
|
||||
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
|
||||
/// or without present timing.
|
||||
gate: PresentGate,
|
||||
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
|
||||
/// portable query exists) — see [`CadenceProbe`].
|
||||
cadence: CadenceProbe,
|
||||
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
|
||||
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
|
||||
/// period (see the probe's call site).
|
||||
mode_period_ns: u64,
|
||||
/// The latch slot the last smoothness present served (one present per slot); 0 =
|
||||
/// none yet.
|
||||
last_target_ns: u64,
|
||||
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
|
||||
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
|
||||
margin_ns: u64,
|
||||
/// This window's latch misses (a present that reached glass > 1.5 latch periods
|
||||
/// after submit) — the adaptive margin's error signal.
|
||||
win_misses: u32,
|
||||
/// This window's peak undisplayed-presents-in-flight (present timing only).
|
||||
win_out_max: usize,
|
||||
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
|
||||
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: bool,
|
||||
// Hardware-path health: a failure streak (or a device with no import support at
|
||||
// all) demotes the decoder to software via the shared flag — once per session.
|
||||
dmabuf_demoted: bool,
|
||||
@@ -274,6 +335,8 @@ impl StreamState {
|
||||
params: SessionParams,
|
||||
force_software: Arc<AtomicBool>,
|
||||
wake: sdl3::event::EventSender,
|
||||
priority: PresentPriority,
|
||||
native_refresh_hz: u32,
|
||||
) -> StreamState {
|
||||
let profile = params.profile.clone();
|
||||
// The presenter's half of phase-locked capture: it writes the latch grid the
|
||||
@@ -308,10 +371,24 @@ impl StreamState {
|
||||
latch_grid,
|
||||
clock_offset: None,
|
||||
hdr: false,
|
||||
hdr_untonemapped: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_pace_us: Vec::with_capacity(256),
|
||||
win_latch_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
store: FrameStore::new(usize::from(priority.fifo_capacity())),
|
||||
clock: LatchClock::new(native_refresh_hz),
|
||||
gate: PresentGate::default(),
|
||||
cadence: CadenceProbe::new(),
|
||||
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
|
||||
last_target_ns: 0,
|
||||
margin_ns: 0,
|
||||
win_misses: 0,
|
||||
win_out_max: 0,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: false,
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
@@ -350,6 +427,25 @@ impl StreamState {
|
||||
}
|
||||
self.handle.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
|
||||
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
|
||||
/// tick (frames, input, and present completions all wake the loop early anyway).
|
||||
fn wake_timeout(&self) -> Duration {
|
||||
const TICK: Duration = Duration::from_millis(15);
|
||||
if !self.store.is_smoothing() || self.store.is_empty() {
|
||||
return TICK;
|
||||
}
|
||||
let now = session::now_ns();
|
||||
let mut target = self
|
||||
.clock
|
||||
.next_slot_after(now.saturating_add(self.margin_ns));
|
||||
if target == self.last_target_ns {
|
||||
// This slot is already served — the next boundary is the deadline.
|
||||
target += self.clock.period_ns();
|
||||
}
|
||||
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
|
||||
@@ -432,9 +528,43 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let instance_exts = window
|
||||
.vulkan_instance_extensions()
|
||||
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
||||
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
||||
let mut presenter = Presenter::new(
|
||||
&window,
|
||||
&instance_exts,
|
||||
crate::vk::PresentPref {
|
||||
vsync: opts.vsync,
|
||||
allow_vrr: opts.allow_vrr,
|
||||
fullscreen: opts.fullscreen,
|
||||
// `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are
|
||||
// both resolved inside `Presenter::new` — the swapchain owns those, so every
|
||||
// caller gets the same answer. `..Default` keeps this site from breaking each
|
||||
// time the struct learns another one.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.context("vulkan presenter")?;
|
||||
// A valid black frame immediately — the window is honest while the connect runs.
|
||||
presenter.present(&window, FrameInput::Redraw, None)?;
|
||||
|
||||
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
|
||||
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
|
||||
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
|
||||
let present_priority = if arrival_override {
|
||||
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
|
||||
PresentPriority::Latency
|
||||
} else {
|
||||
opts.present_priority
|
||||
};
|
||||
let pacing_active = !arrival_override;
|
||||
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
|
||||
// Present completions wake the loop exactly like decoded frames: a glass-gate
|
||||
// reopen or a smoothness slot must not wait out the event timeout.
|
||||
{
|
||||
let sender = events.event_sender();
|
||||
presenter.set_present_wake(Box::new(move || {
|
||||
let _ = sender.push_custom_event(FrameWake);
|
||||
}));
|
||||
}
|
||||
// Browse mode is "ready" the moment the library window presents — there may never be
|
||||
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
|
||||
// a shell only yields to a window that actually shows the stream.)
|
||||
@@ -511,6 +641,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
))
|
||||
}
|
||||
ModeCtl::Browse(_) => None,
|
||||
@@ -538,8 +670,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
|
||||
// when there is work — a short-timeout poll here burned a full core (measured;
|
||||
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let timeout = Duration::from_millis(15);
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
|
||||
// stream tightens the bound to its next latch-slot deadline.
|
||||
let timeout = stream
|
||||
.as_ref()
|
||||
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
|
||||
let first = event_pump.wait_event_timeout(timeout);
|
||||
let mut queued: Vec<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
@@ -602,6 +737,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dragged to another monitor (or the mode changed under us): the
|
||||
// latch grid and the VRR verdict both belong to the OLD panel. The
|
||||
// refresh rate used to be read once at startup and never revisited,
|
||||
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
|
||||
WindowEvent::DisplayChanged(..) => {
|
||||
let hz = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| m.refresh_rate.round().max(0.0) as u32)
|
||||
.unwrap_or(0);
|
||||
if let Some(st) = stream.as_mut() {
|
||||
if hz > 0 {
|
||||
st.clock = LatchClock::new(hz);
|
||||
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
|
||||
}
|
||||
st.cadence.reset();
|
||||
st.last_target_ns = 0;
|
||||
tracing::info!(
|
||||
refresh_hz = hz,
|
||||
"display changed — relearning the latch grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowEvent::Exposed => {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
@@ -1026,6 +1184,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
@@ -1103,6 +1263,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
if stats_verbosity != StatsVerbosity::Off {
|
||||
@@ -1115,6 +1276,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
println!("stats: {}", full.replace('\n', " | "));
|
||||
@@ -1271,11 +1433,148 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
// Present-wait completions drive the latch clock, the glass gate, and the
|
||||
// host-facing grid — drained every pass (a 1 Hz batch would starve all
|
||||
// three; the waiter's SDL wake pairs with this so completions never wait
|
||||
// out the event timeout).
|
||||
if presenter.present_timing_active() {
|
||||
let samples = presenter.take_presented_samples();
|
||||
if !samples.is_empty() {
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let period = st.clock.period_ns();
|
||||
let mut stamps = Vec::with_capacity(samples.len());
|
||||
for s in &samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
|
||||
- s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
// The display split (WP4): our pipeline vs the vsync latch. Only
|
||||
// meaningful with true glass stamps, which is exactly when this
|
||||
// branch runs.
|
||||
st.win_pace_us
|
||||
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
st.win_latch_us
|
||||
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
|
||||
// Latch miss (the adaptive margin's error signal): glass later
|
||||
// than one panel period past submit, PLUS the lead we already
|
||||
// applied — i.e. the slot we aimed at was missed. Measuring the
|
||||
// real latch rather than the store's own evictions is the
|
||||
// Android 0.23.0 correction: policy drops happen whenever the
|
||||
// stream out-runs the panel and say nothing about the latch, and
|
||||
// widening on them walked the margin to its ceiling on healthy
|
||||
// devices, re-imposing the very display latency it had removed.
|
||||
if st.store.is_smoothing()
|
||||
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
|
||||
{
|
||||
st.win_misses += 1;
|
||||
}
|
||||
stamps.push(s.displayed_ns);
|
||||
}
|
||||
st.clock.note_batch(&stamps);
|
||||
// Same stamps answer "is VRR live" — the panel either quantizes them
|
||||
// to its grid or follows our cadence. Evidence only counts from a
|
||||
// window whose presents were flowing normally: a distressed pipeline
|
||||
// (stale force-opens) smears spacings for reasons that have nothing
|
||||
// to do with the panel, and on glass that flapped the verdict.
|
||||
//
|
||||
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
|
||||
// The learned grid comes from our own present spacings, and a stream
|
||||
// running below panel rate only ever produces multiples ≥ its frame
|
||||
// interval — so the learner adopts our cadence as "the grid" and every
|
||||
// delta then looks on-grid by construction. Measured on .21
|
||||
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
|
||||
// and the probe reported VRR on a display with VRR provably disabled.
|
||||
// The vblank grid is the mode's refresh; that is what presents
|
||||
// quantize to when VRR is off.
|
||||
//
|
||||
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
|
||||
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
|
||||
// IMMEDIATE deliberately break that, so their stamps are never
|
||||
// grid-quantized and the probe would call every mailbox session VRR.
|
||||
// Measured on .21: same panel, same second — fifo read `no`
|
||||
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
|
||||
// FIFO the honest answer is "cannot tell", i.e. Unknown.
|
||||
let healthy = st.presented.forced == 0;
|
||||
if presenter.vblank_locked() {
|
||||
st.cadence.note(&stamps, st.mode_period_ns, healthy);
|
||||
}
|
||||
// Phase-locked capture, the presenter's half: publish the grid the
|
||||
// local clock just learned — a recent TRUE on-glass instant plus
|
||||
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
|
||||
// feeds both, so the report and the scheduler cannot disagree.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
grid.period_ns
|
||||
.store(st.clock.period_ns(), Ordering::Relaxed);
|
||||
grid.anchor_ns
|
||||
.store(st.clock.anchor_ns(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
|
||||
// Intake into the intent store: a newest-wins slot under latency (the
|
||||
// shipped drain, now with displacement counters), the smoothing FIFO under
|
||||
// smoothness. PyroWave collapses smoothness to latency for the stream: its
|
||||
// plane-ring retirement accounting assumes the newest-wins hand-off
|
||||
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
|
||||
// buffering moot anyway.
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
|
||||
st.store.force_latency();
|
||||
if !st.pyro_latency_forced {
|
||||
st.pyro_latency_forced = true;
|
||||
tracing::info!(
|
||||
"PyroWave stream — smoothness buffering does not apply \
|
||||
(latency pacing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
st.store.submit(f);
|
||||
}
|
||||
|
||||
// One frame out, by intent: latency takes the newest whenever the glass
|
||||
// gate allows; smoothness serves at most one frame per latch slot (the
|
||||
// preroll/underflow behavior lives in the store).
|
||||
let now_ns = session::now_ns();
|
||||
let mut slot_target = 0u64;
|
||||
let mut to_present = if st.store.is_smoothing() {
|
||||
let target = st
|
||||
.clock
|
||||
.next_slot_after(now_ns.saturating_add(st.margin_ns));
|
||||
if target != st.last_target_ns {
|
||||
slot_target = target;
|
||||
st.store.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
st.store.take()
|
||||
};
|
||||
// The FIFO glass budget: one undisplayed present in flight, so the
|
||||
// swapchain's own FIFO can never become a standing queue (a measured
|
||||
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
|
||||
// only present timing can count, so everywhere else this stays inert and
|
||||
// behavior is the shipped arrival pacing.
|
||||
if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() {
|
||||
if let Some(f) = to_present.take() {
|
||||
if st.gate.open(presenter.presents_outstanding(), now_ns) {
|
||||
to_present = Some(f);
|
||||
} else {
|
||||
// Parked: a newest-wins store replaces it if a fresher frame
|
||||
// lands; the waiter's wake (or the 100 ms stale force-open)
|
||||
// retries.
|
||||
st.store.put_back(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = to_present {
|
||||
// Resize END: a frame at the steered target size means the sharp new-mode
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
@@ -1296,6 +1595,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
|
||||
st.hdr = f.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::PyroWave(f),
|
||||
@@ -1323,6 +1623,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
DecodedImage::Cpu(c) => {
|
||||
st.hdr = c.color.is_pq();
|
||||
// The software lane shows PQ raw (no tone-map pass exists there)
|
||||
// — the OSD badge must not claim `HDR→SDR` for it.
|
||||
st.hdr_untonemapped = true;
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1330,6 +1633,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
|
||||
{
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::Dmabuf(d),
|
||||
@@ -1380,6 +1684,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) if presenter.supports_d3d11() && !st.dmabuf_demoted => {
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::D3d11(d),
|
||||
@@ -1426,6 +1731,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// demotion contract as the dmabuf path.
|
||||
DecodedImage::VkFrame(v) if !st.dmabuf_demoted => {
|
||||
st.hdr = v.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::VkFrame(v),
|
||||
@@ -1457,6 +1763,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
};
|
||||
if did_present {
|
||||
presented_video = true;
|
||||
// Smoothness: this latch slot is served — one present per slot.
|
||||
// (Set only on success: a gated or failed present leaves the slot
|
||||
// open for the retry.)
|
||||
if slot_target != 0 {
|
||||
st.last_target_ns = slot_target;
|
||||
}
|
||||
if opts.json_status && !st.ready_announced {
|
||||
st.ready_announced = true;
|
||||
println!("{{\"ready\":true}}");
|
||||
@@ -1466,6 +1778,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// e2e/display samples arrive via `take_presented_samples` with a
|
||||
// TRUE on-glass stamp instead of the submit-time one below.
|
||||
presenter.note_presented(pts_ns, decoded_ns);
|
||||
st.gate.note_present(now_ns);
|
||||
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
|
||||
} else {
|
||||
let displayed_ns = session::now_ns();
|
||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||
@@ -1480,59 +1794,81 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
|
||||
// No glass stamps on this stack: the submit instant anchors an
|
||||
// approximate grid on the mode's refresh period, so smoothness
|
||||
// still drains one frame per (approximate) slot.
|
||||
st.clock.note_batch(&[displayed_ns]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fold the presenter window into the shared stats line once per second.
|
||||
// (The on-glass samples themselves are drained every pass above — they
|
||||
// drive the latch clock and glass gate, not just this fold.)
|
||||
if st.win_start.elapsed() >= Duration::from_secs(1) {
|
||||
// On-glass samples the present-wait waiter completed this window (empty
|
||||
// when timing is inactive — the legacy submit-time pushes fill in then).
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let samples = presenter.take_presented_samples();
|
||||
// Phase-locked capture, the presenter's half: publish this window's latch
|
||||
// grid — a recent TRUE on-glass instant plus the panel period — for the
|
||||
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
|
||||
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
|
||||
// by the display mode's refresh — under arrival-paced MAILBOX a stream
|
||||
// running below the panel rate spaces its presents at k×period, and the
|
||||
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
if let Some(last) = samples.last() {
|
||||
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
|
||||
let min_delta = samples
|
||||
.windows(2)
|
||||
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
|
||||
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
|
||||
.min()
|
||||
.unwrap_or(refresh_period);
|
||||
grid.period_ns
|
||||
.store(min_delta.min(refresh_period), Ordering::Relaxed);
|
||||
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
for s in samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
}
|
||||
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
|
||||
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
|
||||
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
|
||||
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
|
||||
// Drained ONCE per window and shared by the HUD and the log line below —
|
||||
// a second `take_counters` would read zeros.
|
||||
let (replaced, q_drop, q_dry) = st.store.take_counters();
|
||||
let (gated, forced) = st.gate.take_counters();
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
display_ms: disp_p50 as f32 / 1000.0,
|
||||
pace_ms: pace_p50 as f32 / 1000.0,
|
||||
latch_ms: latch_p50 as f32 / 1000.0,
|
||||
mode: presenter.present_mode_name(),
|
||||
vrr: st.cadence.verdict(),
|
||||
smoothing: st.store.is_smoothing(),
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
st.win_pace_us.clear();
|
||||
st.win_latch_us.clear();
|
||||
st.win_start = Instant::now();
|
||||
// Adaptive slot margin (the Android presenter's measured recipe):
|
||||
// start at 0 — a fixed lead is pure display tax — and widen one step
|
||||
// per window whose measured latch misses demand it. One-way per
|
||||
// stream; the next stream restarts at 0.
|
||||
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
|
||||
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
|
||||
tracing::info!(
|
||||
margin_us = st.margin_ns / 1000,
|
||||
misses = st.win_misses,
|
||||
"smoothness slot margin widened (measured latch misses)"
|
||||
);
|
||||
}
|
||||
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
|
||||
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
|
||||
// the field-triage instrument for the intent engine.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
vrr = st.presented.vrr.label(),
|
||||
replaced,
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
misses = st.win_misses,
|
||||
out_max = st.win_out_max,
|
||||
pace_ms = st.presented.pace_ms,
|
||||
latch_ms = st.presented.latch_ms,
|
||||
period_us = st.clock.period_ns() / 1000,
|
||||
margin_us = st.margin_ns / 1000,
|
||||
"presenter window"
|
||||
);
|
||||
}
|
||||
st.win_misses = 0;
|
||||
st.win_out_max = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1910,6 +2246,7 @@ fn bump_stats_tier(
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
),
|
||||
None => String::new(),
|
||||
@@ -1991,6 +2328,32 @@ struct PresentedWindow {
|
||||
e2e_p50_ms: f32,
|
||||
e2e_p95_ms: f32,
|
||||
display_ms: f32,
|
||||
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
|
||||
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
|
||||
/// (the presentation engine's queue + the vblank wait). Both `0` without
|
||||
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
|
||||
/// unsplit figure rather than inventing a zero latch.
|
||||
///
|
||||
/// This split is what makes a high `display` self-diagnosing: latch dominating means
|
||||
/// the vsync/queue floor (or a standing queue), pace dominating means us.
|
||||
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
|
||||
/// number — Apple subtracts its measured OS present floor, and the latch IS our
|
||||
/// floor, so `pace` is what remains on both sides of that comparison.
|
||||
pace_ms: f32,
|
||||
latch_ms: f32,
|
||||
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
|
||||
/// chosen from what the surface offers, so "why is my latch a refresh long" is
|
||||
/// usually answered by a MAILBOX request having landed on FIFO.
|
||||
mode: &'static str,
|
||||
/// Whether variable refresh is measurably live (never claimed without evidence).
|
||||
vrr: Cadence,
|
||||
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
|
||||
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
|
||||
smoothing: bool,
|
||||
q_drop: u32,
|
||||
q_dry: u32,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
@@ -2007,11 +2370,15 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
||||
///
|
||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR`; and a PQ stream on the
|
||||
/// software-decode lane (`hdr_untonemapped`) shows `HDR→SDR (raw)` — that lane has no
|
||||
/// tone-map pass at all, so the washed-out picture is named for what it is rather than
|
||||
/// passed off as a tone-map.
|
||||
///
|
||||
/// `profile` (the session's settings profile, `None` for the global defaults) closes the
|
||||
/// first line at every tier — the cheapest possible answer to "which profile am I on?"
|
||||
/// (design/client-settings-profiles.md §5.2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stats_text(
|
||||
verbosity: StatsVerbosity,
|
||||
mode_line: &str,
|
||||
@@ -2019,6 +2386,7 @@ fn stats_text(
|
||||
p: &PresentedWindow,
|
||||
hdr_stream: bool,
|
||||
hdr_display: bool,
|
||||
hdr_untonemapped: bool,
|
||||
profile: Option<&str>,
|
||||
) -> String {
|
||||
let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default();
|
||||
@@ -2068,6 +2436,7 @@ fn stats_text(
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
match (hdr_stream, hdr_display) {
|
||||
(true, true) => " · HDR",
|
||||
(true, false) if hdr_untonemapped => " · HDR→SDR (raw)",
|
||||
(true, false) => " · HDR→SDR",
|
||||
_ => "",
|
||||
},
|
||||
@@ -2090,6 +2459,15 @@ fn stats_text(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
// The display split (WP4). Only with true on-glass stamps — without them the
|
||||
// two halves are not separable and the unsplit figure stands alone rather than
|
||||
// implying a zero latch.
|
||||
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
|
||||
text.push_str(&format!(
|
||||
" (pace {:.1} + latch {:.1})",
|
||||
p.pace_ms, p.latch_ms
|
||||
));
|
||||
}
|
||||
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
|
||||
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
|
||||
if s.staged {
|
||||
@@ -2098,6 +2476,32 @@ fn stats_text(
|
||||
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
|
||||
));
|
||||
}
|
||||
// The presenter line: the swapchain mode that is actually live, the chosen
|
||||
// intent, and the engine's own counters. Present-mode alone answers most
|
||||
// "why is my latch a whole refresh" questions; the counters only render when
|
||||
// they are non-zero, so a healthy latency session shows just the mode.
|
||||
if !p.mode.is_empty() {
|
||||
text.push_str(&format!("\npresent: {}", p.mode));
|
||||
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
|
||||
if p.vrr != Cadence::Unknown {
|
||||
text.push_str(&format!(" · vrr {}", p.vrr.label()));
|
||||
}
|
||||
if p.smoothing {
|
||||
text.push_str(" · smoothing");
|
||||
}
|
||||
if p.q_drop > 0 {
|
||||
text.push_str(&format!(" · qdrop {}", p.q_drop));
|
||||
}
|
||||
if p.q_dry > 0 {
|
||||
text.push_str(&format!(" · qdry {}", p.q_dry));
|
||||
}
|
||||
if p.gated > 0 {
|
||||
text.push_str(&format!(" · gated {}", p.gated));
|
||||
}
|
||||
if p.forced > 0 {
|
||||
text.push_str(&format!(" · forced {}", p.forced));
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lost > 0 {
|
||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||
@@ -2371,6 +2775,7 @@ mod tests {
|
||||
e2e_p50_ms: 6.4,
|
||||
e2e_p95_ms: 9.1,
|
||||
display_ms: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2380,7 +2785,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_tiers() {
|
||||
let (s, p) = sample();
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None);
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, false, None);
|
||||
|
||||
assert_eq!(text(StatsVerbosity::Off), "");
|
||||
|
||||
@@ -2397,6 +2802,10 @@ mod tests {
|
||||
|
||||
let detailed = text(StatsVerbosity::Detailed);
|
||||
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||
assert!(
|
||||
!detailed.contains("(raw)"),
|
||||
"the hardware lane tone-maps — no raw tag"
|
||||
);
|
||||
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||
assert!(detailed.contains("host: queue 0.3 · encode 0.5 · xfer 0.1 · pace 0.3 ms"));
|
||||
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||
@@ -2404,6 +2813,96 @@ mod tests {
|
||||
!normal.contains("queue"),
|
||||
"host-stage split is Detailed-only"
|
||||
);
|
||||
assert!(
|
||||
!detailed.contains("pace 1.1"),
|
||||
"no glass stamps in this sample — the display stage stays unsplit"
|
||||
);
|
||||
}
|
||||
|
||||
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
|
||||
/// live present mode is named, and the engine counters render only when non-zero —
|
||||
/// so a healthy latency session shows the mode and nothing else. Without glass
|
||||
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
|
||||
#[test]
|
||||
fn detailed_splits_display_into_pace_and_latch() {
|
||||
let (s, mut p) = sample();
|
||||
p.display_ms = 12.4;
|
||||
p.pace_ms = 1.1;
|
||||
p.latch_ms = 11.3;
|
||||
p.mode = "fifo";
|
||||
let split = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
|
||||
assert!(split.contains("\npresent: fifo"));
|
||||
assert!(
|
||||
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
|
||||
"quiet counters stay off the HUD: {split}"
|
||||
);
|
||||
|
||||
// The smoothing FIFO and the glass gate surface once they actually do something.
|
||||
p.smoothing = true;
|
||||
p.q_drop = 2;
|
||||
p.q_dry = 1;
|
||||
p.gated = 7;
|
||||
p.forced = 1;
|
||||
let busy = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
|
||||
|
||||
// A tier below Detailed never carries any of it.
|
||||
let normal = stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(!normal.contains("present:") && !normal.contains("pace"));
|
||||
}
|
||||
|
||||
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
|
||||
/// tone-mapping (that lane has no PQ→sRGB pass), so its badge must not read as the
|
||||
/// hardware lane's `HDR→SDR` tone-map — and an HDR10 swapchain shows plain `HDR`
|
||||
/// whatever the lane claims (a CPU frame forces the swapchain to SDR anyway).
|
||||
#[test]
|
||||
fn hdr_badge_names_the_untonemapped_cpu_lane() {
|
||||
let (s, p) = sample();
|
||||
let badge = |hdr_display, raw| {
|
||||
stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
true,
|
||||
hdr_display,
|
||||
raw,
|
||||
None,
|
||||
)
|
||||
};
|
||||
assert!(badge(false, true).contains(" · HDR→SDR (raw)"));
|
||||
assert!(!badge(false, false).contains("(raw)"));
|
||||
assert!(badge(false, false).contains(" · HDR→SDR"));
|
||||
assert!(badge(true, false).contains(" · HDR"));
|
||||
assert!(!badge(true, false).contains("HDR→SDR"));
|
||||
}
|
||||
|
||||
/// Detailed shows the negotiated encoder target next to the measured rate — the
|
||||
@@ -2413,7 +2912,7 @@ mod tests {
|
||||
fn detailed_shows_target_and_chroma_resolution() {
|
||||
let (mut s, p) = sample();
|
||||
let line1 = |s: &Stats, v| {
|
||||
stats_text(v, "m", s, &p, false, false, None)
|
||||
stats_text(v, "m", s, &p, false, false, false, None)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
@@ -2446,7 +2945,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_mic_line() {
|
||||
let (mut s, p) = sample();
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None);
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, false, None);
|
||||
assert!(
|
||||
!text(&s, StatsVerbosity::Detailed).contains("mic"),
|
||||
"no mic line while the mic is off"
|
||||
@@ -2473,7 +2972,16 @@ mod tests {
|
||||
s.lost = 0;
|
||||
let p = PresentedWindow::default();
|
||||
assert_eq!(
|
||||
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None),
|
||||
stats_text(
|
||||
StatsVerbosity::Compact,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
),
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
@@ -2491,6 +2999,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Game")
|
||||
),
|
||||
"120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game"
|
||||
@@ -2502,6 +3011,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2515,13 +3025,22 @@ mod tests {
|
||||
&p,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert!(detailed.lines().next().unwrap().ends_with("· HDR · Work"));
|
||||
// No profile → the line is exactly what it always was.
|
||||
assert!(
|
||||
!stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ")
|
||||
);
|
||||
assert!(!stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
)
|
||||
.contains(" · "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,7 +33,7 @@ mod reconfig;
|
||||
mod resources;
|
||||
mod setup;
|
||||
|
||||
pub use setup::list_adapters;
|
||||
pub use setup::{list_adapters, PresentPref};
|
||||
|
||||
/// One presenter iteration's video input.
|
||||
pub enum FrameInput<'a> {
|
||||
@@ -247,10 +247,75 @@ impl Presenter {
|
||||
/// (the presenter itself never sees them). No-op when timing is inactive.
|
||||
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
|
||||
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
|
||||
t.enqueue(sc, id, pts_ns, decoded_ns);
|
||||
// The submit stamp: `present()` already returned, so "now" is within the
|
||||
// present-call tail — the pace/latch split point.
|
||||
t.enqueue(
|
||||
sc,
|
||||
id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
pf_client_core::session::now_ns(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
|
||||
/// FIFO glass gate's budget count.
|
||||
pub(crate) fn presents_outstanding(&self) -> usize {
|
||||
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
|
||||
}
|
||||
|
||||
/// Install the run loop's wake for present completions (an SDL event push). No-op
|
||||
/// without present timing — there is nothing to wake on then.
|
||||
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
if let Some(t) = &self.present_timer {
|
||||
t.set_wake(cb);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live swapchain present mode, for the stats overlay: a mode is picked from
|
||||
/// what the surface actually offers, so the requested one and this can differ (a
|
||||
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
|
||||
/// driver, notably). Showing it is what makes that visible instead of puzzling.
|
||||
pub(crate) fn present_mode_name(&self) -> &'static str {
|
||||
match self.present_mode {
|
||||
vk::PresentModeKHR::MAILBOX => "mailbox",
|
||||
vk::PresentModeKHR::FIFO => "fifo",
|
||||
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
|
||||
vk::PresentModeKHR::IMMEDIATE => "immediate",
|
||||
setup::fifo_latest_ready::MODE => "fifo-latest-ready",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The active present mode QUEUES presents — the only modes where the swapchain
|
||||
/// itself can become a standing queue, and so the only ones the glass gate governs.
|
||||
///
|
||||
/// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does
|
||||
/// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it
|
||||
/// would hold frames back to emulate something the presentation engine is already
|
||||
/// doing, paying the serialisation twice.
|
||||
pub(crate) fn needs_glass_gate(&self) -> bool {
|
||||
matches!(
|
||||
self.present_mode,
|
||||
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
|
||||
)
|
||||
}
|
||||
|
||||
/// The active present mode shows images ON THE VBLANK GRID — the premise the VRR
|
||||
/// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole
|
||||
/// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but
|
||||
/// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them
|
||||
/// the probe reports Unknown rather than calling every session VRR.
|
||||
pub(crate) fn vblank_locked(&self) -> bool {
|
||||
matches!(
|
||||
self.present_mode,
|
||||
vk::PresentModeKHR::FIFO
|
||||
| vk::PresentModeKHR::FIFO_RELAXED
|
||||
| setup::fifo_latest_ready::MODE
|
||||
)
|
||||
}
|
||||
|
||||
/// Take the window's completed on-glass samples (empty when timing is inactive).
|
||||
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
|
||||
self.present_timer
|
||||
|
||||
@@ -40,7 +40,27 @@ impl Presenter {
|
||||
// PQ→sRGB pass.
|
||||
let frame_pq = match &input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(_) => Some(false),
|
||||
FrameInput::Cpu(f) => {
|
||||
// The swapchain answer stays `false` (above) — but a PQ stream on this
|
||||
// lane is then shown RAW: no PQ→sRGB pass exists here (the CSC mode-1
|
||||
// tonemap is hardware-lane only; CPU frames are a straight RGBA upload),
|
||||
// so the picture is washed out and the pq-downgrade warn below never
|
||||
// fires. Say so once, or the only trace is an OSD badge. (A process-once
|
||||
// latch, same idiom as the decoders' first-frame layout dumps — the
|
||||
// condition is a property of the lane, not of one Presenter.)
|
||||
if f.color.is_pq() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"HDR10 (PQ) stream on the software-decode lane — it has no \
|
||||
PQ→sRGB pass, so the picture is shown untonemapped (washed \
|
||||
out). Hardware decode restores correct colour."
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
|
||||
@@ -26,6 +26,9 @@ pub(crate) struct PresentedSample {
|
||||
pub pts_ns: u64,
|
||||
/// Decode-complete stamp (client clock) — the display-stage anchor.
|
||||
pub decoded_ns: u64,
|
||||
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
|
||||
/// `submitted − decoded` is our pipeline, `displayed − submitted` the vsync latch.
|
||||
pub submitted_ns: u64,
|
||||
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
|
||||
pub displayed_ns: u64,
|
||||
}
|
||||
@@ -35,15 +38,24 @@ struct Job {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
}
|
||||
|
||||
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
|
||||
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
|
||||
|
||||
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
|
||||
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
|
||||
pub(crate) struct PresentTimer {
|
||||
tx: Option<mpsc::Sender<Job>>,
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
|
||||
/// and the glass gate's "undisplayed presents in flight" count.
|
||||
pending: Arc<AtomicUsize>,
|
||||
results: Arc<Mutex<Vec<PresentedSample>>>,
|
||||
/// Called by the waiter after each completed wait (sample or not) — the run loop
|
||||
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
|
||||
/// event-loop timeout.
|
||||
wake: WakeSlot,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -52,7 +64,8 @@ impl PresentTimer {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let pending = Arc::new(AtomicUsize::new(0));
|
||||
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
|
||||
let (pending_t, results_t) = (pending.clone(), results.clone());
|
||||
let wake: WakeSlot = Arc::new(Mutex::new(None));
|
||||
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-present-wait".into())
|
||||
.spawn(move || {
|
||||
@@ -69,12 +82,20 @@ impl PresentTimer {
|
||||
results_t.lock().unwrap().push(PresentedSample {
|
||||
pts_ns: job.pts_ns,
|
||||
decoded_ns: job.decoded_ns,
|
||||
submitted_ns: job.submitted_ns,
|
||||
displayed_ns,
|
||||
});
|
||||
}
|
||||
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
|
||||
// (or the loop is about to find out) — never poison the window.
|
||||
pending_t.fetch_sub(1, Ordering::AcqRel);
|
||||
// Wake the run loop AFTER the count dropped: what it observes on
|
||||
// wake is the post-completion state (the gate may now be open).
|
||||
// Called under the slot lock — the callback is a bare SDL event
|
||||
// push and never reenters this type.
|
||||
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn pf-present-wait");
|
||||
@@ -82,10 +103,23 @@ impl PresentTimer {
|
||||
tx: Some(tx),
|
||||
pending,
|
||||
results,
|
||||
wake,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
|
||||
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
*self.wake.lock().unwrap() = Some(cb);
|
||||
}
|
||||
|
||||
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
|
||||
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
|
||||
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
|
||||
pub(crate) fn outstanding(&self) -> usize {
|
||||
self.pending.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Hand a successfully submitted present to the waiter.
|
||||
pub(crate) fn enqueue(
|
||||
&self,
|
||||
@@ -93,6 +127,7 @@ impl PresentTimer {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
) {
|
||||
if let Some(tx) = &self.tx {
|
||||
self.pending.fetch_add(1, Ordering::AcqRel);
|
||||
@@ -102,6 +137,7 @@ impl PresentTimer {
|
||||
present_id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
submitted_ns,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user