Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2c6e1c9c0 | ||
|
|
1d755ebeeb | ||
|
|
c407f6a6d9 | ||
|
|
1e2b956de6 | ||
|
|
49b5ffa2d8 | ||
|
|
d65b9f3b1b | ||
|
|
cd0b53f8fe | ||
|
|
00a9d16201 | ||
|
|
540e282e60 | ||
|
|
7246f0fe60 | ||
|
|
e0a822016f | ||
|
|
b6938a9890 | ||
|
|
faf94087c5 | ||
|
|
46d9e0d20f | ||
|
|
8e8451ca0c | ||
|
|
0e1bab019c | ||
|
|
7951d12b06 | ||
|
|
4690a166ca | ||
|
|
8c628b4e6c | ||
|
|
aef7f7877f | ||
|
|
f60b6e30e2 | ||
|
|
4d155f4985 | ||
|
|
4b5f0dac6b | ||
|
|
5e30805490 | ||
|
|
7312f0ddba | ||
|
|
e7ebaf591c | ||
|
|
010949fead | ||
|
|
f5931650e0 | ||
|
|
519d004cab | ||
|
|
46201fd9c3 | ||
|
|
f320f4b465 | ||
|
|
89eb031cd6 | ||
|
|
2991001fe4 | ||
|
|
19df33e0f7 | ||
|
|
c920204184 | ||
|
|
6f4613e146 | ||
|
|
96f75f4e52 | ||
|
|
3b08da11ff | ||
|
|
3ee88bb8cf | ||
|
|
4c5b97cfe4 | ||
|
|
773eea24d9 | ||
|
|
3b5c95959b | ||
|
|
a2bc9a2bdc | ||
|
|
4beee17953 | ||
|
|
4ad0055416 | ||
|
|
db9cd40079 | ||
|
|
cb07a8f983 | ||
|
|
c63e8cee39 | ||
|
|
b670b5d844 | ||
|
|
11abff5343 | ||
|
|
064ea3de7d | ||
|
|
ec278c0478 | ||
|
|
5d91176500 | ||
|
|
cf7baf3ba8 | ||
|
|
551d0c3294 | ||
|
|
7f77fa68af | ||
|
|
ece8b16a78 | ||
|
|
2b91339cb8 | ||
|
|
ffa4577793 | ||
|
|
4a32c8fb36 | ||
|
|
9bb8d84f12 | ||
|
|
f42aca690f | ||
|
|
34a02fdac5 | ||
|
|
8670b412c7 | ||
|
|
539ac2f2a5 | ||
|
|
f5a75d9edc | ||
|
|
430499bdab | ||
|
|
92578803c2 | ||
|
|
ca2ff7093a | ||
|
|
a2dc011200 | ||
|
|
48eeae7527 | ||
|
|
0df4ca957f | ||
|
|
13aa11355e | ||
|
|
e989d7457f | ||
|
|
b05bb1dd48 | ||
|
|
2898f6b049 | ||
|
|
4eb4e3465b | ||
|
|
44cd5bfd81 | ||
|
|
8977228a4b | ||
|
|
6a82a602a1 | ||
|
|
fc3b2d0328 | ||
|
|
1c1fd7d9bc | ||
|
|
1c60e641b3 | ||
|
|
eda4b7ebd2 | ||
|
|
730ac43169 | ||
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
675030935a | ||
|
|
0e5a059098 | ||
|
|
245173a731 |
@@ -171,6 +171,13 @@ jobs:
|
||||
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
|
||||
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
|
||||
# a build script with no retry, and a truncated transfer here does not surface as a network
|
||||
# error — it silently becomes a from-source Skia build that dies in the container. See the
|
||||
# script for the measured failure.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
|
||||
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
|
||||
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
|
||||
@@ -209,9 +216,25 @@ jobs:
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
|
||||
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
|
||||
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
|
||||
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
|
||||
# failure. This is the first cargo step in the job, so it catches the drop earliest.
|
||||
#
|
||||
# No pipefail: the runner is dash. Capture, then decide.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
run: |
|
||||
set -e
|
||||
rc=0
|
||||
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
|
||||
cat /tmp/android-clippy.log
|
||||
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
|
||||
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
|
||||
+17
-3
@@ -107,6 +107,12 @@ jobs:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -270,6 +276,12 @@ jobs:
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -358,9 +370,11 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
|
||||
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
|
||||
# mode is a well-formed header that only a browser rejects, and the one picking which of the
|
||||
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
|
||||
# Neither would be caught anywhere else.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
|
||||
@@ -50,7 +50,10 @@ on:
|
||||
- 'crates/pf-vaadec/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
# Both halves of this job's correctness, not of the bundle's content: a change to either
|
||||
# can only be proven by a real run, and there is no other trigger that would give it one.
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
- 'scripts/ci/flatpak-deps-present.sh'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -270,12 +273,13 @@ jobs:
|
||||
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions. (No codec extension: the client links no FFmpeg — see the
|
||||
# manifest header.)
|
||||
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
|
||||
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
|
||||
# genuinely MISSING; see the block below. (No codec extension: the client links no
|
||||
# FFmpeg — see the manifest header.)
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
@@ -288,9 +292,40 @@ jobs:
|
||||
# for the mechanism.
|
||||
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
|
||||
# load-sensitive runner, same flathub.org resolution path.
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
#
|
||||
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
|
||||
# that flag does not install what is missing, it UPDATES what is present.
|
||||
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
|
||||
# and runs `flatpak update` for every dep already installed — with no fallback to a
|
||||
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
|
||||
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
|
||||
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
|
||||
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
|
||||
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
|
||||
# .filez: Server returned HTTP 404
|
||||
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
|
||||
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
|
||||
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
|
||||
# saw a crash rather than a clean "this will never work" either. The build never wanted
|
||||
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
|
||||
# one satisfies it. Updating bought nothing and imported an upstream outage.
|
||||
#
|
||||
# So: assert what the image already has, and reach for Flathub only on a real miss —
|
||||
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
|
||||
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
|
||||
# touching it): a bug in it that reports "satisfied" when it is not would build against
|
||||
# whatever runtime happened to be lying around, which is worth more than an inline
|
||||
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
|
||||
# install path below.
|
||||
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
|
||||
echo "deps satisfied by the baked image — not touching Flathub"
|
||||
flatpak list --user --columns=ref
|
||||
else
|
||||
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
fi
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only --disable-updates \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
@@ -298,7 +333,17 @@ jobs:
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# step needs no network.
|
||||
#
|
||||
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
|
||||
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
|
||||
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
|
||||
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
|
||||
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
|
||||
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
|
||||
# design is to be offline, and it could only ever fire if the prefetch step above had
|
||||
# already failed the job. Dropped: the prefetch step is the one place that talks to
|
||||
# Flathub, and it is the one place with retries.
|
||||
#
|
||||
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
|
||||
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
|
||||
@@ -326,7 +371,6 @@ jobs:
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--disable-updates \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
|
||||
+1472
File diff suppressed because it is too large
Load Diff
Generated
+38
-38
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -1959,9 +1959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.0"
|
||||
version = "0.31.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+222
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.0"
|
||||
"version": "0.31.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
@@ -364,6 +364,77 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Rename a paired client",
|
||||
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
|
||||
"operationId": "renameClient",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "fingerprint",
|
||||
"in": "path",
|
||||
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RenameClient"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The client as it now reads",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairedClient"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No paired client with that fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/compositors": {
|
||||
@@ -1860,6 +1931,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"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/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6625,7 +6759,7 @@
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
|
||||
"required": [
|
||||
"hostname",
|
||||
"uniqueid",
|
||||
@@ -6671,7 +6805,7 @@
|
||||
},
|
||||
"local_ip": {
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
@@ -7312,6 +7446,14 @@
|
||||
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
|
||||
"example": "Living Room TV"
|
||||
},
|
||||
"not_after_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
@@ -7333,7 +7475,7 @@
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
|
||||
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7792,6 +7934,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +8028,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenameClient": {
|
||||
"type": "object",
|
||||
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
|
||||
"example": "Living Room TV"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -928,6 +932,10 @@ private val TEST_BUTTONS = listOf(
|
||||
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
|
||||
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
|
||||
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
|
||||
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
|
||||
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
|
||||
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
|
||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
/**
|
||||
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
|
||||
*
|
||||
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
|
||||
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
|
||||
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
|
||||
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
|
||||
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
|
||||
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
|
||||
* the host is already serving 120. Every frame then waits out that mismatch, which is the
|
||||
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
|
||||
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
|
||||
*/
|
||||
private fun resolveHighRefreshMode() {
|
||||
if (isTvDevice(this)) return
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||
@@ -615,7 +628,7 @@ class MainActivity : ComponentActivity() {
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
if (fromPad(event)) {
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
@@ -697,7 +710,7 @@ class MainActivity : ComponentActivity() {
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
lastPadIsGamepad = fromPad(event)
|
||||
if (lastPadIsGamepad) {
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
@@ -705,7 +718,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
if (fromPad(event)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
@@ -728,6 +741,32 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this key event come from a controller — the question every pad branch here actually
|
||||
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
|
||||
*
|
||||
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
|
||||
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
|
||||
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
|
||||
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
|
||||
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
|
||||
*
|
||||
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
|
||||
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
|
||||
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
|
||||
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
|
||||
* keep leaving the stream, which for a device with no pad on it is the documented way out
|
||||
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
|
||||
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
|
||||
* branch and breaks them.
|
||||
*
|
||||
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
|
||||
* and both the raw and the corrected keycode are in this block for every button concerned.
|
||||
*/
|
||||
private fun fromPad(event: KeyEvent): Boolean =
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
|
||||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
|
||||
@@ -114,6 +114,22 @@ data class Settings(
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
|
||||
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
|
||||
* something to impose on a device that does not need it.
|
||||
*
|
||||
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
|
||||
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
|
||||
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
|
||||
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
|
||||
* a cheap 1080p stick, which never had the extra pixels to begin with.
|
||||
*
|
||||
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
|
||||
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
|
||||
* The stream is untouched; that has its own `renderScale`.
|
||||
*/
|
||||
val reduceUiResolution: Boolean = false,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
@@ -478,7 +497,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val mode = display.mode
|
||||
val w = mode.physicalWidth
|
||||
val h = mode.physicalHeight
|
||||
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
|
||||
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
|
||||
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
|
||||
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
|
||||
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
|
||||
// describe the same panel and must not disagree.
|
||||
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
|
||||
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -317,15 +317,21 @@ internal object ConsoleJson {
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
extra.put("android.low_latency", s.lowLatencyMode)
|
||||
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
extra.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
extra.put("android.sc2_capture", s.sc2Capture)
|
||||
extra.put("android.ds_capture", s.dsCapture)
|
||||
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("extra", extra)
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -335,7 +341,8 @@ internal object ConsoleJson {
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
@@ -372,13 +379,15 @@ internal object ConsoleJson {
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ object SkiaConsole {
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
|
||||
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
|
||||
// or the compositor stretches the whole interface, and while those two normally agree,
|
||||
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
|
||||
// depending on the version and on who is currently hiding what. "Normally agree" is not
|
||||
// something to hang picture geometry on. Zero until the first layout, which is exactly what
|
||||
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
|
||||
var viewW by remember { mutableStateOf(0) }
|
||||
var viewH by remember { mutableStateOf(0) }
|
||||
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
|
||||
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
|
||||
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
|
||||
// and there is nothing to give back.
|
||||
//
|
||||
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
|
||||
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
|
||||
// and design-unit scale just below, the pointer coordinates further down — because
|
||||
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
|
||||
// position in view pixels, and handing those straight to a half-size surface would land the
|
||||
// cursor at twice its true offset.
|
||||
val render = if (!settings.reduceUiResolution) 1f else {
|
||||
val long = maxOf(viewW, viewH)
|
||||
if (long > 1920) 1920f / long else 1f
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
|
||||
// directly would freeze them at its first-composition value (1, before the first layout has
|
||||
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
|
||||
// the rest of the session. Same reason `platformUp` is held this way.
|
||||
val currentRender by rememberUpdatedState(render)
|
||||
val dm = context.resources.displayMetrics
|
||||
val scale = if (tv) 0f else {
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
|
||||
// shrinking this would draw the type larger on screen than the same phone draws it today.
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
|
||||
if (handle != 0L) {
|
||||
NativeBridge.nativeConsoleSetViewport(
|
||||
handle,
|
||||
left * render,
|
||||
top * render,
|
||||
right * render,
|
||||
bottom * render,
|
||||
scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
|
||||
@@ -272,7 +313,9 @@ fun SkiaConsoleShell(
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewW = it.width; viewH = it.height },
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
// The console draws opaque, edge to edge; Compose overlays sit above it.
|
||||
@@ -305,7 +348,8 @@ fun SkiaConsoleShell(
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
else -> return@setOnTouchListener false
|
||||
}
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
|
||||
// View pixels → SURFACE pixels (see `render` above).
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
|
||||
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
|
||||
true
|
||||
}
|
||||
@@ -313,13 +357,27 @@ fun SkiaConsoleShell(
|
||||
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
|
||||
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
|
||||
) {
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
},
|
||||
// Applied here rather than in `factory` so flipping the setting takes effect without
|
||||
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
|
||||
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
|
||||
// documented way back to "the view's own size" when the setting goes off again.
|
||||
update = { view ->
|
||||
if (render < 1f) {
|
||||
view.holder.setFixedSize(
|
||||
(viewW * render).roundToInt().coerceAtLeast(1),
|
||||
(viewH * render).roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
} else {
|
||||
view.holder.setSizeFromLayout()
|
||||
}
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -238,6 +282,17 @@ object Gamepad {
|
||||
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
|
||||
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
|
||||
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
|
||||
*
|
||||
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
|
||||
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
|
||||
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
|
||||
* — see there. This table is global, so a device that genuinely presses one of those two
|
||||
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
|
||||
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
|
||||
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
|
||||
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
|
||||
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
|
||||
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
|
||||
*/
|
||||
fun buttonBit(keyCode: Int): Int = when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
|
||||
@@ -251,6 +306,8 @@ object Gamepad {
|
||||
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
|
||||
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
|
||||
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
|
||||
@@ -322,15 +379,26 @@ object Gamepad {
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
|
||||
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
|
||||
// force and already knows this pad better than we do, so we leave it alone.
|
||||
// 2. Which report order to read is decided from what the DEVICE declares, never a model
|
||||
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
|
||||
// keycodes no real controller has a button for.
|
||||
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
|
||||
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
|
||||
// positions too, and that is the fact — not the model — that separates the two firmwares
|
||||
// of the SAME Xbox pad, only the older of which needs any of this.
|
||||
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
|
||||
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
|
||||
// device-specific layout IS in force and knows this pad better than we do.
|
||||
//
|
||||
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
|
||||
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
|
||||
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
|
||||
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
|
||||
// which is why both pads work there on the same box.
|
||||
//
|
||||
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
|
||||
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
|
||||
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
|
||||
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
|
||||
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
|
||||
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
|
||||
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
|
||||
// does not. Declaration is not report order. Only the axes tell them apart.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
@@ -349,9 +417,12 @@ object Gamepad {
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` in that order. The
|
||||
* analog trigger value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold
|
||||
* to keycodes [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*
|
||||
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
|
||||
* the mute button. Everywhere else they are L3/R3.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
@@ -398,7 +469,23 @@ object Gamepad {
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
|
||||
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
|
||||
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
|
||||
//
|
||||
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
|
||||
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
|
||||
// mute purely because a driverless DualSense enumerates its buttons straight
|
||||
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
|
||||
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
|
||||
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
|
||||
// DualSense on the couch. There is no scancode that means the same button on
|
||||
// all pads; that is the entire reason this enum exists.
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
|
||||
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
|
||||
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
|
||||
// safe answer if that ever changes.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
@@ -482,22 +569,42 @@ object Gamepad {
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
|
||||
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
|
||||
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
|
||||
* BUTTON_C and BUTTON_Z.
|
||||
*
|
||||
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
|
||||
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
|
||||
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
|
||||
* Declaring the pair is therefore the signature of a pad Android is guessing at.
|
||||
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
|
||||
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
|
||||
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
|
||||
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
|
||||
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
|
||||
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
|
||||
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
|
||||
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
|
||||
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
|
||||
*
|
||||
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
|
||||
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
|
||||
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
|
||||
* declaring six or more — a standard-layout pad that never presses either included. Read alone
|
||||
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
|
||||
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
|
||||
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
|
||||
* there is one — where a false positive costs nothing.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice): PadButtons {
|
||||
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
val straightThrough = has[0] && has[1]
|
||||
return when {
|
||||
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
|
||||
straightThrough -> PadButtons.GENERIC_XBOX
|
||||
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
|
||||
}
|
||||
|
||||
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
|
||||
* [InputDevice] cannot be built off a device). */
|
||||
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
|
||||
namedTriggers -> PadButtons.NATIVE
|
||||
declaresCZ && sony -> PadButtons.GENERIC_SONY
|
||||
declaresCZ -> PadButtons.GENERIC_XBOX
|
||||
sony -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,11 +629,11 @@ object Gamepad {
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
val buttons = padButtons(dev)
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val buttons = padButtons(dev, namedTriggers = named)
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
|
||||
@@ -101,6 +101,18 @@ class GamepadRouter(
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
/**
|
||||
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
|
||||
* whose physical meaning differs per controller, and the gate on the mic toggle in
|
||||
* [slotButton].
|
||||
*
|
||||
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
|
||||
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
|
||||
* fact each path actually knows: the report order for an [InputDevice] (only
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
|
||||
* capture link.
|
||||
*/
|
||||
val hasMuteButton: Boolean = false,
|
||||
) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
@@ -160,7 +172,8 @@ class GamepadRouter(
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
|
||||
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
|
||||
* couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
|
||||
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
|
||||
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
|
||||
@@ -234,15 +247,40 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
|
||||
*
|
||||
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
|
||||
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
|
||||
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
|
||||
* flush, or a mute held across a disconnect would put a release on the wire for a press that
|
||||
* never went out. Every other system button under that policy leaves [slotButton] at the top
|
||||
* and never reaches a send at all.
|
||||
*/
|
||||
private fun localOnly(slot: Slot, bit: Int): Boolean =
|
||||
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
|
||||
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
|
||||
//
|
||||
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
|
||||
// words are "keeps them entirely with this device", and toggling this device's microphone
|
||||
// is precisely what a mute button does with itself. Returning here would have left the
|
||||
// button present and silently dead under `local`, for a reason nobody would ever find. It
|
||||
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
|
||||
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
|
||||
if (!systemForward &&
|
||||
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
@@ -258,7 +296,7 @@ class GamepadRouter(
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
if (send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
@@ -268,11 +306,26 @@ class GamepadRouter(
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
//
|
||||
// A pad's own mute button is a second trigger for the SAME toggle, not a new
|
||||
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
|
||||
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
|
||||
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
|
||||
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
|
||||
// button is held down.
|
||||
//
|
||||
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
|
||||
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
|
||||
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD) ||
|
||||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
|
||||
) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
if (!owned && send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -543,7 +596,15 @@ class GamepadRouter(
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
|
||||
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
|
||||
// The declared kind separates them (a DualShock 4 has no mute button either).
|
||||
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
|
||||
slots[syntheticId] = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
hasMuteButton = hasMute,
|
||||
)
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
}
|
||||
|
||||
@@ -603,10 +664,15 @@ class GamepadRouter(
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val map = Gamepad.padMap(dev)
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
Gamepad.AxisMapper(handle, index, map),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
|
||||
// report order IS the answer — and unlike `pref` it survives the user pinning every
|
||||
// pad to one type, which would otherwise cost a DualSense its mute button.
|
||||
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
@@ -652,7 +718,9 @@ class GamepadRouter(
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
|
||||
@@ -298,6 +298,18 @@ object NativeBridge {
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -155,6 +156,44 @@ class GamepadChordTest {
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/**
|
||||
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
|
||||
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
|
||||
*
|
||||
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
|
||||
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
|
||||
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
|
||||
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
|
||||
*
|
||||
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
|
||||
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
|
||||
*
|
||||
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
|
||||
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
|
||||
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
|
||||
* the edge rule below is the half a unit test can hold.
|
||||
*/
|
||||
@Test
|
||||
fun `the mute button toggles the mic once per press`() {
|
||||
fun fires(wasHeld: Int, bit: Int) =
|
||||
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
|
||||
|
||||
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
|
||||
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
|
||||
assertTrue(
|
||||
"a press while other buttons are held is still a fresh press",
|
||||
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
|
||||
)
|
||||
for (other in listOf(
|
||||
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
|
||||
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
|
||||
)) {
|
||||
assertFalse("$other toggled the mic", fires(0, other))
|
||||
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
|
||||
}
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
|
||||
@@ -64,12 +64,44 @@ class PadButtonsTest {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
/**
|
||||
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
|
||||
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
|
||||
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
|
||||
* detail of that hop, the BIT is the contract, so both halves are pinned here.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
fun `a DualSense's touchpad and mute reach their wire buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
|
||||
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
|
||||
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
|
||||
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
|
||||
*
|
||||
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
|
||||
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
|
||||
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
|
||||
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
|
||||
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
|
||||
*/
|
||||
@Test
|
||||
fun `every other pad keeps L3 and R3 on those scancodes`() {
|
||||
for (p in listOf(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.PadButtons.GENERIC_XBOX,
|
||||
Gamepad.PadButtons.SONY_MODERN,
|
||||
)) {
|
||||
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
|
||||
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
|
||||
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
|
||||
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
|
||||
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
|
||||
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
|
||||
}
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@@ -116,6 +148,30 @@ class PadButtonsTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard's NEGATIVE path — the half that decides anything.
|
||||
*
|
||||
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
|
||||
* transparent in every one of them and the assertions would hold with it deleted. These are
|
||||
* the ones that fail without it: a device-specific key layout answering something the table
|
||||
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
|
||||
* this controller, and the table is only ever a guess about a pad nothing knew.
|
||||
*/
|
||||
@Test
|
||||
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
|
||||
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
|
||||
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
|
||||
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
|
||||
}
|
||||
// And the two rows added for the touchpad and mute are no different: a pad whose layout
|
||||
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BUTTON_1,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
@@ -200,4 +256,55 @@ class PadButtonsTest {
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
|
||||
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
|
||||
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
|
||||
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
|
||||
* pad ever presses them or not. Naming the triggers is what tells the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is never corrected, whatever it declares`() {
|
||||
for (sony in listOf(false, true)) {
|
||||
for (declaresCZ in listOf(false, true)) {
|
||||
assertEquals(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four buttons the field reports named, on a pad whose report order is already standard:
|
||||
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
|
||||
* keeps them themselves — the correction tables are right for the pads they are for, and this
|
||||
* is about not reaching one of them.
|
||||
*/
|
||||
@Test
|
||||
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
|
||||
val native = Gamepad.PadButtons.NATIVE
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
// What the old heuristic did to each of them, kept here so the difference stays visible.
|
||||
val wrong = Gamepad.PadButtons.GENERIC_XBOX
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
}
|
||||
|
||||
/** Past the gate, which straight-through order to read is still the question it always was. */
|
||||
@Test
|
||||
fun `an unnamed-trigger pad still resolves its report order`() {
|
||||
fun order(sony: Boolean, declaresCZ: Boolean) =
|
||||
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
|
||||
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,7 @@ impl ConsoleHost {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-console".into())
|
||||
.spawn(move || {
|
||||
boost_thread_priority();
|
||||
let run = || -> Result<()> {
|
||||
let console = Console::new(opts, entry, &thread_handles)?;
|
||||
render_loop(console, thread_shared.clone(), thread_store)
|
||||
@@ -249,6 +250,34 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: lift the console's render thread off the default nice band, the same way
|
||||
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
|
||||
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
|
||||
/// free to park it on a little core behind whatever else the system is doing, which reads as a
|
||||
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
|
||||
/// harder deadline, and the two should not compete when the console is up during a session.
|
||||
///
|
||||
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
|
||||
fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!(
|
||||
"console: setpriority(-8) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
|
||||
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
|
||||
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
|
||||
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
|
||||
const FRAME_REPORT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
@@ -283,6 +312,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
|
||||
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
|
||||
let mut report_at = Instant::now();
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -446,8 +478,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => {
|
||||
// The console's real render resolution — the one number a bug report
|
||||
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
|
||||
// for every pass the shell draws.
|
||||
log::info!("console: drawing at {w}×{h}");
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
// Start the frame window here, not at loop entry: the console parks
|
||||
// with no surface while a stream is up, and a window that had been
|
||||
// open across that would report its first frame as "1 frame in 20 min".
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
@@ -462,6 +503,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
insets,
|
||||
scale,
|
||||
};
|
||||
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
|
||||
// wall-clock per iteration is always ~the panel period and says nothing. What
|
||||
// matters is how much of that period the shell spends building the frame —
|
||||
// once that passes the period, the console is missing vsyncs.
|
||||
let drew = Instant::now();
|
||||
console.frame(
|
||||
surf.canvas(),
|
||||
&viewport,
|
||||
@@ -470,6 +516,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
&pads,
|
||||
);
|
||||
g.context.flush_and_submit();
|
||||
let cost = drew.elapsed();
|
||||
frame_time += cost;
|
||||
frame_peak = frame_peak.max(cost);
|
||||
frames += 1;
|
||||
if report_at.elapsed() >= FRAME_REPORT {
|
||||
log::info!(
|
||||
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
|
||||
report_at.elapsed(),
|
||||
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
|
||||
frame_peak.as_secs_f64() * 1000.0,
|
||||
);
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
if let Err(e) = s.swap() {
|
||||
// The window went away under us; wait for the next surface.
|
||||
log::warn!("console: {e:#} — dropping the surface");
|
||||
|
||||
@@ -45,6 +45,10 @@ struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
|
||||
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
|
||||
//! auto-fallback with the residual risk being visual, not a crash.
|
||||
//!
|
||||
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
|
||||
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
|
||||
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
|
||||
//! out-param currently holds — the *successor's* fence:
|
||||
//!
|
||||
//! ```text
|
||||
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
|
||||
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
|
||||
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
|
||||
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
|
||||
//! returns img2 with *fd = F2 ← already given away and closed
|
||||
//! ```
|
||||
//!
|
||||
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
|
||||
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
|
||||
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
|
||||
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
|
||||
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
|
||||
@@ -142,21 +161,21 @@ pub(super) struct AscBackend {
|
||||
impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_w: i32,
|
||||
surface_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_w, surface_h)?;
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
@@ -376,19 +395,24 @@ impl AscBackend {
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
|
||||
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
|
||||
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
|
||||
///
|
||||
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
|
||||
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
|
||||
/// this module.
|
||||
fn drain_reader(&mut self) {
|
||||
if self.fifo_capacity == 0 {
|
||||
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
|
||||
if let Some(acq) = self.acquire(true) {
|
||||
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
|
||||
// candidate drops here — its image returns to the pool, its own acquire fence closes.
|
||||
while let Some(acq) = self.acquire() {
|
||||
if self.candidate.replace(acq).is_some() {
|
||||
self.skipped += 1; // an un-presented candidate was superseded
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
|
||||
while let Some(acq) = self.acquire(false) {
|
||||
while let Some(acq) = self.acquire() {
|
||||
self.fifo.push_back(acq);
|
||||
while self.fifo.len() > self.fifo_capacity {
|
||||
self.fifo.pop_front();
|
||||
@@ -398,19 +422,13 @@ impl AscBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
|
||||
/// `None` when the reader is empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
|
||||
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
|
||||
/// empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self) -> Option<Acquired> {
|
||||
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
|
||||
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
|
||||
// acquire requires.
|
||||
let res = unsafe {
|
||||
if latest {
|
||||
self.reader.acquire_latest_image_async()
|
||||
} else {
|
||||
self.reader.acquire_next_image_async()
|
||||
}
|
||||
};
|
||||
let res = unsafe { self.reader.acquire_next_image_async() };
|
||||
let (image, fence) = match res {
|
||||
Ok(AcquireResult::Image(pair)) => pair,
|
||||
Ok(_) => return None, // no buffer available / max acquired
|
||||
|
||||
@@ -96,8 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -199,8 +198,7 @@ pub(super) fn run_async(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
|
||||
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// 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,
|
||||
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
|
||||
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
|
||||
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
|
||||
/// couldn't read it yet, and the backend falls back to the window buffer size.
|
||||
pub surface_w: i32,
|
||||
pub surface_h: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
|
||||
dest_w: i32,
|
||||
dest_h: i32,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
@@ -287,13 +293,16 @@ impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
|
||||
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
|
||||
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
|
||||
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
|
||||
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
|
||||
/// yet) falls back to that buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
@@ -303,20 +312,11 @@ impl Layer {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let dest_w = if dest_w > 0 {
|
||||
dest_w
|
||||
} else {
|
||||
window.width().max(1)
|
||||
};
|
||||
let dest_h = if dest_h > 0 {
|
||||
dest_h
|
||||
} else {
|
||||
window.height().max(1)
|
||||
};
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
|
||||
window.width(),
|
||||
window.height(),
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
@@ -324,12 +324,20 @@ impl Layer {
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
dest_w,
|
||||
dest_h,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
@@ -370,11 +378,12 @@ impl Layer {
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: self.dest_w,
|
||||
bottom: self.dest_h,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
|
||||
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_w: _,
|
||||
surface_h: _,
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
|
||||
@@ -317,6 +317,12 @@ impl ImageReader {
|
||||
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||
/// access the [`Image`] returned.
|
||||
///
|
||||
/// **The returned fence is unsound whenever the platform actually drops an older image.**
|
||||
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
|
||||
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
|
||||
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
|
||||
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
|
||||
///
|
||||
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
|
||||
#[cfg(feature = "api-level-26")]
|
||||
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
|
||||
|
||||
@@ -1479,39 +1479,28 @@ public final class SessionAudio {
|
||||
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// Encode a single mono bus (folded from the tap's own buffer format): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz mono, so it handles the rate change and the
|
||||
// wrong-channel downmix never happens. Mono end to end — the host's decoder upmixes,
|
||||
// so the old duplicate-into-stereo step only cost bits and cycles.
|
||||
//
|
||||
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
|
||||
// if a larger-than-expected device quantum ever arrives) — the steady-state tap path
|
||||
// allocates nothing.
|
||||
// `chain` carries the rate-dependent pieces INCLUDING the per-callback scratch buffers,
|
||||
// preallocated HERE for the rate the input currently reports — the steady-state tap path
|
||||
// allocates nothing. The tap rebuilds it if the device's real rate or quantum differ,
|
||||
// which is the price of installing the tap with the bus's own format (see below).
|
||||
let scratchFrames: AVAudioFrameCount = 8192
|
||||
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
|
||||
AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
|
||||
}
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
guard let encoder = try? OpusEncoder(),
|
||||
var chain = Self.micChain(
|
||||
rate: inFormat.sampleRate, frames: scratchFrames, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
|
||||
let monoScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: scratchFrames),
|
||||
let stagingScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)
|
||||
else {
|
||||
log.error("Opus encoder unavailable — mic uplink disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
|
||||
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var mono = monoScratch
|
||||
var staging = stagingScratch
|
||||
// Tap-thread-confined state: fold into `chain.mono`, resample into `chain.staging`,
|
||||
// accumulate in `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var fifo: [Float] = []
|
||||
fifo.reserveCapacity(48_000)
|
||||
var seq: UInt32 = 0
|
||||
@@ -1533,22 +1522,32 @@ public final class SessionAudio {
|
||||
// 480 frames = 10 ms, matching the packet duration. Advisory — CoreAudio delivers the
|
||||
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
|
||||
// of the uplink's latency) — but where the system honors it, the tap fires per-packet.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
|
||||
// `format: nil` — NOT the format read above. `installTap` validates a non-nil format
|
||||
// against the bus and raises an Objective-C exception on any mismatch; Swift cannot catch
|
||||
// that, so it aborts the process (SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`). The
|
||||
// format was necessarily read a moment EARLIER, and on macOS the input can move underneath
|
||||
// it — a device switch, a clock/rate change, or the `setDevice` swap `startCapture` itself
|
||||
// performs two lines before this. `nil` means "whatever the bus emits", which is what the
|
||||
// chain wants anyway, and the mismatch cannot arise by construction. The tap then follows
|
||||
// the real format below.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: nil) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData else { return }
|
||||
if frames > Int(mono.frameCapacity) {
|
||||
// A quantum larger than the scratch (bufferSize is advisory both ways) — regrow
|
||||
// once to the new high-water mark; the steady state stays allocation-free.
|
||||
guard let biggerMono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let biggerStaging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat,
|
||||
frameCapacity: stagingCapacity(buffer.frameLength))
|
||||
// Rebuild the rate-dependent chain when the device changes rate under a live tap
|
||||
// (resampling by the old ratio would pitch-shift the mic), and when a quantum larger
|
||||
// than the scratch arrives (`bufferSize` is advisory both ways) — regrown once to the
|
||||
// new high-water mark, so the steady state stays allocation-free.
|
||||
if buffer.format.sampleRate != chain.monoFormat.sampleRate
|
||||
|| buffer.frameLength > chain.mono.frameCapacity {
|
||||
guard let rebuilt = Self.micChain(
|
||||
rate: buffer.format.sampleRate,
|
||||
frames: max(buffer.frameLength, scratchFrames),
|
||||
to: encoder.pcmFormat)
|
||||
else { return }
|
||||
mono = biggerMono
|
||||
staging = biggerStaging
|
||||
chain = rebuilt
|
||||
}
|
||||
let mono = chain.mono, staging = chain.staging, resampler = chain.resampler
|
||||
guard let dst = mono.floatChannelData?[0] else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
@@ -1620,6 +1619,41 @@ public final class SessionAudio {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The rate-dependent half of the mic chain: a mono bus at `rate`, the resampler from it onto
|
||||
/// the encoder's 48 kHz mono, and the two scratch buffers sized for `frames`. Grouped so the
|
||||
/// tap can swap all four together — they are only ever valid as a set.
|
||||
struct MicChain {
|
||||
let monoFormat: AVAudioFormat
|
||||
let resampler: AVAudioConverter
|
||||
let mono: AVAudioPCMBuffer
|
||||
let staging: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
/// Build a `MicChain` for `rate`, or nil if the rate is unusable or an allocation fails.
|
||||
/// Built once up front for the format the input reports, and again from the tap whenever the
|
||||
/// device's real rate differs — a macOS input can change rate under a live tap, and a chain
|
||||
/// pinned to the old rate resamples by the wrong ratio (a pitch-shifted mic).
|
||||
/// `internal` for unit testing: it needs no engine, device or permission.
|
||||
static func micChain(
|
||||
rate: Double, frames: AVAudioFrameCount, to pcmFormat: AVAudioFormat
|
||||
) -> MicChain? {
|
||||
// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio from `rate`
|
||||
// (a 44.1 kHz quantum grows by ~1.088); +64 covers the converter's own slack.
|
||||
guard rate > 0, frames > 0,
|
||||
let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1,
|
||||
interleaved: false),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: pcmFormat),
|
||||
let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames),
|
||||
let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: pcmFormat,
|
||||
frameCapacity: AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / rate).rounded(.up)) + 64)
|
||||
else { return nil }
|
||||
return MicChain(
|
||||
monoFormat: monoFormat, resampler: resampler, mono: mono, staging: staging)
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The rate-dependent half of the mic chain (SessionAudio.micChain). The tap now installs with
|
||||
// `format: nil` — a non-nil format is validated against the bus and raises an Objective-C
|
||||
// exception on mismatch, which Swift cannot catch, so it aborted the whole app (SIGABRT in
|
||||
// AVAudioEngineGraph::InstallTapOnNode, reported against 0.31.0). With nil the tap follows
|
||||
// whatever the bus emits, which means the chain has to be rebuildable at the device's real rate.
|
||||
// This pins the sizing arithmetic that rebuild depends on, without an engine, device or mic grant.
|
||||
|
||||
#if !os(tvOS)
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioMicChainTests: XCTestCase {
|
||||
/// The encoder's target: 48 kHz mono float — what every chain resamples ONTO.
|
||||
private let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1, interleaved: false)!
|
||||
|
||||
/// A chain is built at the device's rate, mono, and resamples onto the 48 kHz encoder format.
|
||||
func testBuildsMonoChainAtDeviceRate() throws {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: 44_100, frames: 8192, to: target))
|
||||
XCTAssertEqual(chain.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertEqual(chain.monoFormat.channelCount, 1)
|
||||
XCTAssertEqual(chain.mono.frameCapacity, 8192)
|
||||
XCTAssertEqual(chain.resampler.outputFormat.sampleRate, 48_000)
|
||||
}
|
||||
|
||||
/// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio — the bug this
|
||||
/// guards is a staging buffer sized for the input rate, which silently truncates every packet
|
||||
/// when the device runs below 48 kHz.
|
||||
func testStagingFitsUpwardResampleRatio() throws {
|
||||
for rate in [8_000.0, 16_000, 44_100, 48_000, 96_000] {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: rate, frames: 1024, to: target))
|
||||
let needed = (1024.0 * 48_000 / rate).rounded(.up)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
Double(chain.staging.frameCapacity), needed,
|
||||
"staging too small to hold 1024 frames resampled from \(rate) Hz")
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate the device cannot report is refused rather than producing a chain that would
|
||||
/// divide by zero in the staging arithmetic. The tap treats nil as "skip this buffer".
|
||||
func testRejectsUnusableRateAndEmptyQuantum() {
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 0, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: -48_000, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 48_000, frames: 0, to: target))
|
||||
}
|
||||
|
||||
/// The rebuild path: a device that switches 48 kHz → 44.1 kHz under a live tap yields a chain
|
||||
/// at the NEW rate. Resampling by the stale ratio is what pitch-shifts the mic.
|
||||
func testRebuildFollowsNewRate() throws {
|
||||
let first = try XCTUnwrap(SessionAudio.micChain(rate: 48_000, frames: 512, to: target))
|
||||
let second = try XCTUnwrap(SessionAudio.micChain(rate: 44_100, frames: 512, to: target))
|
||||
XCTAssertEqual(first.monoFormat.sampleRate, 48_000)
|
||||
XCTAssertEqual(second.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertGreaterThan(second.staging.frameCapacity, first.staging.frameCapacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -148,6 +148,11 @@ pub enum AppMsg {
|
||||
ended: Option<String>,
|
||||
tofu: bool,
|
||||
},
|
||||
/// Hand over to the gamepad console (`punktfunk-session --browse`) — the couch UI's
|
||||
/// door from the desktop shell.
|
||||
OpenConsole,
|
||||
/// The console child exited; `Some` carries why it ended badly.
|
||||
ConsoleExited(Option<String>),
|
||||
/// Request-access Cancel: the child was killed; release busy quietly.
|
||||
CancelPending,
|
||||
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||
@@ -519,6 +524,51 @@ impl SimpleComponent for AppModel {
|
||||
))),
|
||||
}
|
||||
}
|
||||
AppMsg::OpenConsole => {
|
||||
if std::mem::replace(&mut self.busy, true) {
|
||||
return;
|
||||
}
|
||||
// The console owns the screen and the pads while it runs, so it takes `busy`
|
||||
// like a stream does. `gio::Subprocess` is the GLib-native child: its
|
||||
// `wait_check_async` lands the exit on this very main loop — no thread, no
|
||||
// channel — and reports a non-zero exit as an error. That is also how a
|
||||
// build without the session's `ui` feature (Nix) surfaces: the child prints
|
||||
// "--browse needs the console UI" and exits non-zero, and we banner it.
|
||||
let mut argv = vec![
|
||||
std::ffi::OsString::from(crate::spawn::session_binary()),
|
||||
"--browse".into(),
|
||||
];
|
||||
// Same knob a stream uses — the session also fullscreens itself on the Deck
|
||||
// and under gamescope regardless.
|
||||
if self.settings.borrow().fullscreen_on_stream {
|
||||
argv.push("--fullscreen".into());
|
||||
}
|
||||
let argv: Vec<&std::ffi::OsStr> =
|
||||
argv.iter().map(std::ffi::OsString::as_os_str).collect();
|
||||
match gio::Subprocess::newv(&argv, gio::SubprocessFlags::NONE) {
|
||||
Ok(child) => {
|
||||
let sender = sender.clone();
|
||||
child.wait_check_async(gio::Cancellable::NONE, move |res| {
|
||||
sender.input(AppMsg::ConsoleExited(res.err().map(|e| e.to_string())));
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::ShowError(format!(
|
||||
"Couldn't start the console UI — {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMsg::ConsoleExited(err) => {
|
||||
self.busy = false;
|
||||
// Quitting the console (B at its root) exits 0 and returns here silently.
|
||||
if let Some(e) = err {
|
||||
self.hosts
|
||||
.emit(HostsMsg::ShowError(format!("Console UI ended — {e}")));
|
||||
}
|
||||
self.hosts.emit(HostsMsg::Refresh);
|
||||
}
|
||||
AppMsg::CancelPending => {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
@@ -1007,6 +1057,7 @@ fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<App
|
||||
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||
window.add_action(&add("console", || AppMsg::OpenConsole));
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||
|
||||
@@ -859,7 +859,14 @@ impl SimpleComponent for HostsPage {
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
// The couch UI's front door, beside the page's other actions (same placement the
|
||||
// WinUI shell gives it). It was previously reachable only as `--browse` on the
|
||||
// command line, which is no way to find a mode.
|
||||
let console_btn = gtk::Button::from_icon_name("input-gaming-symbolic");
|
||||
console_btn.set_tooltip_text(Some("Console UI — the controller-driven couch interface"));
|
||||
console_btn.set_action_name(Some("win.console"));
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Console UI"), Some("win.console"));
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Punktfunk"), Some("win.about"));
|
||||
@@ -869,7 +876,9 @@ impl SimpleComponent for HostsPage {
|
||||
.primary(true)
|
||||
.tooltip_text("Main menu")
|
||||
.build();
|
||||
// Packed after the menu so the hamburger stays rightmost (pack_end fills inward).
|
||||
header.pack_end(&menu_btn);
|
||||
header.pack_end(&console_btn);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
@@ -1250,7 +1250,9 @@ pub fn show_scoped(
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
|
||||
// could not name any of them, and typing was the only way to reach one.
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row
|
||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
||||
|
||||
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
let df = dropped_frames.clone();
|
||||
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
|
||||
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
|
||||
// sentinel — which is exactly what makes it usable for testing that path.
|
||||
let rxp = rx_wire_packets.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut last_dropped = 0u64;
|
||||
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
|
||||
// window while it is zero, once when the first packets land, then stop — so a host that
|
||||
// predates the message is not flooded with "unknown control message" on a good session.
|
||||
let mut delivery_confirmed = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let d = df.load(Relaxed);
|
||||
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
last_report = std::time::Instant::now();
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
// Independent of whether there is a fresh loss sample: "no fresh sample" is
|
||||
// exactly the shape a dead data plane has, so gating it on one would silence
|
||||
// it in the state it exists to report.
|
||||
let received = rxp.load(Relaxed);
|
||||
if received == 0 || !delivery_confirmed {
|
||||
delivery_confirmed = received > 0;
|
||||
if io::write_msg(
|
||||
&mut ls,
|
||||
&DeliveryReport {
|
||||
packets_received: received,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
|
||||
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
|
||||
/// there isn't (the library grid spends up on rows) the same menu hangs off
|
||||
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
|
||||
Up,
|
||||
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
|
||||
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
|
||||
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,9 @@ enum Resolved {
|
||||
/// The d-pad's up — drawn the same in every style, because it is a direction rather
|
||||
/// than a button whose label changes with the pad.
|
||||
Up,
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Shoulders => Resolved::Key("Tab"),
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
};
|
||||
}
|
||||
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Secondary => Resolved::Badge(Face::Y),
|
||||
HintKey::Shoulders => Resolved::Shoulders,
|
||||
HintKey::Adjust => Resolved::Adjust,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
}
|
||||
@@ -394,17 +403,23 @@ fn draw_glyph(
|
||||
pen += w + 3.0 * k;
|
||||
}
|
||||
}
|
||||
Resolved::Up => {
|
||||
// ▲ — one solid triangle in a badge-sized slot.
|
||||
g @ (Resolved::Up | Resolved::Down) => {
|
||||
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
|
||||
// way up: apex toward the direction it names, base at the other end.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
|
||||
let (apex, base) = if matches!(g, Resolved::Down) {
|
||||
(cyf + th, cyf - th)
|
||||
} else {
|
||||
(cyf - th, cyf + th)
|
||||
};
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, apex));
|
||||
tri.line_to((cx - tw, base));
|
||||
tri.line_to((cx + tw, base));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
|
||||
pub pads: &'a [PadInfo],
|
||||
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
|
||||
pub deck: bool,
|
||||
/// The host app has another interface to fall back to when the console is switched
|
||||
/// off (an Android phone/tablet's touch shell) — see
|
||||
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
|
||||
pub fallback_ui: bool,
|
||||
/// The name the HOST stores this client under when pairing (the machine's
|
||||
/// hostname, resolved by the binary).
|
||||
pub device_name: &'a str,
|
||||
@@ -242,16 +246,22 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.text_input(text),
|
||||
Screen::Pair(s) => s.text_input(text),
|
||||
Screen::Settings(s) => s.text_input(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
|
||||
/// Returns true when consumed.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key) -> bool {
|
||||
///
|
||||
/// Takes the context because a field can commit into the settings store on close —
|
||||
/// the settings screen's typed bitrate does, where add-host and pair only hold text a
|
||||
/// later action row reads.
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.edit_key(key),
|
||||
Screen::Pair(s) => s.edit_key(key),
|
||||
Screen::Settings(s) => s.edit_key(key, ctx),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -261,6 +271,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::AddHost(s) => s.editing(),
|
||||
Screen::Pair(s) => s.editing(),
|
||||
Screen::Settings(s) => s.editing(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +396,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -301,6 +302,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -323,6 +325,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -378,6 +378,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -429,6 +430,7 @@ mod tests {
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -206,7 +206,17 @@ impl HomeScreen {
|
||||
}
|
||||
_ => Some(MenuPulse::Boundary),
|
||||
},
|
||||
MenuEvent::Move(_) => None,
|
||||
// Down is Settings — the same screen X opens. The carousel is horizontal, so
|
||||
// down is the other free direction, and it is the only route to Settings on a
|
||||
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
|
||||
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
|
||||
// answered it by moving rows out to the ordinary Settings app.)
|
||||
MenuEvent::Move(MenuDir::Down) => {
|
||||
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
|
||||
ctx.store,
|
||||
)));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +289,15 @@ impl HomeScreen {
|
||||
{
|
||||
hints.push(Hint::new(HintKey::Up, "Options"));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
|
||||
// Name the route this device actually has. With no pad attached the legend is
|
||||
// already speaking keyboard, and the one input that reaches here with neither a
|
||||
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
|
||||
// Down opens Settings for everyone; only the advertisement changes.
|
||||
hints.push(if ctx.pads.is_empty() {
|
||||
Hint::new(HintKey::Down, "Settings")
|
||||
} else {
|
||||
Hint::new(HintKey::Tertiary, "Settings")
|
||||
});
|
||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||
hints
|
||||
}
|
||||
@@ -859,6 +877,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -885,6 +904,67 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
|
||||
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
|
||||
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
|
||||
/// legend names the direction rather than X when nothing is plugged in.
|
||||
#[test]
|
||||
fn a_remote_reaches_settings_and_options_without_face_buttons() {
|
||||
let mut settings = ctx_settings();
|
||||
let hosts = [host("paired", true, true, false)];
|
||||
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Android,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: true,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
|
||||
// Down opens the same screen X opens.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
|
||||
"down must open Settings"
|
||||
);
|
||||
// Up still opens the host's own menu — the library hangs off that menu now.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
|
||||
"up must open the host options menu"
|
||||
);
|
||||
// With no pad the legend advertises the direction, not a button that isn't there.
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
|
||||
"a padless device is told about down"
|
||||
);
|
||||
// With a pad it goes back to naming X, which is faster to press.
|
||||
let pads = vec![pf_client_core::menu_nav::PadInfo {
|
||||
name: "Pad".into(),
|
||||
key: "045e:028e:Pad".into(),
|
||||
pref: punktfunk_core::config::GamepadPref::Xbox360,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "045E:028E · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
}];
|
||||
ctx.pads = &pads;
|
||||
assert!(
|
||||
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
|
||||
"a pad is told about X"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -908,6 +988,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -932,6 +1013,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -2218,6 +2218,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
|
||||
enum Action {
|
||||
Wake,
|
||||
SendLogs,
|
||||
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
|
||||
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
|
||||
/// exactly like that Y (an unpaired host has no shelf to fetch).
|
||||
Library,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
@@ -154,6 +158,12 @@ impl OptionsScreen {
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
|
||||
// because it is the one row here that goes somewhere rather than acting on the
|
||||
// host — and on a remote-only device it is the ONLY way to the library.
|
||||
if host.paired && host.saved {
|
||||
a.push(Action::Library);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
@@ -171,6 +181,7 @@ impl OptionsScreen {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::Library => "Library".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
@@ -234,7 +245,7 @@ impl OptionsScreen {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
self.run(action, ctx.store, fx);
|
||||
self.run(action, ctx, fx);
|
||||
pulse
|
||||
}
|
||||
}
|
||||
@@ -257,7 +268,8 @@ impl OptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
|
||||
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
|
||||
let store = ctx.store;
|
||||
let key = self.host_key().to_string();
|
||||
match action {
|
||||
Action::Wake => {
|
||||
@@ -289,6 +301,24 @@ impl OptionsScreen {
|
||||
}
|
||||
fx.pop();
|
||||
}
|
||||
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
|
||||
// on the epoch read BEFORE the command drains, so the screen can tell its own
|
||||
// fetch's titles from the ones already in the model. `replace`, not push — the
|
||||
// menu has said its piece, and Back from the shelf belongs on the carousel
|
||||
// rather than on a menu about the host you just left.
|
||||
Action::Library => {
|
||||
let host = self.host();
|
||||
fx.cmds.push(ConsoleCmd::FetchLibrary {
|
||||
addr: host.addr.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
});
|
||||
let epoch = ctx.library.fetch_epoch();
|
||||
fx.replace(Screen::Library(super::library::LibraryScreen::new(
|
||||
self.host(),
|
||||
epoch,
|
||||
)));
|
||||
}
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
@@ -407,6 +437,27 @@ mod tests {
|
||||
use crate::model::ProfileChip;
|
||||
use crate::screens::Nav;
|
||||
|
||||
/// Activate one row. `run` reads the store, and — for Library — the shared library's
|
||||
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
|
||||
/// enough for every action test here.
|
||||
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
|
||||
let mut settings = pf_client_core::trust::Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
s.run(action, &ctx, fx);
|
||||
}
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
@@ -510,6 +561,38 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// The shelf is on this menu, which is the only route to it that survives a device with
|
||||
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
|
||||
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
|
||||
/// the shelf lands on the carousel rather than on a menu about the host just left.
|
||||
#[test]
|
||||
fn the_library_hangs_off_the_menu_for_a_padless_device() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
run_action(&mut s, Action::Library, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
|
||||
"opening the shelf asks for it first"
|
||||
);
|
||||
match fx.nav {
|
||||
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
|
||||
_ => panic!("expected the shelf to replace the menu"),
|
||||
}
|
||||
|
||||
// An unpaired host has no shelf to fetch — the row is absent, not inert.
|
||||
let unpaired = OptionsScreen::for_host(&HostRow {
|
||||
paired: false,
|
||||
..host()
|
||||
});
|
||||
assert!(!unpaired
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::Library));
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
@@ -520,7 +603,7 @@ mod tests {
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::BindProfile, &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
@@ -537,7 +620,7 @@ mod tests {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -551,7 +634,7 @@ mod tests {
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Clipboard, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
@@ -569,12 +652,12 @@ mod tests {
|
||||
s.list.cursor = i;
|
||||
let mut fx = Outbox::default();
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert!(fx.cmds.is_empty(), "the first press only arms");
|
||||
assert!(s.armed);
|
||||
assert!(s.label(Action::Forget).contains("press again"));
|
||||
|
||||
s.run(Action::Forget, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::Forget, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
|
||||
@@ -597,6 +680,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &[],
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -659,7 +743,7 @@ mod tests {
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
] {
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
|
||||
run_action(&mut s, Action::CopyLink, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
assert!(fx.toast.is_some());
|
||||
}
|
||||
|
||||
@@ -497,6 +497,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "living-room-deck",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -537,6 +538,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "deck",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -274,6 +275,7 @@ mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::glyphs::{Hint, HintKey};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
||||
use crate::widgets::{
|
||||
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
|
||||
};
|
||||
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
@@ -80,6 +82,12 @@ enum RowId {
|
||||
/// beside the palette row for the same reason it does: both are presentation, and the
|
||||
/// effect of stepping this one is visible on the backdrop behind it.
|
||||
ReduceMotion,
|
||||
/// Draw the console at 1080p and let the display scale it up, instead of at the panel's
|
||||
/// own resolution. Android-only, and beside [`RowId::ReduceMotion`] on purpose: both are
|
||||
/// "give up some fidelity for a smoother console", and this is the one that matters on a
|
||||
/// 4K TV or projector, where every pass the shell draws costs four times what it does at
|
||||
/// 1080p on a GPU that is not four times faster.
|
||||
ReduceUiResolution,
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. The library
|
||||
/// changes it in place now, from the bar over its own field, which is where an
|
||||
/// arrangement you want to SEE the effect of belongs; this row stays because both
|
||||
@@ -104,6 +112,12 @@ enum RowId {
|
||||
Sc2Passthrough,
|
||||
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
|
||||
DsCapture,
|
||||
/// Whether the console UI fronts the app at all — the touch settings' switch
|
||||
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
|
||||
/// Only offered where there is another interface to fall back to
|
||||
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
|
||||
/// UI, and an off switch would strand the user in nothing.
|
||||
GamepadUi,
|
||||
/// When the console UI fronts the app: with a controller attached, or always.
|
||||
GamepadUiMode,
|
||||
/// The platform's connected-controllers view (an action row — opens a native screen).
|
||||
@@ -121,6 +135,8 @@ mod android_keys {
|
||||
pub const SC2: &str = "android.sc2_capture";
|
||||
pub const DS_CAPTURE: &str = "android.ds_capture";
|
||||
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
|
||||
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
|
||||
pub const REDUCE_UI_RES: &str = "android.reduce_ui_resolution";
|
||||
}
|
||||
|
||||
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
|
||||
@@ -240,11 +256,13 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::ReduceMotion,
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::LibraryView,
|
||||
RowId::LibraryCollections,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
],
|
||||
@@ -272,7 +290,18 @@ const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
|
||||
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
|
||||
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
|
||||
/// The rungs left/right steps through, in kbps. Tight at the bottom, where one rung is the
|
||||
/// difference between watchable and a slideshow on a thin link, and coarse at the top, where
|
||||
/// a rung is noise; the ceiling is 2 Gbps. The list is deliberately long — a ladder no thumb
|
||||
/// can walk to the value it wants is what the Y field is for.
|
||||
const BITRATES: [u32; 30] = [
|
||||
0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 12_000, 15_000, 20_000, 25_000,
|
||||
30_000, 40_000, 50_000, 60_000, 80_000, 100_000, 125_000, 150_000, 200_000, 250_000, 300_000,
|
||||
400_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000,
|
||||
];
|
||||
/// What the typed field accepts, in Mbps: the ladder's own ceiling. The host clamps to its
|
||||
/// range anyway (500 kbps – 8 Gbps), so this is about what a client should let you ask for.
|
||||
const CUSTOM_MAX_MBPS: u32 = 2_000;
|
||||
const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
("kwin", "KWin"),
|
||||
@@ -355,6 +384,14 @@ pub(crate) struct SettingsScreen {
|
||||
/// 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)>,
|
||||
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
|
||||
/// the time. Every other row on this screen is a list of options, and a ladder is the
|
||||
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
|
||||
/// rarely a round rung. Y rather than A so the A-cycles-forward grammar holds everywhere.
|
||||
custom_bitrate: Option<String>,
|
||||
/// The tray keyboard the field types through, where the platform has no keyboard of its
|
||||
/// own (on a Deck, Steam's keyboard types and ours never draws — same rule as add-host).
|
||||
keyboard: Keyboard,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
@@ -372,6 +409,112 @@ impl SettingsScreen {
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
custom_bitrate: None,
|
||||
keyboard: Keyboard::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field is open — the run loop keeps SDL text input started, so a hardware
|
||||
/// keyboard (and Steam's, on a Deck) types straight into it.
|
||||
pub(crate) fn editing(&self) -> bool {
|
||||
self.custom_bitrate.is_some()
|
||||
}
|
||||
|
||||
/// Committed text from SDL. Digits only, four of them: 2000 Mbps is the ceiling.
|
||||
pub(crate) fn text_input(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn type_char(&mut self, ch: char) -> bool {
|
||||
let Some(buf) = self.custom_bitrate.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !permits(Charset::Digits, ch) || buf.chars().count() >= 4 {
|
||||
return false;
|
||||
}
|
||||
buf.push(ch);
|
||||
true
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
self.custom_bitrate.as_mut().and_then(String::pop).is_some()
|
||||
}
|
||||
|
||||
/// Raw key edits while the field is open (Backspace repeats, Return/Escape are done).
|
||||
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
|
||||
use crate::input::Key as K;
|
||||
if self.custom_bitrate.is_none() {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
K::Backspace => {
|
||||
self.backspace();
|
||||
true
|
||||
}
|
||||
K::Return | K::Escape => {
|
||||
self.commit_custom(ctx);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the field, storing what was typed. An empty field (or a typed `0`) leaves the
|
||||
/// rate alone: a cleared field is an abandoned edit, and "let the host decide" is the
|
||||
/// ladder's own first rung, not something to reach by deleting four digits.
|
||||
fn commit_custom(&mut self, ctx: &mut Ctx) {
|
||||
let Some(text) = self.custom_bitrate.take() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mbps) = text.parse::<u32>() else {
|
||||
return;
|
||||
};
|
||||
if mbps == 0 {
|
||||
return;
|
||||
}
|
||||
// The same rebase-then-save every other write here does: another writer may have
|
||||
// stored the file while the keyboard was up.
|
||||
*ctx.settings = ctx.store.load();
|
||||
ctx.settings.bitrate_kbps = mbps.min(CUSTOM_MAX_MBPS) * 1000;
|
||||
ctx.store.save(ctx.settings);
|
||||
}
|
||||
|
||||
/// The field is modal while it is up: the tray takes the events, and closing commits.
|
||||
fn custom_menu(&mut self, ev: MenuEvent, ctx: &mut Ctx) -> Option<MenuPulse> {
|
||||
if ctx.deck {
|
||||
// Steam's keyboard is doing the typing (text arrives through `text_input`); the
|
||||
// pad is only here to say when it's done.
|
||||
return match ev {
|
||||
MenuEvent::Back | MenuEvent::Confirm => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.keyboard.menu(ev);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
if self.type_char(c) {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
if self.backspace() {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Done => {
|
||||
self.commit_custom(ctx);
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
KeyMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +527,7 @@ impl SettingsScreen {
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
|
||||
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
@@ -441,6 +584,27 @@ impl SettingsScreen {
|
||||
/// Mouse/touch. The strip is checked first — its pills sit above the list and a press
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if self.custom_bitrate.is_some() && !ctx.deck {
|
||||
if !self.keyboard.covers(p) {
|
||||
if p.press() {
|
||||
self.commit_custom(ctx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let (msg, _) = self.keyboard.pointer(p);
|
||||
match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
self.type_char(c);
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
self.backspace();
|
||||
}
|
||||
KeyMsg::Done => self.commit_custom(ctx),
|
||||
KeyMsg::None => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
@@ -461,6 +625,9 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
return self.custom_menu(ev, ctx);
|
||||
}
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
@@ -472,6 +639,16 @@ impl SettingsScreen {
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
|
||||
// and the hint bar only offers it where it does.
|
||||
if ev == MenuEvent::Secondary {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
|
||||
self.custom_bitrate = Some(String::new());
|
||||
Some(MenuPulse::Confirm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -574,6 +751,20 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.custom_bitrate.is_some() {
|
||||
if ctx.deck {
|
||||
return vec![
|
||||
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
|
||||
Hint::new(HintKey::Confirm, "Done"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
return vec![
|
||||
Hint::new(HintKey::Confirm, "Type"),
|
||||
Hint::new(HintKey::Tertiary, "Delete"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
@@ -587,6 +778,12 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
// The one row with a value the ladder cannot name every version of.
|
||||
Some(RowId::Bitrate) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Secondary, "Type a rate"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
Some(_) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
@@ -619,20 +816,49 @@ impl SettingsScreen {
|
||||
k,
|
||||
dt,
|
||||
);
|
||||
let seat = self
|
||||
.keyboard
|
||||
.seat(self.custom_bitrate.is_some() && !ctx.deck, dt);
|
||||
let tray_h = if seat > 0.0 {
|
||||
(Keyboard::tray_height() + 12.0) * k * seat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top + strip_h as f32,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
rect.bottom - detail_h as f32 - tray_h as f32,
|
||||
);
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
let mut 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);
|
||||
// While the field is open the Bitrate row IS the field: it shows the digits typed so
|
||||
// far and carries the caret, so the value being edited is where the value lives.
|
||||
if let (Some(text), Some(i)) = (
|
||||
self.custom_bitrate.as_ref(),
|
||||
ids.iter().position(|id| *id == RowId::Bitrate),
|
||||
) {
|
||||
rows[i].value = Some(if text.is_empty() {
|
||||
"Mbps".into()
|
||||
} else {
|
||||
format!("{text} Mbps")
|
||||
});
|
||||
rows[i].value_dim = text.is_empty();
|
||||
rows[i].caret = true;
|
||||
}
|
||||
self.list.render(
|
||||
canvas,
|
||||
list_rect,
|
||||
&rows,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.custom_bitrate.is_none(),
|
||||
);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
@@ -644,9 +870,19 @@ impl SettingsScreen {
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.bottom) - detail_h - tray_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
if seat > 0.0 {
|
||||
self.keyboard.render(
|
||||
canvas,
|
||||
fonts,
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.bottom),
|
||||
seat,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,7 +911,9 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
| RowId::PhoneGyro
|
||||
| RowId::Sc2Passthrough
|
||||
| RowId::DsCapture
|
||||
| RowId::GamepadUi
|
||||
| RowId::GamepadUiMode
|
||||
| RowId::ReduceUiResolution
|
||||
| RowId::Controllers
|
||||
| RowId::Licenses
|
||||
);
|
||||
@@ -694,9 +932,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
|
||||
// The console-off switch needs somewhere for "off" to land: only clients with a
|
||||
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
|
||||
// this console is the only UI, and off would strand the user (the touch settings'
|
||||
// subtitle even promises "A TV always uses it").
|
||||
RowId::GamepadUi => ctx.fallback_ui,
|
||||
// The same two conditions the mode decides anything under: a TV is in console mode
|
||||
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
|
||||
// while the switch above is off nothing fronts the console at all. Hidden rather
|
||||
// than dimmed, like the touch screen's picker, and it sits directly below the row
|
||||
// that drops it so the cursor is never under anything that moves.
|
||||
RowId::GamepadUiMode => {
|
||||
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -806,7 +1057,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
if s.bitrate_kbps == 0 {
|
||||
"Automatic".into()
|
||||
} else {
|
||||
format!("{} Mbps", s.bitrate_kbps / 1000)
|
||||
bitrate_label(s.bitrate_kbps)
|
||||
},
|
||||
),
|
||||
RowId::Compositor => (
|
||||
@@ -914,6 +1165,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// Phrased as the thing that is ON, not as the suppression, so "On" means the
|
||||
// reduction is in effect — the same way every other toggle on this screen reads.
|
||||
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
|
||||
RowId::ReduceUiResolution => (
|
||||
None,
|
||||
"Reduce interface resolution",
|
||||
on_off(extra_bool(s, android_keys::REDUCE_UI_RES, false)).into(),
|
||||
),
|
||||
RowId::LibraryView => (
|
||||
None,
|
||||
"Library view",
|
||||
@@ -962,9 +1218,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"DualSense over USB",
|
||||
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
|
||||
),
|
||||
RowId::GamepadUi => (
|
||||
None,
|
||||
"Controller-optimized UI",
|
||||
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
|
||||
),
|
||||
RowId::GamepadUiMode => (
|
||||
None,
|
||||
"Controller UI",
|
||||
// The touch screen's word for the same picker, which now sits under the same
|
||||
// switch it does there — "Controller UI" beside "Controller-optimized UI"
|
||||
// would be two rows a reader has to tell apart by their tails.
|
||||
"Show it",
|
||||
label_for(
|
||||
&GAMEPAD_UI_MODES,
|
||||
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
@@ -1001,7 +1265,9 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
RowId::Bitrate => {
|
||||
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
|
||||
}
|
||||
RowId::Compositor => {
|
||||
"Which compositor drives the virtual output — honored only if available on the host."
|
||||
}
|
||||
@@ -1101,6 +1367,12 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
fades. Also the gentler choice on an OLED, where a still field can sit for \
|
||||
hours."
|
||||
}
|
||||
RowId::ReduceUiResolution => {
|
||||
"Draws the menus at 1080p and lets the display scale them up. Text goes a \
|
||||
little softer; the console gets much smoother on a 4K TV or projector, whose \
|
||||
graphics chip is far slower than the panel in front of it. Nothing about a \
|
||||
stream changes — this is the interface only."
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
|
||||
for when you already know what you are looking for. The library's own bar \
|
||||
@@ -1145,9 +1417,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
|
||||
Needs the USB grant when the pad is plugged in."
|
||||
}
|
||||
RowId::GamepadUi => {
|
||||
"Front the app with this console instead of the touch interface. Off returns \
|
||||
to the touch home immediately — switch it back on there."
|
||||
}
|
||||
RowId::GamepadUiMode => {
|
||||
"When this console fronts the app: whenever a controller is attached, or \
|
||||
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
|
||||
always — for a device that lives docked to a TV. The switch above turns it \
|
||||
off altogether."
|
||||
}
|
||||
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
|
||||
RowId::Licenses => "The open-source licences this app ships under.",
|
||||
@@ -1164,6 +1441,26 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate as the row says it: Mbps to a gigabit, Gbps above it, and a decimal only where
|
||||
/// dropping one would print two different rates the same way (12.5 Mbps, 1.5 Gbps). Rates
|
||||
/// off the ladder are real — the field below types them, and the desktop shells' free-form
|
||||
/// spinner has always been able to store one.
|
||||
fn bitrate_label(kbps: u32) -> String {
|
||||
let unit = |v: f64, suffix: &str| {
|
||||
if (v - v.round()).abs() < 0.05 {
|
||||
format!("{} {suffix}", v.round())
|
||||
} else {
|
||||
format!("{v:.1} {suffix}")
|
||||
}
|
||||
};
|
||||
let mbps = f64::from(kbps) / 1000.0;
|
||||
if kbps >= 1_000_000 {
|
||||
unit(mbps / 1000.0, "Gbps")
|
||||
} else {
|
||||
unit(mbps, "Mbps")
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(v: bool) -> &'static str {
|
||||
if v {
|
||||
"On"
|
||||
@@ -1228,8 +1525,21 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
|
||||
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
|
||||
// the generic step snaps a value it cannot find to the first option — which here
|
||||
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
|
||||
// thumb is heading for instead.
|
||||
let stepped = match BITRATES.iter().position(|b| *b == s.bitrate_kbps) {
|
||||
Some(i) => step_option(Some(i), BITRATES.len(), delta, wrap),
|
||||
None if delta < 0 => BITRATES.iter().rposition(|b| *b < s.bitrate_kbps),
|
||||
// Above the top rung there is nothing higher to step to; A (which wraps) still
|
||||
// comes back round to Automatic.
|
||||
None => BITRATES
|
||||
.iter()
|
||||
.position(|b| *b > s.bitrate_kbps)
|
||||
.or(if wrap { Some(0) } else { None }),
|
||||
};
|
||||
stepped.map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
}
|
||||
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
|
||||
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
|
||||
@@ -1361,6 +1671,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
|
||||
RowId::ReduceUiResolution => {
|
||||
toggle_extra(s, android_keys::REDUCE_UI_RES, false, delta, wrap)
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
let all = &crate::library::LibraryView::ALL;
|
||||
let cur = crate::library::LibraryView::parse(&s.library_view);
|
||||
@@ -1375,6 +1688,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
|
||||
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
|
||||
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
|
||||
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
|
||||
RowId::GamepadUiMode => {
|
||||
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
|
||||
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
|
||||
@@ -1504,6 +1818,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1538,6 +1853,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1603,6 +1919,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1652,6 +1969,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1686,6 +2004,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1727,6 +2046,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1757,6 +2077,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1793,6 +2114,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1867,6 +2189,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1900,6 +2223,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1931,6 +2255,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -1947,10 +2272,14 @@ pub(super) mod tests {
|
||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
||||
}
|
||||
|
||||
/// A rate that is not a rung — typed on the row, or stored by a desktop shell's
|
||||
/// free-form spinner — steps to its NEIGHBOUR. Every other picker here snaps an
|
||||
/// unrecognised value to its first option, which on this row is Automatic: one nudge
|
||||
/// would throw away the exact rate the user went to the trouble of typing.
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
fn an_off_ladder_rate_steps_to_its_neighbour() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
|
||||
settings.bitrate_kbps = 12_345;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
@@ -1960,11 +2289,86 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 15_000, "the rung above");
|
||||
ctx.settings.bitrate_kbps = 12_345;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 12_000, "the rung below");
|
||||
// The ends still thud rather than wrap under left/right.
|
||||
ctx.settings.bitrate_kbps = 2_000_000;
|
||||
assert!(!adjust(RowId::Bitrate, 1, false, &mut ctx), "the ceiling");
|
||||
// …and a rung it does know steps as it always did.
|
||||
ctx.settings.bitrate_kbps = 5_000;
|
||||
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 4_000);
|
||||
}
|
||||
|
||||
/// The typed rate: Y opens the field on the Bitrate row (and nowhere else), digits land
|
||||
/// in it, and closing stores what was typed — clamped to the ceiling, because four
|
||||
/// digits can ask for 9999 Mbps and no client should send that.
|
||||
#[test]
|
||||
fn a_typed_bitrate_is_stored_and_clamped() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// A snapshot store, not the file one: this test SAVES, and a unit test must not
|
||||
// rewrite the machine's real settings file to prove it.
|
||||
let store = crate::store::SnapshotStore::new(settings.clone(), Vec::new());
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: &store,
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
let ids = s.row_ids(&ctx);
|
||||
s.list.cursor = ids
|
||||
.iter()
|
||||
.position(|id| *id == RowId::Bitrate)
|
||||
.expect("the bitrate row");
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.editing(), "Y opens the field");
|
||||
s.text_input("13x7"); // digits only: the 'x' is refused, not typed
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert!(!s.editing(), "Return closes it");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 137_000);
|
||||
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
s.text_input("99999"); // four digits fit; the fifth is refused
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(
|
||||
ctx.settings.bitrate_kbps, 2_000_000,
|
||||
"clamped to the ceiling"
|
||||
);
|
||||
|
||||
// An emptied field is an abandoned edit, not a request for Automatic.
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 2_000_000, "left alone");
|
||||
|
||||
// Y is the bitrate row's alone — on a neighbour it does nothing at all.
|
||||
s.list.cursor = 0;
|
||||
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
|
||||
assert!(!s.editing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_read_in_the_biggest_round_unit() {
|
||||
assert_eq!(bitrate_label(20_000), "20 Mbps");
|
||||
assert_eq!(bitrate_label(12_500), "12.5 Mbps");
|
||||
assert_eq!(bitrate_label(1_000_000), "1 Gbps");
|
||||
assert_eq!(bitrate_label(1_500_000), "1.5 Gbps");
|
||||
assert_eq!(bitrate_label(2_000_000), "2 Gbps");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
@@ -2009,6 +2413,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2060,6 +2465,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2105,6 +2511,10 @@ pub(super) mod tests {
|
||||
RowId::Sc2Passthrough,
|
||||
RowId::DsCapture,
|
||||
RowId::Controllers,
|
||||
// Between the Input tab's rows and the rest of Interface: this one sits under
|
||||
// Reduce motion, which is earlier in that tab than the console-UI switch.
|
||||
RowId::ReduceUiResolution,
|
||||
RowId::GamepadUi,
|
||||
RowId::GamepadUiMode,
|
||||
RowId::Licenses,
|
||||
]
|
||||
@@ -2147,6 +2557,9 @@ pub(super) mod tests {
|
||||
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
|
||||
"always"
|
||||
);
|
||||
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
|
||||
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
|
||||
// Only `extra` moved.
|
||||
let mut after = ctx.settings.clone();
|
||||
after.extra = before.extra.clone();
|
||||
@@ -2154,6 +2567,34 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The console-off switch exists only where there is a fallback interface for "off"
|
||||
/// to land in, and the mode row under it only where the mode decides anything: not on
|
||||
/// a TV (always console, whatever the mode says) and not while the switch is off.
|
||||
#[test]
|
||||
fn console_off_switch_needs_a_fallback_ui() {
|
||||
with_ctx(|ctx| {
|
||||
ctx.platform = crate::platform::Platform::Android;
|
||||
// A TV: no off switch (it would strand the user), and no mode row either —
|
||||
// `gamepadUiActive`'s tv term satisfies the OR on its own.
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUi, ctx),
|
||||
"a TV offers no off switch"
|
||||
);
|
||||
assert!(!row_applies(RowId::GamepadUiMode, ctx));
|
||||
// A phone or tablet with the console on: both rows.
|
||||
ctx.fallback_ui = true;
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(row_applies(RowId::GamepadUiMode, ctx));
|
||||
// Switched off: the switch stays (it is the way back), the mode row goes.
|
||||
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
|
||||
assert!(row_applies(RowId::GamepadUi, ctx));
|
||||
assert!(
|
||||
!row_applies(RowId::GamepadUiMode, ctx),
|
||||
"the mode row decides nothing while the switch above it is off"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
@@ -2168,11 +2609,12 @@ pub(super) mod tests {
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
// 35 desktop rows + the ten Android-only ones (design android-skia-console-port.md
|
||||
// D3): eight `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 45, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::ReduceUiResolution));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
@@ -2209,6 +2651,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2250,6 +2693,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2295,6 +2739,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
@@ -2373,6 +2818,7 @@ pub(super) mod tests {
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
|
||||
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
|
||||
pub device_name: String,
|
||||
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
||||
pub deck: bool,
|
||||
/// Whether the host app has another interface to fall back to when the console is
|
||||
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
|
||||
/// on the settings screen; false where this console is the only UI there is (the
|
||||
/// desktop session, an Android TV), where offering "off" would strand the user.
|
||||
pub fallback_ui: bool,
|
||||
/// Where settings persist and the profile catalog comes from. `None` = the desktop
|
||||
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
|
||||
/// only store there is on Linux/Windows; every other host must supply one.
|
||||
@@ -203,6 +208,7 @@ impl ConsoleOptions {
|
||||
ConsoleOptions {
|
||||
device_name,
|
||||
deck,
|
||||
fallback_ui: false,
|
||||
store: None,
|
||||
platform: Platform::Desktop,
|
||||
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
|
||||
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
|
||||
hosts_gen: u64,
|
||||
device_name: String,
|
||||
deck: bool,
|
||||
/// See [`ConsoleOptions::fallback_ui`].
|
||||
fallback_ui: bool,
|
||||
pub(crate) in_stream: bool,
|
||||
connecting: Option<Connecting>,
|
||||
/// The last host title a connect was raised for, kept past the connect itself so
|
||||
@@ -352,6 +360,7 @@ impl Shell {
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
deck: opts.deck,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
in_stream: false,
|
||||
connecting: None,
|
||||
last_connect_title: None,
|
||||
@@ -888,6 +897,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
@@ -951,6 +961,10 @@ impl Shell {
|
||||
// navigation but "open this tile's menu". Without this the context menu —
|
||||
// and with it the only way to copy a host's link — is pad-only.
|
||||
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
|
||||
// ▼ is the same kind of hint: a direction that steers nothing, because
|
||||
// the only screen publishing it is the home carousel, where down means
|
||||
// "open Settings". A finger must be able to press what it advertises.
|
||||
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
@@ -970,6 +984,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
@@ -990,8 +1005,20 @@ impl Shell {
|
||||
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
|
||||
use crate::input::Key as S;
|
||||
if self.editing() {
|
||||
let mut ctx = Ctx {
|
||||
hosts: &self.hosts,
|
||||
library: &self.library,
|
||||
settings: &mut self.settings,
|
||||
store: &*self.store,
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t: self.t0.elapsed().as_secs_f64(),
|
||||
};
|
||||
if let Some(top) = self.stack.last_mut() {
|
||||
if top.edit_key(key) {
|
||||
if top.edit_key(key, &mut ctx) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +163,19 @@ impl Shell {
|
||||
let bw = lead + tw + pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||
// BOUNDED to the pill. Unbounded, `save_layer` allocates an offscreen the size of
|
||||
// the whole SURFACE and composites it back — on a 4K TV that is a 33 MB render
|
||||
// target raised and torn down every frame, for four seconds, to fade a 34 dp pill
|
||||
// (and on a box whose whole Skia budget is 64 MB, it evicts real work to do it).
|
||||
//
|
||||
// Everything drawn inside is inside `rect`: the pill fill, `theme::panel`'s
|
||||
// hairline ON that rect, the kind mark centred in it, and text that ends a `pad_x`
|
||||
// short of its right edge. There is no blur to reach further, so the outset is
|
||||
// slack for the stroke rather than a computed reach — `screens::home` needs 36 k
|
||||
// for the same layer only because it wraps a σ = 10 k halo.
|
||||
let bounds = rect.with_outset((12.0 * k as f32, 12.0 * k as f32));
|
||||
canvas.save_layer_alpha_f(Some(bounds), alpha);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||
&fill(crate::theme::shade(0.6)),
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Shell {
|
||||
}
|
||||
None => dt,
|
||||
};
|
||||
// The shaped-paragraph cache's clock, before anything asks it to draw.
|
||||
fonts.begin_frame();
|
||||
self.sync();
|
||||
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
|
||||
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
|
||||
@@ -80,10 +82,14 @@ impl Shell {
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.chip = Some(pad.map_or_else(
|
||||
|| "No controller — keyboard works too".to_string(),
|
||||
str::to_owned,
|
||||
));
|
||||
// Compared before it is rebuilt: this string changes when someone plugs a controller
|
||||
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
|
||||
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
|
||||
// `PartialEq` in another crate to be worth the same treatment.)
|
||||
let chip = pad.unwrap_or("No controller — keyboard works too");
|
||||
if self.chip.as_deref() != Some(chip) {
|
||||
self.chip = Some(chip.to_owned());
|
||||
}
|
||||
|
||||
let (full_w, full_h) = (f64::from(viewport.width), f64::from(viewport.height));
|
||||
let ins = viewport.insets;
|
||||
@@ -172,6 +178,7 @@ impl Shell {
|
||||
platform: self.platform,
|
||||
pads: &self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: &self.device_name,
|
||||
t,
|
||||
glyphs: self.glyphs,
|
||||
@@ -331,6 +338,8 @@ struct LayerEnv<'a> {
|
||||
platform: crate::platform::Platform,
|
||||
pads: &'a [PadInfo],
|
||||
deck: bool,
|
||||
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
|
||||
fallback_ui: bool,
|
||||
device_name: &'a str,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
@@ -350,7 +359,26 @@ impl LayerEnv<'_> {
|
||||
scale: f64,
|
||||
) -> Vec<(crate::glyphs::HintKey, Rect)> {
|
||||
let canvas = self.canvas;
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
// Only RAISE the layer when it carries something. A settled screen is painted at full
|
||||
// alpha, unscaled and unslid, and an unbounded `save_layer` allocates an offscreen the
|
||||
// size of the whole SURFACE and composites it back — so the console was paying for one
|
||||
// full-screen offscreen on every frame it sat still, to apply an alpha of 1. Skia does
|
||||
// not elide it either: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
|
||||
// `saveLayer(bounds, nullptr)`, whose only early-out is an empty clip.
|
||||
//
|
||||
// Dropping the layer is pixel-identical rather than merely close: nothing in this crate
|
||||
// draws with a blend mode other than `SrcOver`, and `SrcOver` is associative, so
|
||||
// compositing the draws into a transparent layer and then over the backdrop lands on
|
||||
// exactly the value drawing them straight onto the backdrop does. (It is also why the
|
||||
// text stays grayscale-AA — no LCD subpixel text to gain or lose an isolation.) Same
|
||||
// reasoning `screens::home` already bounds its per-tile layer by.
|
||||
let layered = alpha < 0.999 || (scale - 1.0).abs() > 0.001 || dy.abs() > 0.001;
|
||||
if layered {
|
||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||
} else {
|
||||
// Still a save: the transform below is undone by the same `restore`.
|
||||
canvas.save();
|
||||
}
|
||||
canvas.translate((0.0, dy as f32));
|
||||
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
|
||||
canvas.translate((cx, cy));
|
||||
@@ -365,6 +393,7 @@ impl LayerEnv<'_> {
|
||||
platform: self.platform,
|
||||
pads: self.pads,
|
||||
deck: self.deck,
|
||||
fallback_ui: self.fallback_ui,
|
||||
device_name: self.device_name,
|
||||
t: self.t,
|
||||
};
|
||||
|
||||
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
|
||||
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
|
||||
finish_motion(&mut s);
|
||||
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
|
||||
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
|
||||
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
|
||||
// quietly landing this test's Confirm on "Forget".
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
|
||||
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, …] — Edit is three
|
||||
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
|
||||
// instead of quietly landing this test's Confirm on something destructive.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
|
||||
FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
|
||||
TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::{
|
||||
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// --- Paint ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -396,9 +399,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
|
||||
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
|
||||
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
|
||||
let r = ((corner - 0.5) * k).max(0.0);
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
|
||||
}
|
||||
|
||||
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
|
||||
/// rect AND the corner radius take it — see the draw there.
|
||||
const HALO_OUTSET: f32 = 4.0;
|
||||
|
||||
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
|
||||
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
|
||||
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
|
||||
@@ -439,8 +449,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
|
||||
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
|
||||
// farthest-first the focused card's corona landed on top of its neighbours — which is
|
||||
// what made every card look like it was glowing.
|
||||
let spread = rect.with_outset((4.0 * k, 4.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
|
||||
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
|
||||
// original's only if its radius grows by `d` too (the two arcs then share a centre).
|
||||
// Reusing the card's own radius left the halo squarer than the card it sits under, so
|
||||
// it read as a misaligned outline at the four corners and a clean glow along the edges.
|
||||
let r = (corner + HALO_OUTSET) * k;
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
@@ -509,7 +524,7 @@ pub(crate) const EDGE_INSET: f64 = 24.0;
|
||||
// --- Typography ---------------------------------------------------------------------------
|
||||
|
||||
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum W {
|
||||
Regular,
|
||||
Medium,
|
||||
@@ -526,6 +541,111 @@ pub(crate) struct Fonts {
|
||||
semibold: Typeface,
|
||||
bold: Typeface,
|
||||
collection: FontCollection,
|
||||
/// Shaped paragraphs, keyed by everything that shapes one ([`ParaKey`]).
|
||||
///
|
||||
/// `Paragraph::layout` runs the whole shaper — HarfBuzz, line breaking, font fallback —
|
||||
/// and the shell re-built every paragraph on screen from scratch EVERY frame, which on a
|
||||
/// TV box is the largest CPU cost in the frame. Position is deliberately not part of the
|
||||
/// key (`paint` takes it), so one shaped paragraph serves a string wherever it moves to:
|
||||
/// a scrolling shelf and a screen transition both re-use it rather than re-shaping.
|
||||
///
|
||||
/// `RefCell` because every draw path here takes `&self` and the console's shell is
|
||||
/// single-threaded by construction (one render thread owns it on all three ABIs).
|
||||
paragraphs: RefCell<HashMap<ParaKey, Cached>>,
|
||||
/// The frame counter [`Fonts::begin_frame`] bumps — the cache's liveness clock.
|
||||
frame: Cell<u64>,
|
||||
}
|
||||
|
||||
/// The three paragraph shapes the console draws. A single tag rather than a loose
|
||||
/// `(TextAlign, Option<usize>)` pair because it is half of a hash key, and because those two
|
||||
/// were never independent — every call site picks one of these three.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Para {
|
||||
/// Centred, wrapping freely.
|
||||
Centered,
|
||||
/// Left-aligned, wrapping freely.
|
||||
Leading,
|
||||
/// Left-aligned, clamped to one ellipsized line.
|
||||
Heading,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
/// The paragraph style this shape asks for: alignment, and the line clamp if it has one.
|
||||
fn style(self) -> (TextAlign, Option<usize>) {
|
||||
match self {
|
||||
Para::Centered => (TextAlign::Center, None),
|
||||
Para::Leading => (TextAlign::Left, None),
|
||||
Para::Heading => (TextAlign::Left, Some(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`shape`] bakes into a laid-out `Paragraph` — change any of it and the shaped
|
||||
/// result differs, so all of it is in the key.
|
||||
///
|
||||
/// The floats ride as bits: the sizes and widths are all `k`-scaled, so they are never whole
|
||||
/// numbers, and `f64`/`f32` are not `Hash`. Bit equality is the right test anyway — the same
|
||||
/// `k` produces the same bits, and a different `k` must re-shape.
|
||||
#[derive(PartialEq, Eq, Hash)]
|
||||
struct ParaKey {
|
||||
text: String,
|
||||
kind: Para,
|
||||
weight: W,
|
||||
size: u64,
|
||||
max_w: u32,
|
||||
/// ARGB, as `[a, r, g, b]`.
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
/// One shaped paragraph and the frame it was last drawn on.
|
||||
struct Cached {
|
||||
para: Paragraph,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
/// How many shaped paragraphs stay resident before the cold ones are dropped. A screen draws
|
||||
/// well under this; the ceiling exists for the library, where paging a large catalogue walks
|
||||
/// through thousands of titles and every one of them would otherwise be kept forever.
|
||||
const PARA_CACHE_MAX: usize = 512;
|
||||
|
||||
/// Build and lay out one paragraph — the shaping [`Fonts::draw_paragraph`]'s cache exists to
|
||||
/// do exactly once per distinct key.
|
||||
///
|
||||
/// A free function rather than a method because the cache hands it a `&ParaKey` borrowed out
|
||||
/// of the map it is inserting into, which rules out holding `&self` across the call.
|
||||
fn shape(collection: &FontCollection, key: &ParaKey) -> Paragraph {
|
||||
let (align, clamp) = key.kind.style();
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(f64::from_bits(key.size) as f32);
|
||||
let [a, r, g, b] = key.color;
|
||||
ts.set_color(skia_safe::Color::from_argb(a, r, g, b));
|
||||
ts.set_font_style(match key.weight {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut builder = ParagraphBuilder::new(&style, collection.clone());
|
||||
builder.add_text(&key.text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::from_bits(key.max_w));
|
||||
p
|
||||
}
|
||||
|
||||
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
|
||||
@@ -562,6 +682,8 @@ pub(crate) fn build_fonts() -> Result<Fonts> {
|
||||
semibold,
|
||||
bold,
|
||||
collection,
|
||||
paragraphs: RefCell::new(HashMap::new()),
|
||||
frame: Cell::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -629,50 +751,59 @@ impl Fonts {
|
||||
}
|
||||
}
|
||||
|
||||
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
|
||||
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
|
||||
/// screen's content, which is why both other clients pin theirs to one line.
|
||||
/// Start a frame — the paragraph cache's clock. Anything not drawn on this frame or the
|
||||
/// one before it becomes a candidate for eviction, so the live set is exactly "what the
|
||||
/// last two frames drew". The shell calls this once per `render_in`.
|
||||
pub(crate) fn begin_frame(&self) {
|
||||
self.frame.set(self.frame.get().wrapping_add(1));
|
||||
}
|
||||
|
||||
/// Draw a shaped paragraph, building and laying it out only the first time this exact
|
||||
/// (text, shape, weight, size, width, colour) is asked for — see [`Fonts::paragraphs`].
|
||||
/// `at` is the paragraph's TOP-LEFT, and is deliberately not part of the key.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paragraph(
|
||||
fn draw_paragraph(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
kind: Para,
|
||||
w: W,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
align: TextAlign,
|
||||
max_w: f64,
|
||||
clamp: Option<usize>,
|
||||
) -> skia_safe::textlayout::Paragraph {
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(size as f32);
|
||||
ts.set_color(color.to_color());
|
||||
ts.set_font_style(match w {
|
||||
W::Regular => FontStyle::normal(),
|
||||
W::Medium => FontStyle::new(
|
||||
skia_safe::font_style::Weight::MEDIUM,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::SemiBold => FontStyle::new(
|
||||
skia_safe::font_style::Weight::SEMI_BOLD,
|
||||
skia_safe::font_style::Width::NORMAL,
|
||||
skia_safe::font_style::Slant::Upright,
|
||||
),
|
||||
W::Bold => FontStyle::bold(),
|
||||
at: Point,
|
||||
) {
|
||||
let frame = self.frame.get();
|
||||
// ponytail: the key owns its text, so a HIT still costs one small `String` allocation
|
||||
// where a borrowed-key lookup would cost none. Deliberate — it is a rounding error
|
||||
// against the shape it replaces, and the alternatives (hash-only keys, `hashbrown`'s
|
||||
// raw entry) trade a real collision risk or a dependency for it. Revisit only if a
|
||||
// profile ever puts this line on the board.
|
||||
let key = ParaKey {
|
||||
text: text.to_owned(),
|
||||
kind,
|
||||
weight: w,
|
||||
size: size.to_bits(),
|
||||
max_w: (max_w as f32).to_bits(),
|
||||
color: {
|
||||
// The 8-bit ARGB the paragraph actually bakes, not the `Color4f` it came
|
||||
// from — two float colours that round to the same pixel share an entry.
|
||||
let c = color.to_color();
|
||||
[c.a(), c.r(), c.g(), c.b()]
|
||||
},
|
||||
};
|
||||
let mut cache = self.paragraphs.borrow_mut();
|
||||
let entry = cache.entry(key).or_insert_with_key(|k| Cached {
|
||||
para: shape(&self.collection, k),
|
||||
used: frame,
|
||||
});
|
||||
style.set_text_style(&ts);
|
||||
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
|
||||
b.add_text(text);
|
||||
let mut p = b.build();
|
||||
p.layout(max_w as f32);
|
||||
p
|
||||
entry.used = frame;
|
||||
entry.para.paint(canvas, at);
|
||||
// Drop what the last two frames did not draw. Every entry still on screen is
|
||||
// re-stamped above on the frame it appears in, so this only reaps strings that left.
|
||||
if cache.len() > PARA_CACHE_MAX {
|
||||
cache.retain(|_, c| c.used + 1 >= frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
|
||||
@@ -688,8 +819,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
let at = Point::new((cx - max_w / 2.0) as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Centered, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
|
||||
@@ -707,8 +838,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Leading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
|
||||
@@ -731,8 +862,8 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
let at = Point::new(x as f32, y as f32);
|
||||
self.draw_paragraph(canvas, text, Para::Heading, w, size, color, max_w, at);
|
||||
}
|
||||
|
||||
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
|
||||
@@ -758,8 +889,12 @@ impl Fonts {
|
||||
let ell_w = font.measure_str(ell, None).0;
|
||||
let mut fitted = String::new();
|
||||
let mut used = 0.0f32;
|
||||
// The char goes onto the stack to be measured, not into a fresh `String` per character:
|
||||
// this runs for every over-long title on screen, every frame, and the allocation was
|
||||
// the bulk of it. `encode_utf8` writes the same bytes `to_string` would have.
|
||||
let mut buf = [0u8; 4];
|
||||
for ch in text.chars() {
|
||||
let cw = font.measure_str(ch.to_string().as_str(), None).0;
|
||||
let cw = font.measure_str(&*ch.encode_utf8(&mut buf), None).0;
|
||||
if used + cw + ell_w > max_w as f32 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const SCROLL_HORIZONTAL: u32 = 1;
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
/// as `pf_vdisplay`'s `panel_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
|
||||
@@ -867,14 +867,25 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
// DPMS control of the box's own physical panels — how a gamescope session (which owns no output on
|
||||
// the box's desktop) honors `Topology::Exclusive`. Dispatches per desktop: KDE over
|
||||
// org_kde_kwin_dpms, sway and Hyprland over their own IPC, and `drm_dpms` for a box with no
|
||||
// desktop at all. GNOME is the one it cannot serve — Mutter exposes no DPMS to clients.
|
||||
// The desktop's outputs can't be *disabled* the way the desktop backends do it (KWin refuses zero
|
||||
// enabled outputs, and no output there is ours to keep), so DPMS-off is the honest translation:
|
||||
// the desk is untouched, the panels just go dark. Refcounted across concurrent spawns; consumed by
|
||||
// `gamescope` on both its owning routes, best-effort throughout.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
#[path = "vdisplay/linux/panel_dpms.rs"]
|
||||
mod panel_dpms;
|
||||
|
||||
// The compositor-independent half of the same policy: turn the CRTCs off over DRM directly, for a
|
||||
// box with no desktop to ask (Game Mode runs gamescope and no KWin, and is exactly where the
|
||||
// operator's TV is lit by the box itself). Reached from `panel_dpms`'s "not KDE" arm, which is what
|
||||
// owns the refcount and the hold.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/drm_dpms.rs"]
|
||||
mod drm_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Compositor-independent panel darkening over DRM — how a box with **no desktop compositor**
|
||||
//! honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! [`crate::panel_dpms`] asks KWin to turn the panels off, which is the right answer whenever there
|
||||
//! is a KDE desktop to ask. There often isn't. A box sitting in **Game Mode** runs gamescope and no
|
||||
//! KWin at all, so that path declines — and Game Mode is precisely the deployment where the
|
||||
//! operator's TV is lit by the box itself. Measured on the Nobara VM (2026-08-24): after the
|
||||
//! takeover idles the box's gaming session, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On`
|
||||
//! indefinitely. Nothing blanks on its own — when no client holds DRM master the kernel simply
|
||||
//! keeps the CRTC configured, and fbcon owns it.
|
||||
//!
|
||||
//! So ask the kernel directly. The sequence, all of it measured on that box:
|
||||
//!
|
||||
//! 1. `open("/dev/dri/cardN")` — permitted for the ordinary session user, because logind puts a
|
||||
//! **uaccess ACL** on the node for whoever holds the active seat (`crw-rw----+`). No root, no
|
||||
//! polkit, no group: this is the same access every local compositor gets.
|
||||
//! 2. `DRM_IOCTL_SET_MASTER` — succeeds while no one else is master, which is exactly the state the
|
||||
//! takeover has just produced by idling the box's session. If it FAILS, someone else is driving
|
||||
//! that card (a live compositor, a foreign gamescope) and we decline: darkening a panel out from
|
||||
//! under its owner is not ours to do, and on the Attach route it would darken the very picture
|
||||
//! being streamed.
|
||||
//! 3. `DRM_IOCTL_MODE_GETRESOURCES` (count pass, then data pass) for the CRTC ids, and
|
||||
//! `DRM_IOCTL_MODE_SETCRTC` with `fb_id = 0, mode_valid = 0, count_connectors = 0` on each one
|
||||
//! that is actually driving something. That is a modeset to "off": the connector goes
|
||||
//! `enabled=disabled dpms=Off`, which is the same end state `kscreen-doctor --dpms off` reaches
|
||||
//! through KWin.
|
||||
//! 4. `DRM_IOCTL_DROP_MASTER`, and **keep the fd open**.
|
||||
//!
|
||||
//! Step 4 is the part worth reading twice. The darkness **survives dropping master** (measured), so
|
||||
//! we hand mastering rights straight back — the box's own gamescope must be able to take the card
|
||||
//! when the restore relaunches its session, and a host still holding master would starve it. What
|
||||
//! holds the panel dark is the open fd, not the mastership.
|
||||
//!
|
||||
//! **The re-light is `close(fd)`, and that is the whole of it.** The kernel's last-close handling
|
||||
//! restores the console and the panel comes back lit (measured: `enabled=enabled dpms=On` within
|
||||
//! 2 s of the close). There is no saved mode to replay and no restore that can half-fail — which
|
||||
//! also means **crash safety comes free**, the same property [`crate::panel_dpms`] gets from DPMS
|
||||
//! being non-persistent: a host that dies holding this has its fds closed by the kernel, and the
|
||||
//! box lights up. Nothing to journal, nothing to sweep at startup. (Contrast the Windows
|
||||
//! `pnp_disable_monitors` path, which needs a recovery journal precisely because its disable
|
||||
//! survives everything.)
|
||||
//!
|
||||
//! Best-effort throughout, like every other arm of this policy: a box with no `/dev/dri` at all, a
|
||||
//! card whose master is held by someone else, or a card with nothing lit simply contributes
|
||||
//! nothing and the stream proceeds.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
// ---------------------------------------------------------------- the kernel ABI
|
||||
//
|
||||
// `include/uapi/drm/drm.h` and `drm_mode.h`. Hand-declared rather than pulled from a crate: this is
|
||||
// four ioctls and three plain-old-data structs, and the const asserts below pin every layout that
|
||||
// could drift. `_IO('d', nr)` / `_IOWR('d', nr, T)` encoded by hand — the sizes are in the names.
|
||||
|
||||
/// `DRM_IOCTL_SET_MASTER` — `_IO('d', 0x1e)`.
|
||||
const DRM_IOCTL_SET_MASTER: libc::c_ulong = 0x641e;
|
||||
/// `DRM_IOCTL_DROP_MASTER` — `_IO('d', 0x1f)`.
|
||||
const DRM_IOCTL_DROP_MASTER: libc::c_ulong = 0x641f;
|
||||
/// `DRM_IOCTL_MODE_GETRESOURCES` — `_IOWR('d', 0xA0, drm_mode_card_res)`, 64-byte payload.
|
||||
const DRM_IOCTL_MODE_GETRESOURCES: libc::c_ulong = 0xC040_64A0;
|
||||
/// `DRM_IOCTL_MODE_GETCRTC` — `_IOWR('d', 0xA1, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_GETCRTC: libc::c_ulong = 0xC068_64A1;
|
||||
/// `DRM_IOCTL_MODE_SETCRTC` — `_IOWR('d', 0xA2, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_SETCRTC: libc::c_ulong = 0xC068_64A2;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct DrmModeCardRes {
|
||||
fb_id_ptr: u64,
|
||||
crtc_id_ptr: u64,
|
||||
connector_id_ptr: u64,
|
||||
encoder_id_ptr: u64,
|
||||
count_fbs: u32,
|
||||
count_crtcs: u32,
|
||||
count_connectors: u32,
|
||||
count_encoders: u32,
|
||||
min_width: u32,
|
||||
max_width: u32,
|
||||
min_height: u32,
|
||||
max_height: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeModeinfo {
|
||||
clock: u32,
|
||||
hdisplay: u16,
|
||||
hsync_start: u16,
|
||||
hsync_end: u16,
|
||||
htotal: u16,
|
||||
hskew: u16,
|
||||
vdisplay: u16,
|
||||
vsync_start: u16,
|
||||
vsync_end: u16,
|
||||
vtotal: u16,
|
||||
vscan: u16,
|
||||
vrefresh: u32,
|
||||
flags: u32,
|
||||
type_: u32,
|
||||
name: [u8; 32],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeCrtc {
|
||||
set_connectors_ptr: u64,
|
||||
count_connectors: u32,
|
||||
crtc_id: u32,
|
||||
fb_id: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
gamma_size: u32,
|
||||
mode_valid: u32,
|
||||
mode: DrmModeModeinfo,
|
||||
}
|
||||
|
||||
// The ioctl numbers above encode their payload size (0x40 = 64, 0x68 = 104). If a struct here ever
|
||||
// disagrees with that, the kernel reads or writes the wrong number of bytes — so pin it at compile
|
||||
// time rather than discovering it as a corrupted modeset on someone's TV.
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCardRes>() == 0x40);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeModeinfo>() == 68);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCrtc>() == 0x68);
|
||||
|
||||
impl Default for DrmModeCrtc {
|
||||
fn default() -> Self {
|
||||
// SAFETY: both structs are `repr(C)` plain old data — integers and a `[u8; 32]`, no
|
||||
// padding invariants, no pointers that must be valid, and no `Drop`. An all-zero value is
|
||||
// a legal instance, and is exactly what the ioctls want for "no connectors, no mode".
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One card we have darkened: the open fd is the hold. Dropping this closes it, and the kernel
|
||||
/// re-lights — see the module docs.
|
||||
pub struct DrmDarken {
|
||||
/// Kept solely for its `Drop`. The panel stays dark exactly as long as these are open.
|
||||
_cards: Vec<File>,
|
||||
/// Which `/dev/dri/cardN` we actually turned something off on — logging only.
|
||||
pub darkened: Vec<String>,
|
||||
}
|
||||
|
||||
/// `ioctl(fd, req, &mut arg)` for the modeset structs, returning the raw `errno` on failure.
|
||||
///
|
||||
/// Split out so each call site is one line and there is exactly one `unsafe` block to justify
|
||||
/// instead of five near-identical ones.
|
||||
fn ioctl<T>(fd: libc::c_int, req: libc::c_ulong, arg: &mut T) -> std::io::Result<()> {
|
||||
// SAFETY: `fd` is an open DRM node owned by the caller for the whole call; `req` is one of the
|
||||
// five `_IO`/`_IOWR` codes declared above, each paired with the `T` its size field names (the
|
||||
// const asserts pin that); and `arg` is a live, uniquely-borrowed, `repr(C)` value of that
|
||||
// exact type, so the kernel's read/write of `size_of::<T>()` bytes stays inside it.
|
||||
let rc = unsafe { libc::ioctl(fd, req, arg as *mut T) };
|
||||
if rc < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn every lit CRTC on every DRM card off, and hold them off. `None` when nothing was darkened
|
||||
/// — no cards, none masterable, or none lit — and therefore nothing to restore.
|
||||
pub fn darken() -> Option<DrmDarken> {
|
||||
let mut cards = Vec::new();
|
||||
let mut darkened = Vec::new();
|
||||
for entry in std::fs::read_dir("/dev/dri").ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
// `cardN` only: `renderD*` is the render node (no modesetting at all) and `by-path/` is a
|
||||
// directory of symlinks to the same nodes.
|
||||
if !name.starts_with("card") {
|
||||
continue;
|
||||
}
|
||||
match darken_card(&path) {
|
||||
// Masterable, but nothing on this card was lit. Its fd is dropped here, which is
|
||||
// correct: we changed nothing, so there is nothing to hold.
|
||||
Ok((_, 0)) => {}
|
||||
Ok((card, n)) => {
|
||||
tracing::debug!(card = name, crtcs = n, "DRM: CRTCs off");
|
||||
darkened.push(name.to_string());
|
||||
// ⚠ HOLD THE FD THAT DID THE WORK. Closing it and re-opening does not survive the
|
||||
// round trip: the close is the kernel's LAST close on that device, which restores
|
||||
// the console and re-lights the panel — the fresh fd then holds nothing. Measured
|
||||
// on the Nobara VM 2026-08-24, where exactly that shape reported `darkened
|
||||
// cards: ["card0"]` while the connector sat at `enabled=enabled dpms=On`.
|
||||
cards.push(card);
|
||||
}
|
||||
Err(why) => tracing::debug!(card = name, %why, "DRM: not ours to darken"),
|
||||
}
|
||||
}
|
||||
if darkened.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DrmDarken {
|
||||
_cards: cards,
|
||||
darkened,
|
||||
})
|
||||
}
|
||||
|
||||
/// Darken one card, returning the open fd **and** how many CRTCs were actually turned off.
|
||||
///
|
||||
/// The fd comes back with the count because the caller MUST keep this exact one to hold the panel
|
||||
/// dark: closing it is the kernel's last close on the device, which restores the console. A card
|
||||
/// that reports 0 can have its fd dropped freely — nothing was changed to undo.
|
||||
fn darken_card(path: &Path) -> std::io::Result<(File, usize)> {
|
||||
let card = File::options().read(true).write(true).open(path)?;
|
||||
let fd = card.as_raw_fd();
|
||||
// Someone else driving this card (a live compositor, a foreign gamescope) ⇒ not ours. This is
|
||||
// also what keeps the Attach route honest without needing to know about it here.
|
||||
ioctl(fd, DRM_IOCTL_SET_MASTER, &mut 0u64)?;
|
||||
|
||||
// Count pass: every pointer NULL, the kernel fills in the counts.
|
||||
let mut res = DrmModeCardRes::default();
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
let n = res.count_crtcs as usize;
|
||||
if n == 0 {
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
return Ok((card, 0));
|
||||
}
|
||||
// Data pass: hand back a buffer sized by that count and ask again.
|
||||
let mut ids = vec![0u32; n];
|
||||
let mut res = DrmModeCardRes {
|
||||
crtc_id_ptr: ids.as_mut_ptr() as u64,
|
||||
count_crtcs: n as u32,
|
||||
..Default::default()
|
||||
};
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
// The kernel may report FEWER than the count pass promised (a hotplug between the two); it
|
||||
// never reports more than the buffer we sized, so trust the second count.
|
||||
ids.truncate(res.count_crtcs as usize);
|
||||
|
||||
let mut off = 0usize;
|
||||
for id in ids {
|
||||
let mut crtc = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_GETCRTC, &mut crtc).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Only touch a CRTC that is actually driving a display. Disabling an already-dark one is a
|
||||
// harmless no-op, but counting it would make the log claim a panel went off that never was
|
||||
// on — and that verdict is the whole point of reporting a count at all.
|
||||
if crtc.mode_valid == 0 && crtc.fb_id == 0 {
|
||||
continue;
|
||||
}
|
||||
// The modeset to "off": no framebuffer, no mode, no connectors.
|
||||
let mut disable = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_SETCRTC, &mut disable).is_ok() {
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
// Hand mastering back immediately: the darkness does not depend on holding it (measured), and
|
||||
// the box's own gamescope needs to be able to take this card when the restore relaunches its
|
||||
// session. Keeping it would turn a dark panel into a session that cannot start.
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
Ok((card, off))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DrmModeCardRes, DrmModeCrtc, DrmModeModeinfo};
|
||||
|
||||
/// The layouts the ioctl numbers encode. The `const` asserts above already fail the BUILD on
|
||||
/// drift; this restates them as a test so the reason is greppable from a failure, and pins the
|
||||
/// two field offsets the count/data-pass dance actually depends on.
|
||||
#[test]
|
||||
fn the_abi_structs_match_the_ioctl_payload_sizes() {
|
||||
assert_eq!(std::mem::size_of::<DrmModeCardRes>(), 0x40, "_IOWR 0x40");
|
||||
assert_eq!(std::mem::size_of::<DrmModeModeinfo>(), 68);
|
||||
assert_eq!(std::mem::size_of::<DrmModeCrtc>(), 0x68, "_IOWR 0x68");
|
||||
// `crtc_id_ptr` is the second u64 — the field the data pass points at its id buffer. A
|
||||
// reorder here would hand the kernel the framebuffer-id pointer instead.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, crtc_id_ptr), 8);
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, count_crtcs), 36);
|
||||
// `mode` must sit right after the seven u32s, or SETCRTC reads a mode we never wrote.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCrtc, mode), 36);
|
||||
}
|
||||
|
||||
/// ON GLASS. Darken this box's panels for real and read the verdict back out of sysfs.
|
||||
///
|
||||
/// Run it on a box with a **connected head and no compositor holding the card** — i.e. exactly
|
||||
/// the takeover state this module exists for. On the Nobara VM:
|
||||
///
|
||||
/// ```sh
|
||||
/// # idle the box's gaming session first (what stop_autologin_sessions does), then:
|
||||
/// ./pf_vdisplay-<hash> --ignored --nocapture drm_dpms
|
||||
/// ```
|
||||
///
|
||||
/// Skips itself (rather than failing) when nothing was ours to darken, because that is the
|
||||
/// honest outcome on a dev box with a live desktop — the card is already mastered.
|
||||
#[test]
|
||||
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
|
||||
fn live_the_panels_go_dark_and_come_back() {
|
||||
fn connectors() -> Vec<(String, String, String)> {
|
||||
let mut v = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
|
||||
return v;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let rd = |f: &str| {
|
||||
std::fs::read_to_string(p.join(f))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if rd("status") == "connected" {
|
||||
v.push((
|
||||
e.file_name().to_string_lossy().into_owned(),
|
||||
rd("enabled"),
|
||||
rd("dpms"),
|
||||
));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
let before = connectors();
|
||||
println!("before: {before:?}");
|
||||
assert!(
|
||||
!before.is_empty(),
|
||||
"no connected head — this test needs one to mean anything"
|
||||
);
|
||||
|
||||
let Some(hold) = super::darken() else {
|
||||
println!("nothing was ours to darken (card already mastered?) — skipping");
|
||||
return;
|
||||
};
|
||||
println!("darkened cards: {:?}", hold.darkened);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let during = connectors();
|
||||
println!("during: {during:?}");
|
||||
|
||||
drop(hold);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let after = connectors();
|
||||
println!("after: {after:?}");
|
||||
|
||||
// The claim: every head that was lit went dark, and every one of them came back.
|
||||
for (name, en, dpms) in &during {
|
||||
assert_eq!(dpms, "Off", "{name} should be DPMS-off while held ({en})");
|
||||
}
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"dropping the hold must restore exactly the state we found"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zeroed `DrmModeCrtc` IS the disable request — that is the only thing `Default` is for
|
||||
/// here, so a change that made it non-zero would silently stop disabling anything.
|
||||
#[test]
|
||||
fn the_default_crtc_is_the_disable_request() {
|
||||
let c = DrmModeCrtc::default();
|
||||
assert_eq!(c.fb_id, 0, "a framebuffer would keep the CRTC lit");
|
||||
assert_eq!(
|
||||
c.mode_valid, 0,
|
||||
"a valid mode would re-modeset, not disable"
|
||||
);
|
||||
assert_eq!(c.count_connectors, 0);
|
||||
assert_eq!(c.set_connectors_ptr, 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -483,7 +483,12 @@ fn gamescope_patch_level() -> u32 {
|
||||
cursor composited into the capture stream"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
// INFO, not DEBUG: this is the whole reason a box streams SDR, and the branch above
|
||||
// announces the good news at INFO. A field report ("HDR stopped working after the
|
||||
// update") cost a deep dive because the handshake's `capture_supports_hdr=false` was
|
||||
// visible at INFO while the ONE line saying why sat a level below it. Fires once per
|
||||
// process — the answer is cached in `LEVEL`.
|
||||
tracing::info!(
|
||||
bin = %gamescope_bin(),
|
||||
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
|
||||
with a host-composited cursor (install punktfunk-gamescope for HDR)"
|
||||
|
||||
@@ -539,6 +539,123 @@ fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> V
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *Hyprland backend's own* exclusive
|
||||
/// topology does, and deliberately so on this compositor above all: disabling a Hyprland head is
|
||||
/// the operation whose only known undo is re-reading the operator's whole config
|
||||
/// ([`restore_heads`]), dropping every runtime override they set by hand. DPMS is a separate axis
|
||||
/// — this module's own notes record `dispatch dpms on <name>` failing to re-enable a *disabled*
|
||||
/// head for exactly that reason — so off/on round-trips cleanly and touches nothing else.
|
||||
///
|
||||
/// A gamescope spawn owns no Hyprland output, hence the empty `ours`; a concurrent session's
|
||||
/// `HEADLESS-*` is still spared by [`heads_to_disable`]'s `managed` filter.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light undoes exactly those.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match dpms_one(&name, on) {
|
||||
// Only a head THIS call moved is recorded: one already in the wanted state was left
|
||||
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
|
||||
// changed would have the re-light toggle a head we never darkened.
|
||||
Ok(true) => changed.push(name),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"hyprland: could not DPMS this monitor for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
|
||||
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
|
||||
///
|
||||
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
|
||||
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
|
||||
/// [`dpms_one`] is built around.
|
||||
fn monitor_dpms(name: &str) -> Option<bool> {
|
||||
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
|
||||
.get("dpmsStatus")?
|
||||
.as_bool()
|
||||
}
|
||||
|
||||
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
|
||||
///
|
||||
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
|
||||
/// single most important fact in this function. It ignores the state word entirely:
|
||||
///
|
||||
/// ```text
|
||||
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
|
||||
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
|
||||
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
|
||||
/// ```
|
||||
///
|
||||
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
|
||||
/// the operator's screen left off after the stream, which is the failure this whole policy exists
|
||||
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
|
||||
/// config manager where the call really is a set, so it is not conditional on detecting which.
|
||||
///
|
||||
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
|
||||
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
|
||||
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
|
||||
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
|
||||
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
|
||||
/// Lua, and report both failures if neither lands.
|
||||
///
|
||||
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
|
||||
/// with a name it is at least addressed at the head we mean.
|
||||
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
|
||||
if monitor_dpms(name) == Some(want_on) {
|
||||
return Ok(false); // already where we want it — toggling would break it
|
||||
}
|
||||
let classic =
|
||||
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
|
||||
Ok(()) => None,
|
||||
Err(e) => {
|
||||
let lua = lua_dpms_expr(name, want_on);
|
||||
match hyprctl_dispatch(&["dispatch", &lua]) {
|
||||
Ok(()) => None,
|
||||
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(why) = classic {
|
||||
bail!("neither dispatch form was accepted for {name} — {why}");
|
||||
}
|
||||
// Verify, because a toggle that fired against a state we misread is worse than one that did
|
||||
// not fire at all.
|
||||
match monitor_dpms(name) {
|
||||
Some(now) if now == want_on => Ok(true),
|
||||
Some(now) => bail!(
|
||||
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
|
||||
wanted {want_on} (the dispatcher toggles — the readback disagreed with reality)"
|
||||
),
|
||||
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
|
||||
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
|
||||
/// wrong on that manager.
|
||||
fn lua_dpms_expr(name: &str, on: bool) -> String {
|
||||
format!(
|
||||
"hl.dsp.dpms(\"{}\", \"{name}\")",
|
||||
if on { "on" } else { "off" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
|
||||
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
|
||||
/// that screen, not the session.
|
||||
@@ -1388,6 +1505,22 @@ fn portal_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
|
||||
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
|
||||
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
|
||||
/// entire difference between working and silently doing nothing.
|
||||
#[test]
|
||||
fn the_lua_dpms_expression_quotes_both_arguments() {
|
||||
assert_eq!(
|
||||
lua_dpms_expr("HDMI-A-1", false),
|
||||
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
|
||||
);
|
||||
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
|
||||
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
|
||||
// which would flip a just-restored head back off.
|
||||
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_parses_release_and_dev_builds() {
|
||||
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
|
||||
|
||||
+172
-22
@@ -1,23 +1,54 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//! Turning the box's OWN physical panels off — how a gamescope session honors
|
||||
//! [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//! A gamescope session is its own compositor: nothing on either owning route (bare spawn, managed
|
||||
//! takeover) touches the desktop the box is showing, so the physical panel keeps displaying the
|
||||
//! (idle) desktop for the whole stream — while the same `exclusive` policy on a *desktop* backend
|
||||
//! turns the physicals off outright. That backend's mechanism is closed to us here: a compositor
|
||||
//! refuses an output configuration with ZERO enabled outputs, and a gamescope session has no
|
||||
//! output of its own on that desktop to leave enabled.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//! DPMS is the honest translation. The desk stays exactly where it is — no topology churn, no
|
||||
//! workspace moves, no window re-homing — the panels just go dark, and any LOCAL input wakes them,
|
||||
//! which is the right answer for a desktop someone can walk up to. Stream input never wakes them:
|
||||
//! it is injected into the nested gamescope's own EIS socket and never reaches the desktop.
|
||||
//!
|
||||
//! **There is no cross-compositor DPMS protocol**, so this module is a dispatcher. In order, each
|
||||
//! arm self-gating so a box only pays for the one that answers:
|
||||
//!
|
||||
//! | desktop | mechanism |
|
||||
//! |---|---|
|
||||
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
|
||||
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
|
||||
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
|
||||
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
|
||||
//! | GNOME / Mutter | **cannot be served** — see below |
|
||||
//!
|
||||
//! KDE is driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the
|
||||
//! same stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a
|
||||
//! separate libkscreen/KDED layer that can be wedged while KWin itself answers fine). sway and
|
||||
//! Hyprland are driven through their own native IPC, which is how [`crate::wlroots`] and
|
||||
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
|
||||
//! is warranted.
|
||||
//!
|
||||
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
|
||||
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
|
||||
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
|
||||
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
|
||||
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
|
||||
//! re-light moves exactly those and never a head it did not darken.
|
||||
//!
|
||||
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
|
||||
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
|
||||
//!
|
||||
//! ⚠ **GNOME is the one gap, and it is structural.** Mutter exposes no DPMS to clients at all, and
|
||||
//! its `exclusive` mechanism (an `ApplyMonitorsConfig` that omits the physicals) needs a virtual
|
||||
//! output of its own to keep enabled — which a gamescope session, being its own compositor, does
|
||||
//! not have. The DRM floor cannot cover it either: Mutter holds DRM master, so `SET_MASTER` is
|
||||
//! refused. [`darken`] says so at `warn!` rather than failing silently.
|
||||
//!
|
||||
//! This module owns the refcount and the hold for every arm — see [`Darkened`] for how each is
|
||||
//! undone.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
@@ -433,6 +464,16 @@ enum Darkened {
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
/// sway (wlroots) turned these outputs off — `swaymsg output <name> dpms off`. Addressed by
|
||||
/// connector name, so the re-light undoes exactly the heads we changed and never a sibling's.
|
||||
Sway(Vec<String>),
|
||||
/// Hyprland turned these monitors off — `hyprctl dispatch dpms off <name>`. Same per-name
|
||||
/// discipline as [`Darkened::Sway`], and the same reason.
|
||||
Hyprland(Vec<String>),
|
||||
/// No desktop to ask, so [`crate::drm_dpms`] turned the CRTCs off over DRM directly. The
|
||||
/// re-light is a `drop` — the hold IS a set of open `/dev/dri/cardN` fds, and the kernel
|
||||
/// re-lights on last close. Nothing to replay, and crash-safe for the same reason.
|
||||
Drm(crate::drm_dpms::DrmDarken),
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
@@ -505,9 +546,42 @@ pub fn release_stream_darken() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-KDE desktops we can ask, in preference order. Each self-gates on its own IPC being
|
||||
/// reachable — `wlroots::dpms_other_heads` shells out to `swaymsg`, which needs `SWAYSOCK`;
|
||||
/// Hyprland's needs `HYPRLAND_INSTANCE_SIGNATURE` — so a box only ever pays for the one that
|
||||
/// answers, and a box running neither falls straight through.
|
||||
///
|
||||
/// Both address heads BY NAME and report back the ones they actually changed, so the re-light
|
||||
/// undoes exactly those and never a concurrent session's headless output.
|
||||
///
|
||||
/// **GNOME is absent on purpose.** Mutter exposes no DPMS to clients at all, and its `exclusive`
|
||||
/// mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its own to
|
||||
/// keep enabled — which a gamescope spawn, being its own compositor, does not have. There is
|
||||
/// nothing to call; the `warn!` at the end of [`darken`] names it rather than failing silently.
|
||||
fn non_kde_desktop_darken() -> Option<Darkened> {
|
||||
let sway = crate::wlroots::dpms_other_heads(false);
|
||||
if !sway.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?sway,
|
||||
"sway: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Sway(sway));
|
||||
}
|
||||
let hypr = crate::hyprland::dpms_other_heads(false);
|
||||
if !hypr.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?hypr,
|
||||
"hyprland: desktop monitors off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Hyprland(hypr));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
/// wedged-compositor fallback, then the other desktops, then DRM. `None` = nothing was darkened
|
||||
/// (no desktop that answers, panels already off, or every arm declined) — and therefore nothing to
|
||||
/// restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
@@ -526,8 +600,49 @@ fn darken() -> Option<Darkened> {
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// the same KDE-only machinery). Declining is still right — but NOT quietly. [`darken`] is
|
||||
// only ever reached because the operator selected `Topology::Exclusive`, so every decline
|
||||
// here is "you asked for your screens off and they stayed on", which is a verdict and not
|
||||
// a routine state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel. Same
|
||||
// discipline as [`relight`], which has always said so when it gave up — a lit panel under
|
||||
// `exclusive` deserves the honesty a dark one already got.
|
||||
Err(e @ (OpenFailure::NoDpmsGlobal | OpenFailure::Connect(_))) => {
|
||||
// Not KDE. Try the other desktops we drive, then the compositor-independent floor.
|
||||
// Each arm self-gates on its own IPC being reachable, so the order is just preference
|
||||
// and a box only ever pays for the ones that answer.
|
||||
if let Some(d) = non_kde_desktop_darken() {
|
||||
return Some(d);
|
||||
}
|
||||
match crate::drm_dpms::darken() {
|
||||
Some(d) => {
|
||||
tracing::info!(
|
||||
cards = ?d.darkened,
|
||||
"DRM: the box's own CRTCs are off for the exclusive gamescope stream (no \
|
||||
desktop compositor to ask — a session in Game Mode has none)"
|
||||
);
|
||||
Some(Darkened::Drm(d))
|
||||
}
|
||||
// Nothing on this box was ours to darken: no desktop that answers, and then no
|
||||
// `/dev/dri` card that was ours either — every one already mastered by someone
|
||||
// else (a live compositor, including the gamescope an Attach route is mirroring,
|
||||
// which must NOT be darkened), or nothing lit. Say so: `darken` is only ever
|
||||
// reached because the operator selected `Topology::Exclusive`, so this is "you
|
||||
// asked for your screens off and they stayed on" — a verdict, not a routine
|
||||
// state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%e,
|
||||
"exclusive topology asked for the box's own screens to go dark: no \
|
||||
desktop compositor on this box could be asked (GNOME/Mutter exposes no \
|
||||
DPMS to clients), and no DRM card was ours to turn off either — the \
|
||||
panel stays as it is for this stream"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
@@ -594,6 +709,41 @@ fn relight(d: Darkened) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Per-name, so exactly the heads we darkened come back and a sibling's headless output is
|
||||
// never switched on by us. A head the operator unplugged meanwhile just fails its one
|
||||
// command and says so — the others still re-light.
|
||||
Darkened::Sway(outputs) => {
|
||||
let back = crate::wlroots::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"sway: could NOT re-light the desktop outputs — they stay dark until local \
|
||||
input or `swaymsg output '*' dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "sway: desktop outputs back on");
|
||||
}
|
||||
}
|
||||
Darkened::Hyprland(outputs) => {
|
||||
let back = crate::hyprland::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"hyprland: could NOT re-light the desktop monitors — they stay dark until \
|
||||
local input or `hyprctl dispatch dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "hyprland: desktop monitors back on");
|
||||
}
|
||||
}
|
||||
// The one arm that cannot fail: the hold IS the open fds, so dropping it closes them and
|
||||
// the kernel's last-close restores the console. No ioctl to be refused, no saved mode to
|
||||
// replay — which is why this path needs no "could NOT re-light" line of its own.
|
||||
Darkened::Drm(d) => {
|
||||
let cards = d.darkened.clone();
|
||||
drop(d);
|
||||
tracing::info!(?cards, "DRM: the box's own CRTCs released — panel back on");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,45 @@ fn disable_argv(name: &str) -> [&str; 3] {
|
||||
["output", name, "disable"]
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that DPMS-es `name` off or on. Same noun-first shape as [`disable_argv`],
|
||||
/// and a different axis from it: `dpms off` leaves the output enabled and configured (its
|
||||
/// workspaces do not move, no window is re-homed) and merely stops driving the panel.
|
||||
fn dpms_argv(name: &str, on: bool) -> [&str; 4] {
|
||||
["output", name, "dpms", if on { "on" } else { "off" }]
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *wlroots backend's own* exclusive
|
||||
/// topology does. A gamescope spawn is its own compositor and owns no sway output, so there is
|
||||
/// nothing here to promote to "the desk" and nothing to focus — and disabling the operator's
|
||||
/// outputs would move their workspaces around for a stream that is not even on this compositor.
|
||||
/// DPMS is the honest translation: the desk stays exactly as it is, the panels just go dark.
|
||||
///
|
||||
/// Reuses [`heads_to_disable`]'s filter with an empty `ours`, so a concurrent wlroots session's
|
||||
/// `HEADLESS-*` output is spared for the same reason it is there — blanking it would black out
|
||||
/// that client's stream.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light can undo exactly those. Best-effort per
|
||||
/// head, like its neighbour: one that refuses costs a lit screen, not the stream.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match swaymsg(&dpms_argv(&name, on)) {
|
||||
Ok(_) => changed.push(name),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"wlroots: could not DPMS this output for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
|
||||
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
|
||||
/// way the Hyprland twin's `reload` does.
|
||||
@@ -1060,6 +1099,30 @@ mod tests {
|
||||
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
|
||||
}
|
||||
|
||||
/// `dpms` is a different sway verb from `disable`, and the difference is the whole point of
|
||||
/// the gamescope arm: `disable` moves workspaces and re-homes windows on the operator's desk,
|
||||
/// `dpms off` leaves the desk alone and only stops driving the panel. Four tokens, not three —
|
||||
/// sway spells it `output <name> dpms on|off`.
|
||||
#[test]
|
||||
fn dpms_is_a_separate_verb_from_disable() {
|
||||
assert_eq!(dpms_argv("DP-1", false), ["output", "DP-1", "dpms", "off"]);
|
||||
assert_eq!(dpms_argv("DP-1", true), ["output", "DP-1", "dpms", "on"]);
|
||||
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
|
||||
}
|
||||
|
||||
/// The gamescope DPMS arm reuses the disable filter with an EMPTY `ours`: a gamescope spawn
|
||||
/// owns no sway output, so nothing of ours needs sparing — but a concurrent wlroots session's
|
||||
/// `HEADLESS-*` still must be, or darkening would black out that client's stream.
|
||||
#[test]
|
||||
fn the_gamescope_dpms_arm_still_spares_a_sibling_headless() {
|
||||
let heads = [
|
||||
head("DP-1", true),
|
||||
head("HEADLESS-1", true),
|
||||
head("DP-3", false),
|
||||
];
|
||||
assert_eq!(heads_to_disable(&heads, ""), vec!["DP-1"]);
|
||||
}
|
||||
|
||||
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
|
||||
/// restore is prepared and teardown touches nothing.
|
||||
#[test]
|
||||
|
||||
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
|
||||
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
|
||||
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
|
||||
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
|
||||
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
|
||||
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
|
||||
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
|
||||
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||||
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||||
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||||
//! encoder off a cliff the network could carry.
|
||||
//! encoder off a cliff the network could carry. It is also the one signal that can fire for a
|
||||
//! reason the rate cannot fix (contention on the host's GPU), so it stands itself down when
|
||||
//! backing off stops helping, and a later clean run re-probes it — see
|
||||
//! [`ENCODE_NOOP_BACKOFFS_TO_DISARM`].
|
||||
//!
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
@@ -126,10 +129,49 @@ const PROVEN_HEADROOM_DEN: u32 = 2;
|
||||
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||||
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||||
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||||
///
|
||||
/// A FRAME BUDGET, not a fixed duration — the two constants here are the 120 Hz values, used
|
||||
/// only until [`set_frame_budget`](BitrateController::set_frame_budget) supplies the session's
|
||||
/// own (see [`BitrateController::encode_thresholds`]). Left absolute they encode a 120 Hz
|
||||
/// assumption into every session: at 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode
|
||||
/// hiccup clears the SEVERE tier and takes the immediate ×0.7 where the same hiccup at 120 Hz
|
||||
/// (8.3 ms) does not even reach it. That asymmetry is a field report — a 1440p60 session ratcheted
|
||||
/// to the floor while 1440p120 sessions on the same host and client climbed to their shape ceiling.
|
||||
const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
/// queue is growing past the knee; skip the two-window confirmation. Frame-budget-scaled like
|
||||
/// [`ENCODE_RISE_US`].
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Consecutive encode-attributed backoffs that did NOT bring host encode time down before the
|
||||
/// encode down-driver is disarmed for the session.
|
||||
///
|
||||
/// The signal's whole premise is that encode time is a function of the rate the controller can
|
||||
/// actuate: it exists to find the encoder's compute knee, where cutting the rate cuts the work.
|
||||
/// When the rise comes from something else on the GPU — a game saturating the card, which is
|
||||
/// exactly when the host is also behind cadence — the premise is false. The backoff changes
|
||||
/// nothing, the signal fires again, and [`on_ack`](BitrateController::on_ack)'s baseline re-seed
|
||||
/// erases the evidence that nothing improved, so the controller ratchets to the floor pulling the
|
||||
/// one lever that cannot work (the field case: 57 → 5 Mbps over ten minutes with zero packet loss,
|
||||
/// zero keyframe asks and a flat decoder).
|
||||
///
|
||||
/// So: remember the level each encode-attributed backoff fired at, and when the next one fires no
|
||||
/// lower, count it. Two in a row means the rate is not what is driving encode time here — stop
|
||||
/// letting it drive. Same shape as the clock-flush detector's
|
||||
/// [`crate::client::frame_channel::NOOP_CLOCK_FLUSHES_TO_DISARM`]: a signal whose remedy is
|
||||
/// demonstrably doing nothing should stand down rather than repeat forever.
|
||||
///
|
||||
/// Two, not one: a single pair of backoffs at a similar level is also what a real knee looks like
|
||||
/// while the rate is still above it, and the knee is the case this signal was built for.
|
||||
///
|
||||
/// And a stand-down, never a permanent disarm. Nothing this controller learns from evidence is
|
||||
/// permanent — both caps re-probe, and the clock-flush detector was itself changed from "off for
|
||||
/// the session" to re-armable for exactly this reason. GPU contention is transient by nature (the
|
||||
/// game exits to a menu, the shader storm ends), while what it silences is the only signal that
|
||||
/// can descend when the encoder is past its knee on a link that shows nothing. So a clean run
|
||||
/// re-arms it on the [`CAP_REPROBE_WINDOWS_MIN`] ladder, doubling each time the silence is
|
||||
/// immediately re-earned. The loss, OWD, decode and keyframe signals keep their full power
|
||||
/// throughout, and the host's own climb refusal stays the backstop for a genuine knee.
|
||||
const ENCODE_NOOP_BACKOFFS_TO_DISARM: u32 = 2;
|
||||
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
|
||||
/// interval backs off to.
|
||||
///
|
||||
@@ -329,6 +371,30 @@ pub(crate) struct BitrateController {
|
||||
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||||
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||||
encode_means: VecDeque<i64>,
|
||||
/// This session's frame budget in µs (one refresh interval), the unit the encode thresholds
|
||||
/// are expressed in — see [`encode_thresholds`](Self::encode_thresholds). `None` = the mode
|
||||
/// was never plumbed in, and the 120 Hz constants stand exactly as before.
|
||||
frame_budget_us: Option<i64>,
|
||||
/// The window mean host-encode latency (µs) that drove the last encode-attributed backoff;
|
||||
/// `0` = none yet, or the streak was broken by a backoff something else drove.
|
||||
encode_backoff_us: i64,
|
||||
/// Consecutive encode-attributed backoffs after which encode time did NOT come down (see
|
||||
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]).
|
||||
encode_noop_backoffs: u32,
|
||||
/// The encode down-driver is stood down: its rises are not answering the rate, so they
|
||||
/// neither mark a window bad nor teach a baseline. Lifted by a clean run (see
|
||||
/// `encode_reprobe_after`) or a mode switch — never permanent, like every other piece of
|
||||
/// evidence-learned state here.
|
||||
encode_disarmed: bool,
|
||||
/// Clean windows since the stand-down, against `encode_reprobe_after`.
|
||||
encode_disarm_clean_windows: u32,
|
||||
/// Clean windows the stand-down must survive before the signal is re-armed. Doubles each
|
||||
/// time a re-armed signal is immediately silenced again, so a standing contention settles
|
||||
/// into a slow poll instead of thrashing ([`CAP_REPROBE_WINDOWS_MIN`]).
|
||||
encode_reprobe_after: u32,
|
||||
/// A stand-down has been lifted at least once, so the next one is re-silencing something the
|
||||
/// re-probe already tried — the trigger for backing that clock off.
|
||||
encode_rearmed: bool,
|
||||
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
@@ -427,6 +493,13 @@ impl BitrateController {
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
frame_budget_us: None,
|
||||
encode_backoff_us: 0,
|
||||
encode_noop_backoffs: 0,
|
||||
encode_disarmed: false,
|
||||
encode_disarm_clean_windows: 0,
|
||||
encode_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
encode_rearmed: false,
|
||||
host_cap_kbps: None,
|
||||
last_requested_kbps: None,
|
||||
short_ack_kbps: 0,
|
||||
@@ -485,6 +558,33 @@ impl BitrateController {
|
||||
self.stream_cap_kbps = Some(kbps);
|
||||
}
|
||||
|
||||
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
|
||||
/// FRAME BUDGETS rather than the 120 Hz durations they were calibrated at (see
|
||||
/// [`ENCODE_RISE_US`]). Ignored for a nonsense rate — the defaults are the old behavior, which
|
||||
/// is the right answer when the mode is not known.
|
||||
pub(crate) fn set_frame_budget(&mut self, refresh_hz: u32) {
|
||||
if refresh_hz > 0 {
|
||||
self.frame_budget_us = Some(1_000_000 / refresh_hz as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(rise, severe)` for the host-encode signal: half a frame budget and one and a half of
|
||||
/// them, the shape [`ENCODE_RISE_US`] documents, against this session's actual budget.
|
||||
///
|
||||
/// Scales with the SESSION REFRESH, not with the rate the source actually delivers. A game
|
||||
/// rendering below refresh stretches the real budget further still (the host stretches its own
|
||||
/// cadence deadline by exactly that, `cadence_budget`), so a sub-refresh source can still
|
||||
/// present a one-frame hiccup above the severe tier — that residue is what
|
||||
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`] is for. Deliberately not chased here: the client would
|
||||
/// have to infer the source period from arrival cadence, which is the same jitter the signal
|
||||
/// is trying to read through.
|
||||
fn encode_thresholds(&self) -> (i64, i64) {
|
||||
match self.frame_budget_us {
|
||||
Some(budget) => (budget / 2, budget * 3 / 2),
|
||||
None => (ENCODE_RISE_US, ENCODE_SEVERE_US),
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||
///
|
||||
@@ -603,6 +703,16 @@ impl BitrateController {
|
||||
self.owd_means.clear();
|
||||
self.decode_means.clear();
|
||||
self.encode_means.clear();
|
||||
// The encode down-driver's disarm is mode-scoped like everything else here: the new mode
|
||||
// is a different amount of encode work per frame, so a rate that could not move encode
|
||||
// time under the old one says nothing about this one. Re-arm and let it prove itself
|
||||
// again. (The caller re-sizes the frame budget for the new refresh alongside this.)
|
||||
self.encode_disarmed = false;
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
self.encode_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
self.encode_rearmed = false;
|
||||
self.proven_kbps = 0;
|
||||
}
|
||||
|
||||
@@ -684,11 +794,17 @@ impl BitrateController {
|
||||
// frame describe what reached the CLIENT, and they mean the same thing however little
|
||||
// flowed — the periodic-capture-stall case (see [`STARVED_DELIVERY_DIV`]) still backs off
|
||||
// on one window, as its tests require.
|
||||
//
|
||||
// Withheld the same way once the signal has DISARMED itself (see
|
||||
// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): a rise the rate has twice failed to answer is not
|
||||
// evidence about the rate, so it must neither mark a window bad nor teach a baseline.
|
||||
let (encode_rise_us, encode_severe_us) = self.encode_thresholds();
|
||||
let encode_usable = !starved && !self.encode_disarmed;
|
||||
let (encode_bad, encode_severe) = score_baseline(
|
||||
&mut self.encode_means,
|
||||
encode_mean_us.filter(|_| !starved),
|
||||
ENCODE_RISE_US,
|
||||
ENCODE_SEVERE_US,
|
||||
encode_mean_us.filter(|_| encode_usable),
|
||||
encode_rise_us,
|
||||
encode_severe_us,
|
||||
);
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||||
@@ -782,6 +898,42 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The encode down-driver's stand-down re-probes on the same clock, for the same reason
|
||||
// the two caps do: it is EVIDENCE, not a spec limit. What silenced it — a game
|
||||
// saturating the GPU, a shader-compile storm, another app on the card — is exactly the
|
||||
// sort of thing that ENDS mid-session, and what it silences is the only signal that can
|
||||
// descend when the encoder is genuinely past its compute knee on a link that shows
|
||||
// nothing. Left permanent, one contended stretch would strip that protection from every
|
||||
// later minute of the session, including the calm ones where a climb can reach a rate
|
||||
// the ASIC cannot hold.
|
||||
//
|
||||
// A clean run is the cheapest moment to ask again: nothing else is unhappy, so if the
|
||||
// rate still cannot move encode time, two more no-op backoffs stand it down again at a
|
||||
// bounded cost — while the doubling interval keeps a genuinely standing contention from
|
||||
// thrashing. The asymmetry decides it: a too-eager re-arm costs one ×0.7, a too-permanent
|
||||
// silence costs the knee protection outright.
|
||||
if self.encode_disarmed {
|
||||
if bad {
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
} else {
|
||||
self.encode_disarm_clean_windows += 1;
|
||||
if self.encode_disarm_clean_windows >= self.encode_reprobe_after {
|
||||
self.encode_disarmed = false;
|
||||
self.encode_rearmed = true;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
// Re-arm on a FRESH baseline and with no streak carried over: the level the
|
||||
// old backoffs fired at describes a regime that has since been clean for
|
||||
// seconds, so it is not the reference the next one should be judged against.
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
self.encode_means.clear();
|
||||
tracing::debug!(
|
||||
after_windows = self.encode_reprobe_after,
|
||||
"adaptive bitrate: re-arming the encode down-driver after a clean run"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -875,6 +1027,56 @@ impl BitrateController {
|
||||
} else {
|
||||
self.decode_backoff_kbps = 0;
|
||||
}
|
||||
// Encode attribution (see [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): did the LAST
|
||||
// encode-driven backoff buy anything? Judged from the level this one fires at, not
|
||||
// from the baseline — `on_ack` re-seeded that after the last decrease, so the firing
|
||||
// level is the only surviving record of what encode time did in between. Network
|
||||
// distress disqualifies the attribution: loss, a flush or a dropped frame explain the
|
||||
// backoff without the encoder, and cutting the rate genuinely is the remedy for those.
|
||||
let encode_attributed = (encode_severe || encode_bad)
|
||||
&& dropped == 0
|
||||
&& !flushed
|
||||
&& loss_ppm < HEAVY_LOSS_PPM;
|
||||
if let Some(mean) = encode_mean_us.filter(|_| encode_attributed) {
|
||||
if self.encode_backoff_us > 0
|
||||
&& mean >= self.encode_backoff_us.saturating_sub(encode_rise_us)
|
||||
{
|
||||
// Fired again no lower than last time: the ×0.7 in between did nothing.
|
||||
self.encode_noop_backoffs += 1;
|
||||
if self.encode_noop_backoffs >= ENCODE_NOOP_BACKOFFS_TO_DISARM {
|
||||
// Re-silencing something the re-probe had already lifted means the
|
||||
// contention is STANDING, not the transient the re-probe exists to ride
|
||||
// out — back its clock off, exactly as both learned caps do.
|
||||
self.encode_reprobe_after = if self.encode_rearmed {
|
||||
self.encode_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
self.encode_disarmed = true;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
self.encode_means.clear();
|
||||
tracing::info!(
|
||||
at_kbps = self.current_kbps,
|
||||
encode_mean_us = mean,
|
||||
noop_backoffs = self.encode_noop_backoffs,
|
||||
rearm_after_windows = self.encode_reprobe_after,
|
||||
"adaptive bitrate: host encode time is not answering the rate — \
|
||||
standing the encode down-driver down until a clean run re-probes it \
|
||||
(loss, OWD, decode and keyframe signals keep driving)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.encode_noop_backoffs = 0;
|
||||
}
|
||||
self.encode_backoff_us = mean;
|
||||
} else {
|
||||
// Something else drove this one: the encode streak is broken, and the level the
|
||||
// next encode-driven backoff would have to beat no longer means anything.
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
}
|
||||
self.climb_since_backoff = false;
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
@@ -2178,6 +2380,283 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One encode-attributed choke: re-seed the baseline `on_ack` cleared, then present `level`
|
||||
/// again — the shape of an encoder held up by something the last ×0.7 did nothing about.
|
||||
/// Four seed windows is under [`CLEAN_WINDOWS_TO_INCREASE`], so no cycle can climb its way
|
||||
/// out from under the test.
|
||||
fn encode_choke(
|
||||
c: &mut BitrateController,
|
||||
start: Instant,
|
||||
tick: &mut u32,
|
||||
level: i64,
|
||||
) -> Option<u32> {
|
||||
for _ in 0..BASELINE_MIN_WINDOWS {
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
// Seed windows are clean by construction; ack a climb if the controller takes one, so
|
||||
// the helper stays usable in tests that leave climb headroom below the ceiling.
|
||||
if let Some(k) = c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(level),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// `n` clean windows carrying no encode sample, acking any climb the controller takes.
|
||||
fn clean_run(c: &mut BitrateController, start: Instant, tick: &mut u32, n: u32) {
|
||||
for _ in 0..n {
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
if let Some(k) = c.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, false, 0) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the field ratchet: encode-attributed backoffs at a level the ×0.7s never move, until
|
||||
/// the signal stands down.
|
||||
fn disarm_encode(c: &mut BitrateController, start: Instant, tick: &mut u32) {
|
||||
for _ in 0..=ENCODE_NOOP_BACKOFFS_TO_DISARM {
|
||||
let verdict = encode_choke(c, start, tick, 20_000);
|
||||
c.on_ack(verdict.expect("an unanswered encode rise must back off"));
|
||||
}
|
||||
assert!(c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stood_down_encode_signal_re_arms_after_a_clean_run() {
|
||||
// The stand-down is EVIDENCE, not a spec limit, and what it answers — contention on the
|
||||
// host's GPU — is exactly the sort of thing that ends mid-session. Left permanent, one
|
||||
// contended stretch would strip the knee down-driver from every calm minute that follows,
|
||||
// including the ones where a climb can reach a rate the ASIC cannot hold.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
|
||||
// A short clean spell is not enough — the re-probe is a run, not a blip.
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
assert!(c.encode_disarmed);
|
||||
clean_run(&mut c, start, &mut tick, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
|
||||
// And it really drives again: a fresh excursion backs the rate off.
|
||||
assert!(encode_choke(&mut c, start, &mut tick, 40_000).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standing_contention_backs_the_re_arm_clock_off() {
|
||||
// A re-armed signal silenced again means the contention is STANDING, not the transient
|
||||
// the re-probe rides out. Same answer both caps give: poll it slowly rather than either
|
||||
// giving up forever or thrashing every twelve seconds.
|
||||
//
|
||||
// Started high enough that two full ratchets stay clear of the floor — a rate pinned at
|
||||
// `FLOOR_KBPS` stops backing off at all, which would starve the second stand-down of the
|
||||
// backoffs it is counted from.
|
||||
let mut c = BitrateController::new(200_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN);
|
||||
assert!(!c.encode_disarmed);
|
||||
// Re-armed, and the contention is still there.
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_window_restarts_the_re_arm_run() {
|
||||
// The re-probe wants a genuinely quiet stretch: a window the network spoiled says nothing
|
||||
// about whether the encoder would answer the rate now, so the run starts over.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
let at = ticks(start, tick);
|
||||
tick += 1;
|
||||
// A flush: severe, so it also costs a ×0.7 — and it resets the clean run behind it.
|
||||
assert!(c
|
||||
.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, true, 0)
|
||||
.is_some());
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
assert!(c.encode_disarmed, "the spoiled window must restart the run");
|
||||
clean_run(&mut c, start, &mut tick, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encode_thresholds_follow_the_session_frame_budget() {
|
||||
// The 1440p60-vs-1440p120 field asymmetry. One frame of encode delay is 8.3 ms at 120 Hz
|
||||
// and 16.7 ms at 60 Hz, so against FIXED thresholds the 60 Hz session takes the immediate
|
||||
// ×0.7 for the same physical hiccup the 120 Hz one shrugs off. Sized in frame budgets,
|
||||
// both treat it the same way: ordinary, and confirmed by a second window.
|
||||
let excursion = 23_700; // 7 ms baseline + ~one 60 Hz frame
|
||||
let mut hz120 = BitrateController::new(20_000);
|
||||
hz120.set_frame_budget(120);
|
||||
let mut tick = 0;
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
encode_choke(&mut hz120, start, &mut tick, excursion),
|
||||
Some(14_000),
|
||||
"at 120 Hz that is ~2.8 frame budgets over baseline — severe, one window"
|
||||
);
|
||||
|
||||
let mut hz60 = BitrateController::new(20_000);
|
||||
hz60.set_frame_budget(60);
|
||||
let mut tick = 0;
|
||||
assert_eq!(
|
||||
encode_choke(&mut hz60, start, &mut tick, excursion),
|
||||
None,
|
||||
"the same excursion is ~1 frame budget at 60 Hz — bad, but not severe"
|
||||
);
|
||||
// Confirmed by a second window, it still backs off — the signal is not weakened, only
|
||||
// re-scaled.
|
||||
let at = ticks(start, tick + 1);
|
||||
assert_eq!(
|
||||
hz60.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(excursion),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unactuatable_encode_rises_disarm_the_down_driver() {
|
||||
// The field ratchet (2026-08-22): a game saturating the GPU holds host encode time up,
|
||||
// the client reads it as the compute knee, and every ×0.7 changes nothing — 57 Mbps to
|
||||
// the floor over ten minutes with zero loss, zero keyframe asks and a flat decoder.
|
||||
// `on_ack` re-seeds the encode baseline after each decrease, so nothing in the signal
|
||||
// itself ever notices that the backoffs are not working. The firing LEVEL does.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
|
||||
// First one is a legitimate knee sample — nothing has been learned yet.
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
// Fires again no lower: the first ×0.7 bought nothing. One no-op is not a verdict — a
|
||||
// real knee still above the current rate looks exactly like this.
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(9_800));
|
||||
c.on_ack(9_800);
|
||||
assert_eq!(c.encode_noop_backoffs, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
// Twice in a row ⇒ the rate is not the lever. This backoff still lands (the window was
|
||||
// judged before the verdict), and it is the last one this signal drives until a clean run
|
||||
// re-probes it (`a_stood_down_encode_signal_re_arms_after_a_clean_run`).
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(6_860));
|
||||
c.on_ack(6_860);
|
||||
assert!(c.encode_disarmed);
|
||||
|
||||
// The ratchet stops: the same excursion no longer moves the rate…
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), None);
|
||||
// …and the session climbs back out instead of parking at the floor.
|
||||
c.set_ceiling(200_000);
|
||||
assert!(
|
||||
run_clean(&mut c, start, tick, 8).is_some_and(|k| k > 6_860),
|
||||
"a disarmed encode signal must not keep the session pinned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encode_backoff_that_helps_keeps_the_down_driver_armed() {
|
||||
// The knee this signal was built for: the ×0.7 lands nearer it and encode time genuinely
|
||||
// comes down, so the next excursion is a fresh event rather than evidence that the rate
|
||||
// is the wrong lever. Nothing here may disarm.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 40_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 22_000), Some(9_800));
|
||||
c.on_ack(9_800);
|
||||
assert_eq!(c.encode_noop_backoffs, 0);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_driven_backoff_breaks_the_encode_streak() {
|
||||
// Loss, a flush or a dropped frame explain a backoff without the encoder — and cutting
|
||||
// the rate genuinely IS the remedy for those. Such a window must not count toward the
|
||||
// disarm, even when encode time happens to be elevated in it too.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
assert_eq!(c.encode_backoff_us, 20_000);
|
||||
// Re-seed so the encode signal is live again…
|
||||
for _ in 0..BASELINE_MIN_WINDOWS {
|
||||
let at = ticks(start, tick);
|
||||
tick += 1;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// …then a window carrying BOTH an encode excursion and a jump-to-live flush. The flush is
|
||||
// the explanation, so the encode streak resets rather than advancing toward a disarm.
|
||||
let at = ticks(start, tick);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
true,
|
||||
0
|
||||
),
|
||||
Some(9_800)
|
||||
);
|
||||
assert_eq!(c.encode_backoff_us, 0);
|
||||
assert_eq!(c.encode_noop_backoffs, 0);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_max_mbps_caps_every_learned_ceiling() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
|
||||
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
use crate::quic::{
|
||||
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
|
||||
};
|
||||
|
||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
|
||||
/// forcing a full IDR. See [`RfiRequest`].
|
||||
Rfi(RfiRequest),
|
||||
Loss(LossReport),
|
||||
/// How many data-plane packets have reached us all session — sent straight after every
|
||||
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
|
||||
/// identical) and only this separates them. See [`DeliveryReport`].
|
||||
Delivery(DeliveryReport),
|
||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||
SetBitrate(u32),
|
||||
|
||||
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
|
||||
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
|
||||
/// (nothing arriving, versus more arriving than it can drain).
|
||||
///
|
||||
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
|
||||
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
|
||||
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
|
||||
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
|
||||
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
|
||||
/// local copy is exactly the drift that made the two indistinguishable in the first place.
|
||||
///
|
||||
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
|
||||
/// send one; this keeps the period itself informative for those that are not.
|
||||
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
|
||||
|
||||
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
||||
|
||||
@@ -42,7 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
|
||||
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -97,6 +97,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
negotiated.bit_depth,
|
||||
negotiated.chroma_format,
|
||||
);
|
||||
// This session's frame budget, the unit the ABR's host-encode thresholds are expressed in
|
||||
// (see [`crate::abr::BitrateController::encode_thresholds`]). The NEGOTIATED refresh, not the
|
||||
// requested one — `mode_slot` still holds the request until the connect handshake seeds it,
|
||||
// and a host that answered 60 to a 120 ask is exactly the session that must not be scored
|
||||
// against a 120 Hz budget.
|
||||
let refresh_hz = negotiated.mode.refresh_hz;
|
||||
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||
@@ -179,6 +185,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
||||
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
||||
// The data pump re-reads the accepted mode when `mode_gen` moves, to re-size the ABR's
|
||||
// frame-budget-scaled encode thresholds for the new refresh.
|
||||
let mode_slot_pump = mode_slot.clone();
|
||||
tokio::spawn(
|
||||
control_task::ControlTask {
|
||||
ctrl_rx,
|
||||
@@ -271,6 +280,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
};
|
||||
let _ = tokio::task::spawn_blocking(move || pump.run()).await;
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ impl ControlTask {
|
||||
}
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::Delivery(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
if clock_rtt_ns.is_none() {
|
||||
|
||||
@@ -44,6 +44,12 @@ pub(super) struct DataPump {
|
||||
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
|
||||
/// to. Computed where the negotiated geometry lives, so this module stays codec-agnostic.
|
||||
pub(super) stream_cap_kbps: u32,
|
||||
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
|
||||
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
|
||||
pub(super) refresh_hz: u32,
|
||||
/// The accepted mode, written by the control task on a mode switch — read when `mode_gen`
|
||||
/// moves so the frame budget follows the new refresh.
|
||||
pub(super) mode_slot: Arc<Mutex<crate::config::Mode>>,
|
||||
}
|
||||
|
||||
impl DataPump {
|
||||
@@ -69,6 +75,8 @@ impl DataPump {
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
} = self;
|
||||
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||
@@ -77,6 +85,12 @@ impl DataPump {
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
|
||||
// the delivery count is reported every window while it is ZERO (the state the host acts on)
|
||||
// and once more when the first packets land, then never again. A host that predates the
|
||||
// message logs "unknown control message" for each one, so a healthy session must not stream
|
||||
// them — one line per session is a fair price on an old host, eighty a minute is not.
|
||||
let mut delivery_confirmed = false;
|
||||
let (
|
||||
mut last_recovered,
|
||||
mut last_late,
|
||||
@@ -115,6 +129,10 @@ impl DataPump {
|
||||
// no inter-coded stream benefits from — the field session walked to 657 Mbps for 1440p120
|
||||
// and drove the client's decode latency from 0.8 ms to 10 ms getting there.
|
||||
abr.set_stream_cap(stream_cap_kbps);
|
||||
// Size the host-encode thresholds in this session's frame budgets rather than the 120 Hz
|
||||
// durations they were calibrated at — a 60 Hz session otherwise takes the SEVERE
|
||||
// one-window ×0.7 on an ordinary one-frame encode hiccup.
|
||||
abr.set_frame_budget(refresh_hz);
|
||||
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||
@@ -122,18 +140,31 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
// The burst target is DERIVED from `stream_cap_kbps`, not set "far above any plausible
|
||||
// link". It used to be a flat 2 Gbps on that reasoning — the burst must measure the link
|
||||
// and not itself — but the ABR already discards every bit measured above what the session
|
||||
// could use: `set_ceiling` clamps to the stream cap set a few lines up, so everything past
|
||||
// `stream_cap_kbps / 0.7` is thrown away the moment it lands. All that height bought was
|
||||
// bufferbloat for a number nothing reads, and on links the burst DISTURBS it backfires — a
|
||||
// constrained Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting
|
||||
// the 6 s timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211; the same shape is reported on a Fire TV Stick 4K Max), and a 2-3
|
||||
// core TV client starves decoding the firehose.
|
||||
//
|
||||
// ×2 is the smallest multiplier that still PROVES the cap: the measured ceiling is
|
||||
// `delivered × 0.7`, so reaching `stream_cap_kbps` needs `delivered ≥ cap × 1.43` and the
|
||||
// rest is margin. Deriving it this way cannot cap anyone — a session whose mode and codec
|
||||
// justify a high ceiling asks for a correspondingly high target by itself, and a mode we
|
||||
// cannot size (`stream_ceiling_kbps` → `u32::MAX`) still gets the old 2 Gbps. It also
|
||||
// fixes webOS and every other constrained client, not just the box that reported it.
|
||||
//
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` overrides the target outright (unset/0/garbage → the derived
|
||||
// one). An embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
.unwrap_or_else(|| probe_target_kbps(stream_cap_kbps));
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -148,6 +179,9 @@ impl DataPump {
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
// `frames_completed` as the burst began, so the probe-end block below can ask "did ANY
|
||||
// frame survive this burst" rather than only "has one ever arrived" — see there.
|
||||
let mut frames_at_probe_start: u64 = 0;
|
||||
// The window this closes is discarded outright: no LossReport, no standing-latency close,
|
||||
// no ABR feed. Two causes, both of them "this window's signals describe something other
|
||||
// than the link, and one bogus congestion verdict here ends slow start for good":
|
||||
@@ -283,6 +317,24 @@ impl DataPump {
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
// …and if the burst swallowed the video with it, re-anchor the decoder. This runs
|
||||
// on EVERY probe end — a successful one, a timed-out one, an embedder "Test
|
||||
// connection" — and the frame-count guard is what makes it a no-op the rest of the
|
||||
// time: a burst the link couldn't hold can take the keyframe down with it, and
|
||||
// then nothing re-requests one, so the client sits on black until some unrelated
|
||||
// recovery path happens to fire. That is the reported Fire TV / webOS black
|
||||
// screen. Compared against the count SNAPSHOTTED at the burst's leading edge
|
||||
// rather than against 0: at startup the two are the same test, but this one also
|
||||
// catches a burst that kills an already-running stream (an embedder speed test
|
||||
// mid-session), which the cumulative counter never could. At most one request per
|
||||
// probe, and it funnels through the control task's coalescer like the other two
|
||||
// emitters in this file, so it cannot IDR-storm.
|
||||
if st.frames_completed == frames_at_probe_start {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||
tracing::warn!(
|
||||
"no frame survived the capacity probe — requested a keyframe to re-anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
@@ -290,6 +342,7 @@ impl DataPump {
|
||||
if !was_probing && probe_active {
|
||||
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
|
||||
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
|
||||
frames_at_probe_start = st.frames_completed;
|
||||
}
|
||||
if !probe_active {
|
||||
probe_watchdog = None;
|
||||
@@ -415,6 +468,27 @@ impl DataPump {
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
|
||||
// host, which cannot otherwise tell a flawless link from one delivering
|
||||
// nothing. The session TOTAL, not this window's, so one message stands on its
|
||||
// own. Deliberately inside the same arm: a discarded window is discarded
|
||||
// because the host was rebuilding or a probe distorted it, and staying silent
|
||||
// there keeps that contract exact. Nothing is lost — the state this reports
|
||||
// (no packets at all) produces no discards, so its windows always send.
|
||||
//
|
||||
// Sent every window while the count is ZERO, then ONCE when the first packets
|
||||
// land (so the host stops guessing and can name the other failure confidently),
|
||||
// then never again: a healthy session must not stream a message that older
|
||||
// hosts log as unknown on every arrival.
|
||||
// ponytail: only start-of-session death is covered. A path that dies MID-stream
|
||||
// leaves the count frozen above zero and silent, which the host still reads as
|
||||
// healthy — detecting that needs a stalled-counter check with its own timing,
|
||||
// worth adding if a mid-session case is ever reported.
|
||||
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
|
||||
packets_received: st.packets_received,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
@@ -476,6 +550,10 @@ impl DataPump {
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
// The frame budget is a property of the MODE: a switch that changes the
|
||||
// refresh changes what one frame of encode time costs, and the encode
|
||||
// thresholds are sized in those.
|
||||
abr.set_frame_budget(pump_mode_slot.lock().unwrap().refresh_hz);
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
@@ -757,10 +835,104 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
|
||||
///
|
||||
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
|
||||
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
|
||||
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
|
||||
/// the message logs every unknown control message, and a healthy hours-long session must not fill
|
||||
/// its log with them.
|
||||
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
|
||||
let owed = packets_received == 0 || !*confirmed;
|
||||
*confirmed = packets_received > 0;
|
||||
owed
|
||||
}
|
||||
|
||||
/// The capacity probe's burst target for a session bounded at `stream_cap_kbps`, in kbps — the
|
||||
/// default `PUNKTFUNK_ABR_PROBE_KBPS` overrides. See the probe's comment in the pump for why it is
|
||||
/// derived rather than fixed: `BitrateController::set_ceiling` clamps the measurement to the
|
||||
/// stream cap, so every bit measured above `cap / 0.7` is discarded, and bursting for it only
|
||||
/// buys bufferbloat. ×2 clears that `1.43×` bar with margin.
|
||||
///
|
||||
/// `u32::MAX` in (a mode [`crate::abr::stream_ceiling_kbps`] declines to size) keeps the historic
|
||||
/// 2 Gbps, which is also the ceiling on the whole derivation: this can only ever lower the target.
|
||||
fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
|
||||
stream_cap_kbps.saturating_mul(2).min(2_000_000)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
|
||||
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
|
||||
/// on an older host, which warns per unknown message, is what pays for the first two.
|
||||
#[test]
|
||||
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
|
||||
let mut confirmed = false;
|
||||
// Nothing arriving: reported every window, for as long as it stays true.
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
should_report_delivery(0, &mut confirmed),
|
||||
"a dead data plane must be re-reported every window"
|
||||
);
|
||||
}
|
||||
// First packets land: one confirmation, so the host can name the other failure confidently.
|
||||
assert!(should_report_delivery(500, &mut confirmed));
|
||||
// Healthy from here: silent.
|
||||
for n in [900, 1_200, 90_000] {
|
||||
assert!(
|
||||
!should_report_delivery(n, &mut confirmed),
|
||||
"a healthy session must not stream delivery reports"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A session that never receives anything must never look confirmed, no matter how long it runs
|
||||
/// — the whole point is that the host keeps being told.
|
||||
#[test]
|
||||
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
|
||||
let mut confirmed = false;
|
||||
for _ in 0..100 {
|
||||
assert!(should_report_delivery(0, &mut confirmed));
|
||||
assert!(!confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The burst has to be big enough to PROVE the stream cap and no bigger. Anything the burst
|
||||
/// measures above `cap / 0.7` is discarded by `BitrateController::set_ceiling` (pinned by
|
||||
/// `abr::tests::the_stream_bound_clamps_a_learned_ceiling_only`) and paid for in bufferbloat.
|
||||
#[test]
|
||||
fn the_probe_target_proves_the_stream_cap_without_overshooting_it() {
|
||||
// Real modes, from the smallest a session runs to the largest — including 1440p120, the
|
||||
// field session that walked to 657 Mbps and taught the ABR the cap in the first place.
|
||||
for (w, h, hz, codec, depth) in [
|
||||
(1280, 720, 60, crate::quic::CODEC_HEVC, 8),
|
||||
(1920, 1080, 60, crate::quic::CODEC_H264, 8),
|
||||
(2560, 1440, 120, crate::quic::CODEC_HEVC, 8),
|
||||
(3840, 2160, 120, crate::quic::CODEC_HEVC, 10),
|
||||
] {
|
||||
let cap = crate::abr::stream_ceiling_kbps(w, h, hz, codec, depth, 0);
|
||||
let target = probe_target_kbps(cap);
|
||||
// Enough: a link that delivers the whole burst measures `delivered × 0.7`, and that
|
||||
// has to reach the cap or the session can never climb to what its mode allows.
|
||||
assert!(
|
||||
target.saturating_mul(7) / 10 >= cap,
|
||||
"{w}x{h}@{hz}: a {target} kbps burst cannot prove a {cap} kbps cap"
|
||||
);
|
||||
// …and no more: a target that overshoots what the clamp keeps is pure bufferbloat.
|
||||
// (The old flat 2 Gbps overshot 1440p120 by 6×.)
|
||||
assert!(
|
||||
target <= cap.saturating_mul(2),
|
||||
"{w}x{h}@{hz}: {target} kbps chases capacity the clamp discards"
|
||||
);
|
||||
}
|
||||
// A mode `stream_ceiling_kbps` declines to size (`u32::MAX`) keeps the historic 2 Gbps,
|
||||
// which is also the hard ceiling on the derivation — it can only ever lower the target.
|
||||
assert_eq!(probe_target_kbps(u32::MAX), 2_000_000);
|
||||
assert_eq!(probe_target_kbps(1_500_000), 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pipeline_gap_is_taken_exactly_once() {
|
||||
let slot = AtomicU32::new(0);
|
||||
@@ -899,6 +1071,12 @@ mod tests {
|
||||
resolved_bitrate_kbps: 20_000,
|
||||
negotiated_codec: crate::quic::CODEC_HEVC,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})),
|
||||
};
|
||||
let started = Instant::now();
|
||||
let pump_thread = std::thread::spawn(move || pump.run());
|
||||
@@ -935,8 +1113,8 @@ mod tests {
|
||||
.expect("the window after the gap reports on schedule");
|
||||
assert!(
|
||||
matches!(reported, Some(CtrlRequest::Loss(_))),
|
||||
"the window after the gap must produce a loss report — an idle session's only \
|
||||
outbound request"
|
||||
"the window after the gap must produce a loss report — the first of the two requests \
|
||||
an idle session makes (the delivery count follows it)"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(1_400),
|
||||
|
||||
@@ -97,6 +97,33 @@ pub struct LossReport {
|
||||
pub loss_ppm: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
|
||||
/// received all session, cumulative.
|
||||
///
|
||||
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
|
||||
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
|
||||
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
|
||||
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
|
||||
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
|
||||
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
|
||||
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
|
||||
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
|
||||
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
|
||||
///
|
||||
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
|
||||
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
|
||||
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
|
||||
/// normal here (the field case that motivated this ran a current host against a months-old client),
|
||||
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
|
||||
/// ignores every other control message it predates.
|
||||
///
|
||||
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
|
||||
/// mirrors, with no saturation to reason about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DeliveryReport {
|
||||
pub packets_received: u64,
|
||||
}
|
||||
|
||||
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
|
||||
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
|
||||
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
|
||||
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
|
||||
/// duration precisely so that no clock domain is involved.
|
||||
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
|
||||
/// Type byte of [`DeliveryReport`].
|
||||
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -436,6 +465,26 @@ impl LossReport {
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] packets_received[5..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_DELIVERY_REPORT);
|
||||
b.extend_from_slice(&self.packets_received.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
|
||||
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
|
||||
}
|
||||
Ok(DeliveryReport {
|
||||
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SetBitrate {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] bitrate_kbps[5..9]
|
||||
@@ -1291,6 +1340,41 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_report_roundtrip() {
|
||||
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
|
||||
let r = DeliveryReport { packets_received };
|
||||
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||
}
|
||||
|
||||
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
|
||||
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
|
||||
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
|
||||
/// mixed-version case here (the field report that motivated this ran a current host against a
|
||||
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
|
||||
/// simply ignores the message it does not know.
|
||||
#[test]
|
||||
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
|
||||
let loss = LossReport { loss_ppm: 42 }.encode();
|
||||
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
|
||||
assert_eq!(loss[4], MSG_LOSS_REPORT);
|
||||
|
||||
let delivery = DeliveryReport {
|
||||
packets_received: 0,
|
||||
}
|
||||
.encode();
|
||||
assert_ne!(
|
||||
delivery[4], MSG_LOSS_REPORT,
|
||||
"a distinct type byte is what makes an old host ignore it instead of failing"
|
||||
);
|
||||
// Neither can be silently mis-parsed as the other.
|
||||
assert!(LossReport::decode(&delivery).is_err());
|
||||
assert!(DeliveryReport::decode(&loss).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
|
||||
@@ -26,17 +26,26 @@
|
||||
|
||||
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
|
||||
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
|
||||
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
|
||||
/// sweep needs.
|
||||
const OWNER_MARKERS: [&str; 3] = [
|
||||
pub(crate) const OWNER_MARKERS: [&str; 3] = [
|
||||
pe::PAD_INDEX_VALUE,
|
||||
minted::ROLE_MARKER,
|
||||
audio_probe::PROBE_MARKER,
|
||||
];
|
||||
|
||||
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
|
||||
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
|
||||
const MINTED_HWIDS: [&str; 2] = [
|
||||
"ROOT\\SteamStreamingSpeakers",
|
||||
"ROOT\\SteamStreamingMicrophone",
|
||||
];
|
||||
|
||||
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
|
||||
/// registry half is best-effort by design — see [`delete_endpoint_record`].
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
out.push(inst);
|
||||
continue;
|
||||
}
|
||||
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
|
||||
// all — a devnode registered by a host that died before the marker write landed. It is
|
||||
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
|
||||
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
|
||||
//
|
||||
// The instance prefix is what makes this safe, and it is NOT redundant with
|
||||
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
|
||||
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
|
||||
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
|
||||
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
|
||||
if is_abandoned_mint(
|
||||
&inst,
|
||||
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
|
||||
) {
|
||||
out.push(inst);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
|
||||
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
|
||||
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
|
||||
instance_id
|
||||
.to_ascii_uppercase()
|
||||
.starts_with("ROOT\\MEDIA\\")
|
||||
&& MINTED_HWIDS
|
||||
.iter()
|
||||
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod abandoned_tests {
|
||||
use super::is_abandoned_mint;
|
||||
|
||||
fn hw(s: &str) -> Vec<String> {
|
||||
vec![s.to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopts_our_own_unmarked_devnodes() {
|
||||
// What a host that died mid-mint leaves behind, either role.
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0004",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0002",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
// PnP casing is not guaranteed on either half.
|
||||
assert!(is_abandoned_mint(
|
||||
r"root\media\0009",
|
||||
&hw(r"root\steamstreamingspeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_valves_own_devices() {
|
||||
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
|
||||
// enumerated too — only the instance prefix separates them from ours.
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_other_vendors_or_real_hardware() {
|
||||
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
|
||||
///
|
||||
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
|
||||
|
||||
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
|
||||
let devnode = match find_role_devnode(role)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
|
||||
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
|
||||
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
|
||||
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
|
||||
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
|
||||
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
|
||||
None => match adopt_orphan_devnode(role, &hwid)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::bind_driver(&hwid, &inf)?;
|
||||
|
||||
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
|
||||
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
|
||||
///
|
||||
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
|
||||
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
|
||||
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
|
||||
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
|
||||
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
|
||||
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
|
||||
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
|
||||
///
|
||||
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
|
||||
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
|
||||
/// stick across restarts.
|
||||
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
let set = pe::media_class_devs()?;
|
||||
for i in 0.. {
|
||||
let mut did = pe::devinfo_data();
|
||||
// SAFETY: live set; `did` is a live out-param with cbSize set.
|
||||
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
|
||||
break; // ERROR_NO_MORE_ITEMS
|
||||
}
|
||||
let Some(inst) = pe::instance_id(&set, &did) else {
|
||||
continue;
|
||||
};
|
||||
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
|
||||
continue;
|
||||
}
|
||||
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
|
||||
.iter()
|
||||
.any(|h| h.eq_ignore_ascii_case(hwid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if super::devnode_cleanup::OWNER_MARKERS
|
||||
.iter()
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
|
||||
tracing::warn!(
|
||||
role = role.label(),
|
||||
devnode = %inst,
|
||||
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
|
||||
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
|
||||
);
|
||||
return Ok(Some(inst));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
|
||||
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
|
||||
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
|
||||
|
||||
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
|
||||
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
|
||||
/// non-capture id resolves to, and the pad program only ever has render endpoints.
|
||||
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
|
||||
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
|
||||
MMDEV_CAPTURE_PATH
|
||||
} else {
|
||||
MMDEV_RENDER_PATH
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
|
||||
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
|
||||
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
|
||||
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
let guid = endpoint_guid_part(endpoint_id)?;
|
||||
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
|
||||
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
|
||||
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
|
||||
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
|
||||
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
|
||||
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
|
||||
// the property store was denied — silently, since the caller degrades to "keeps the
|
||||
// driver's default name".
|
||||
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
|
||||
grant_system_full_control(&path)
|
||||
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
|
||||
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
|
||||
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
|
||||
#[test]
|
||||
fn registry_stamp_hive_follows_the_endpoint_direction() {
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
|
||||
MMDEV_CAPTURE_PATH,
|
||||
"the minted microphone's capture endpoint records under Capture"
|
||||
);
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
|
||||
MMDEV_RENDER_PATH,
|
||||
);
|
||||
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
|
||||
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
|
||||
}
|
||||
|
||||
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
|
||||
/// value, and byte 23 must be the pad index.
|
||||
#[test]
|
||||
|
||||
@@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let deck = args.iter().any(|a| a == "--deck");
|
||||
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
|
||||
// pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on
|
||||
// CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this
|
||||
// because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the
|
||||
// `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit
|
||||
// can look healthy on one plane and die on the other.
|
||||
let idle_after: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--idle-after")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// `--resume-after M` ends the silence at M seconds and drives again. That is the half that
|
||||
// actually answers the question: enumeration surviving a silence proves nothing, because a pad
|
||||
// can stay listed and still deliver no input. What matters is whether a report written AFTER
|
||||
// the silence still reaches a consumer — check it with `win-input-matrix --watch` while this
|
||||
// runs, and watch whether the timestamps start advancing again.
|
||||
let resume_after: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--resume-after")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let extra_buttons: u32 = if edge || deck {
|
||||
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||
} else {
|
||||
@@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
$label
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let started = Instant::now();
|
||||
let mut announced_silence = false;
|
||||
let mut announced_resume = false;
|
||||
let (mut i, mut last) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
mgr.pump(
|
||||
@@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
),
|
||||
|o| println!(" hid output from game: {o:?}"),
|
||||
);
|
||||
if last.elapsed() >= Duration::from_millis(400) {
|
||||
let el = started.elapsed();
|
||||
let resumed =
|
||||
resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after));
|
||||
let silent =
|
||||
idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed;
|
||||
if silent && !announced_silence {
|
||||
announced_silence = true;
|
||||
println!(
|
||||
" --- going SILENT (no more state frames, still pumping) at {}s ---",
|
||||
idle_after
|
||||
);
|
||||
}
|
||||
if resumed && !announced_resume {
|
||||
announced_resume = true;
|
||||
println!(
|
||||
" --- RESUMING state frames at {}s (after {}s of silence) ---",
|
||||
resume_after,
|
||||
resume_after.saturating_sub(idle_after)
|
||||
);
|
||||
}
|
||||
if !silent && last.elapsed() >= Duration::from_millis(400) {
|
||||
last = Instant::now();
|
||||
i += 1;
|
||||
let buttons = if i % 2 == 0 {
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
|
||||
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
|
||||
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
|
||||
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
|
||||
/// of leaving one behind polling for a service nobody advertises.
|
||||
_stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
/// How often a live advert re-checks the address it is announcing.
|
||||
const IP_RECHECK: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The address to advertise right now — loopback only while the machine still has none.
|
||||
fn current_ip() -> IpAddr {
|
||||
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
|
||||
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
|
||||
///
|
||||
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
|
||||
/// announced until something registers a newer one. The host process comes up during boot, which
|
||||
/// on a cold start is before the machine has an address — so the first registration could be
|
||||
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
|
||||
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
|
||||
/// again.
|
||||
///
|
||||
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
|
||||
/// boot race usually resolves without one: the NIC often has its address before we register and
|
||||
/// only the default route lands late, so no interface event ever fires.
|
||||
pub(crate) fn advertise_live(
|
||||
service: &'static str,
|
||||
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let registered = current_ip();
|
||||
daemon
|
||||
.register(build(registered)?)
|
||||
.with_context(|| format!("register {service} mDNS service"))?;
|
||||
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
let bg_daemon = daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut announced = registered;
|
||||
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
|
||||
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
|
||||
while matches!(
|
||||
stop_rx.recv_timeout(IP_RECHECK),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
let now = current_ip();
|
||||
if now == announced {
|
||||
continue;
|
||||
}
|
||||
match build(now)
|
||||
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
|
||||
announced = now;
|
||||
}
|
||||
// Leave the previous record standing and retry next tick rather than going dark.
|
||||
Err(e) => {
|
||||
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Advert {
|
||||
_daemon: daemon,
|
||||
_stop: stop_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
@@ -95,7 +166,6 @@ pub struct Advert {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
port: u16,
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
@@ -103,14 +173,17 @@ pub fn advertise_native(
|
||||
mgmt_port: Option<u16>,
|
||||
os_chain: &str,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
|
||||
// has to be a legal DNS name, hence the separate sanitized label.
|
||||
let host_name = format!("{}.local.", dns_label(hostname));
|
||||
let mut props: HashMap<String, String> = HashMap::new();
|
||||
props.insert("proto".into(), NATIVE_PROTO.into());
|
||||
props.insert("fp".into(), fingerprint.to_string());
|
||||
props.insert(
|
||||
// Owned, because the record is rebuilt whenever the host's address changes — see
|
||||
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
|
||||
// so it is computed once here and moved into the builder.
|
||||
let instance = hostname.to_string();
|
||||
let mut fixed: HashMap<String, String> = HashMap::new();
|
||||
fixed.insert("proto".into(), NATIVE_PROTO.into());
|
||||
fixed.insert("fp".into(), fingerprint.to_string());
|
||||
fixed.insert(
|
||||
"pair".into(),
|
||||
if require_pairing {
|
||||
"required"
|
||||
@@ -119,31 +192,14 @@ pub fn advertise_native(
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
fixed.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
fixed.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
|
||||
if !os_chain.is_empty() {
|
||||
props.insert("os".into(), os_chain.to_string());
|
||||
fixed.insert("os".into(), os_chain.to_string());
|
||||
}
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
|
||||
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
|
||||
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
|
||||
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
|
||||
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
|
||||
// silently fails.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
.register(service)
|
||||
.context("register native mDNS service")?;
|
||||
tracing::info!(
|
||||
service = "_punktfunk._udp",
|
||||
port,
|
||||
@@ -151,7 +207,26 @@ pub fn advertise_native(
|
||||
pair = if require_pairing { "required" } else { "optional" },
|
||||
"native punktfunk/1 mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
advertise_live(NATIVE_SERVICE, move |ip| {
|
||||
let mut props = fixed.clone();
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
|
||||
// NIC first. A client persists these while the host is awake so it can send a
|
||||
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
|
||||
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
|
||||
// the magic packet is inert and the cert fingerprint still gates the actual connection.
|
||||
// Omitted when none can be read, which is what a host that came up before its network did
|
||||
// used to report forever.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
|
||||
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
|
||||
// different one now.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -114,9 +114,18 @@ pub enum LeaseKind {
|
||||
Child,
|
||||
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
|
||||
Matched,
|
||||
/// Nothing identifies this title's process — no detect signals and no child we own. Both
|
||||
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
|
||||
/// guessing.
|
||||
/// A launcher owns the game and **tells us** when it starts and stops
|
||||
/// ([`crate::runstate`]) — no process signal of our own.
|
||||
///
|
||||
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
|
||||
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
|
||||
/// through its own tracking and reports the edges, where the host could see only a
|
||||
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
|
||||
/// — the honest answer at the time, and a dead end.
|
||||
Reported,
|
||||
/// Nothing identifies this title's process — no detect signals, no child we own, and no
|
||||
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
|
||||
/// once in the log rather than guessing.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
@@ -126,6 +135,7 @@ impl LeaseKind {
|
||||
Self::Nested => "nested",
|
||||
Self::Child => "child",
|
||||
Self::Matched => "matched",
|
||||
Self::Reported => "reported",
|
||||
Self::Untracked => "untracked",
|
||||
}
|
||||
}
|
||||
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
LeaseKind::Child
|
||||
} else if !spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(game.id.as_deref()) {
|
||||
// Nothing to scan for, but the provider that published this title is reporting liveness for
|
||||
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
|
||||
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
|
||||
// would make both lifetime behaviors depend on a plugin's uptime.
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
};
|
||||
@@ -551,8 +567,32 @@ fn watch(
|
||||
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
|
||||
};
|
||||
|
||||
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
|
||||
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
|
||||
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
|
||||
// changes while the lease is alive.
|
||||
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
|
||||
|
||||
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
|
||||
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
|
||||
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
|
||||
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
|
||||
// hand-off, so every such lease arrives here.
|
||||
let fallback_kind = || {
|
||||
if !shared.spec.is_empty() {
|
||||
LeaseKind::Matched
|
||||
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
|
||||
LeaseKind::Reported
|
||||
} else {
|
||||
LeaseKind::Untracked
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Phase 1: wait for the game to show up. ----
|
||||
let start_deadline = spawned_at + START_GRACE;
|
||||
// How long the scan has *continuously* seen something for this title — the scan-side twin of
|
||||
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
|
||||
let mut seen_since: Option<Instant> = None;
|
||||
loop {
|
||||
if cancelled() {
|
||||
return;
|
||||
@@ -567,8 +607,10 @@ fn watch(
|
||||
&& !spawned_up(&spawned)
|
||||
{
|
||||
spawned = None;
|
||||
if spawned_at.elapsed() < SHIM_WINDOW {
|
||||
if shared.spec.is_empty() {
|
||||
let quick = spawned_at.elapsed() < SHIM_WINDOW;
|
||||
kind = fallback_kind();
|
||||
if quick {
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and this \
|
||||
@@ -582,11 +624,10 @@ fn watch(
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by its store \
|
||||
signals instead"
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game another way"
|
||||
);
|
||||
kind = LeaseKind::Matched;
|
||||
} else if shared.spec.is_empty() {
|
||||
} else if matches!(kind, LeaseKind::Untracked) {
|
||||
// It ran long enough to have BEEN the game, and nothing else identifies it.
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -604,31 +645,30 @@ fn watch(
|
||||
shared.forget_child();
|
||||
if quick && status.success() {
|
||||
// A launcher that handed the game off and exited. Fall back to recognizing
|
||||
// the game by its store's signals; with none, stop tracking entirely rather
|
||||
// than pretend the shim's exit was the game's.
|
||||
kind = if shared.spec.is_empty() {
|
||||
// the game by its store's signals (or its provider's reporting); with
|
||||
// neither, stop tracking entirely rather than pretend the shim's exit was
|
||||
// the game's.
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"the launch command exited immediately (a launcher handing off) and \
|
||||
this title has no detect signals — stopping game tracking for it"
|
||||
);
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the launch command handed off and exited — recognizing the game by \
|
||||
its store signals instead"
|
||||
);
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
"the launch command handed off and exited — recognizing the game \
|
||||
another way"
|
||||
);
|
||||
} else {
|
||||
// It ran long enough to have BEEN the game (or failed outright). Either way
|
||||
// the game is gone; only a success after a real run counts as "played".
|
||||
if shared.spec.is_empty() {
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
if spawned_at.elapsed() >= SHIM_WINDOW {
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
finish(&shared, &on_exit, "the launched process exited");
|
||||
@@ -642,11 +682,7 @@ fn watch(
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
|
||||
child = None;
|
||||
kind = if shared.spec.is_empty() {
|
||||
LeaseKind::Untracked
|
||||
} else {
|
||||
LeaseKind::Matched
|
||||
};
|
||||
kind = fallback_kind();
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
@@ -680,7 +716,39 @@ fn watch(
|
||||
&& (child.is_some() || spawned.is_some())
|
||||
&& spawned_at.elapsed() >= SHIM_WINDOW;
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
|
||||
// chain of process trees, and the ones that run before the game carry the signals the game
|
||||
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
|
||||
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
|
||||
// a tree that was never the game.
|
||||
//
|
||||
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
|
||||
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
|
||||
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
|
||||
// flips the lease from the first to the second, permanently; when that tree then exits with
|
||||
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
|
||||
// League session 10 s after launch, while Steam was still compiling its shaders, and the
|
||||
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
|
||||
//
|
||||
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
|
||||
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
|
||||
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
|
||||
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
|
||||
// by name); this bounds what no signal caught.
|
||||
let scan_settled = if live.is_empty() {
|
||||
seen_since = None;
|
||||
false
|
||||
} else {
|
||||
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
|
||||
};
|
||||
// A provider saying so is as good as seeing it — better, for a title there is nothing to
|
||||
// see: it is the launcher that started the game telling us it did. This is the only way a
|
||||
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
|
||||
// gets there sooner than the scan would. Not gated by the window above: a report is the
|
||||
// launcher's own statement about the game, not an inference from a process that resembles
|
||||
// it, so there is nothing to wait out.
|
||||
let said_running = reported().is_some_and(|l| l.running);
|
||||
if scan_settled || child_alive || said_running {
|
||||
known = live.clone();
|
||||
publish(&live);
|
||||
shared.was_running.store(true, Ordering::Relaxed);
|
||||
@@ -693,6 +761,8 @@ fn watch(
|
||||
title = %shared.game.title,
|
||||
kind = kind.as_str(),
|
||||
procs = live.len(),
|
||||
// Which processes, not just how many: see [`crate::procscan::names`].
|
||||
names = ?crate::procscan::names(&live),
|
||||
"the launched game is running"
|
||||
);
|
||||
break;
|
||||
@@ -754,6 +824,27 @@ fn watch(
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else if let Some(said) = reported() {
|
||||
// Nothing of the game is visible to us, but its provider is still reporting on it — and
|
||||
// that report is decisive in BOTH directions, where `running_hint` below may only ever
|
||||
// delay an exit.
|
||||
//
|
||||
// The difference is what backs each claim. Steam's registry flag is a leftover that
|
||||
// survives an unclean exit, so believing it indefinitely produces a session that never
|
||||
// ends; a provider report is an event from the launcher that started the game, restated
|
||||
// continuously, and it stops counting the moment it goes stale
|
||||
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
|
||||
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
|
||||
// session open for a game the host cannot see at all, which is the entire point for a
|
||||
// title with no detect signals, and a dead one costs at most one TTL.
|
||||
if said.running {
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else {
|
||||
finish(&shared, &on_exit, "its provider reported the game stopped");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
|
||||
// the veto below — letting it run on is exactly what bounds the veto.
|
||||
@@ -909,7 +1000,7 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
"released the nested session's kept display to end its game"
|
||||
);
|
||||
}
|
||||
LeaseKind::Child | LeaseKind::Matched => {
|
||||
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
|
||||
#[cfg(target_os = "linux")]
|
||||
unix_term_ladder(shared);
|
||||
#[cfg(windows)]
|
||||
@@ -919,6 +1010,26 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
|
||||
/// time, or `None`.
|
||||
///
|
||||
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
|
||||
/// nothing by construction, so without this "End" would have no target and would silently do
|
||||
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
|
||||
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
|
||||
/// pid the kernel has since recycled, contributes nothing.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
let pid = shared
|
||||
.game
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(crate::runstate::opinion)
|
||||
.filter(|l| l.running)?
|
||||
.pid?;
|
||||
crate::procscan::resolve(pid)
|
||||
}
|
||||
|
||||
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
|
||||
///
|
||||
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
|
||||
@@ -942,11 +1053,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
|
||||
unsafe { libc::kill(target, sig) == 0 }
|
||||
};
|
||||
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
|
||||
// which for a `Reported` lease is the only member of this set.
|
||||
let targets = || {
|
||||
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
if !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
}
|
||||
procs
|
||||
};
|
||||
let signal_matched = |sig: i32| -> usize {
|
||||
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
||||
// sweep is never hit.
|
||||
scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.alive(&targets())
|
||||
.into_iter()
|
||||
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
||||
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
||||
@@ -965,9 +1087,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
||||
let deadline = Instant::now() + TERM_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(POLL);
|
||||
let still = scanner
|
||||
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||
.len();
|
||||
let still = scanner.alive(&targets()).len();
|
||||
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
||||
let child_gone = !signal_child(0);
|
||||
if still == 0 && child_gone {
|
||||
@@ -1000,11 +1120,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
|
||||
let live = || {
|
||||
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
|
||||
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
|
||||
// de-duplicated: the matcher may well have found this same process by its image.
|
||||
if let Some(p) = shared.spawned {
|
||||
// de-duplicated: the matcher may well have found this same process by its image. The
|
||||
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
|
||||
// only thing here (see `reported_proc`).
|
||||
let mut fold = |p: crate::procscan::ProcRef| {
|
||||
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
|
||||
procs.push(p);
|
||||
}
|
||||
};
|
||||
if let Some(p) = shared.spawned {
|
||||
fold(p);
|
||||
}
|
||||
if let Some(p) = reported_proc(shared) {
|
||||
fold(p);
|
||||
}
|
||||
procs
|
||||
};
|
||||
@@ -1570,6 +1698,54 @@ mod tests {
|
||||
assert!(!l.shared().is_trackable());
|
||||
}
|
||||
|
||||
/// A title with nothing to scan for is tracked after all when its provider reports on it.
|
||||
///
|
||||
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
|
||||
/// manually added one, a library plugin that records no install directory. The launch is a
|
||||
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
|
||||
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
|
||||
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
|
||||
#[test]
|
||||
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
|
||||
// The same request with no provider reporting: unchanged, and the control for what follows.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
assert!(!l.shared().is_trackable());
|
||||
drop(l);
|
||||
|
||||
// A provider that speaks for the title — while reporting it NOT running, which is exactly
|
||||
// what a report looks like at the moment a game is launched. Trackability follows from the
|
||||
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
|
||||
// the answer would make both lifetime behaviours depend on a plugin's timing.
|
||||
crate::runstate::report(
|
||||
"playnite-lease-test",
|
||||
["playnite:lease-test".to_string()].into_iter().collect(),
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
|
||||
assert!(
|
||||
l.shared().is_trackable(),
|
||||
"so its exit is noticed and `POST /game/end` has a target"
|
||||
);
|
||||
drop(l);
|
||||
crate::runstate::forget("playnite-lease-test");
|
||||
|
||||
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
|
||||
// its plugin is the one way this could hold a session open forever.
|
||||
let l = open(
|
||||
req("playnite:lease-test", DetectSpec::default(), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untracked_lease_is_never_terminated() {
|
||||
let l = open(
|
||||
@@ -1875,6 +2051,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
|
||||
/// game.
|
||||
///
|
||||
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
|
||||
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
|
||||
/// latched on that single sighting: the lease left the start phase immediately, and when the
|
||||
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
|
||||
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
|
||||
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
|
||||
/// to launch the game twice.
|
||||
///
|
||||
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
|
||||
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
|
||||
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
|
||||
///
|
||||
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
|
||||
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
|
||||
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
|
||||
// this test for entirely the wrong reason. (Same trap as the live matcher test in
|
||||
// [`crate::procscan`].)
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let stand_in = td.path().join("sleep");
|
||||
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
|
||||
let launch_stamp = launch_clock();
|
||||
|
||||
// Alive for less than the shim window — Steam's shader job, in miniature.
|
||||
let mut child = std::process::Command::new(&stand_in)
|
||||
.arg("3")
|
||||
.spawn()
|
||||
.expect("spawn the fake pre-launch tree");
|
||||
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
|
||||
// so the scan would call it alive forever and the exit under test never happen.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
|
||||
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
|
||||
PRE_EXITS.store(0, Ordering::SeqCst);
|
||||
let lease = open(
|
||||
LeaseRequest {
|
||||
launch_stamp,
|
||||
// No child and no pid: the scan is the only signal, which is the field-report shape
|
||||
// (`steam steam://rungameid/…` had already handed off and exited).
|
||||
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
|
||||
},
|
||||
Box::new(|| {
|
||||
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
let shared = lease.shared();
|
||||
assert!(matches!(shared.kind(), LeaseKind::Matched));
|
||||
|
||||
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
|
||||
assert_eq!(
|
||||
PRE_EXITS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a tree that ran before the game must not end the session when it exits — this is the \
|
||||
field report"
|
||||
);
|
||||
assert_ne!(
|
||||
shared.state(),
|
||||
GameState::Exited,
|
||||
"the game never started, so nothing of it can have exited"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
|
||||
/// notices when it exits, and reports that exit exactly once.
|
||||
///
|
||||
|
||||
@@ -821,6 +821,7 @@ fn on_receive(
|
||||
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
|
||||
// because the creating event never arrives.
|
||||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||||
crate::sleep_inhibit::note_input();
|
||||
if permitted(grants, GrantClass::Gamepad, drops) {
|
||||
pads.handle(&gp);
|
||||
}
|
||||
@@ -831,6 +832,7 @@ fn on_receive(
|
||||
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
|
||||
// Pointer-class by construction (the plane tag decides, like the native pen plane).
|
||||
if let Some(p) = super::input::decode_pointer(&pt) {
|
||||
crate::sleep_inhibit::note_input();
|
||||
if permitted(grants, GrantClass::Pointer, drops) {
|
||||
pointer.apply(&p, |ev| {
|
||||
let _ = inj_tx.send(ev);
|
||||
@@ -862,6 +864,10 @@ fn on_receive(
|
||||
if events.is_empty() {
|
||||
return; // keepalive / QoS / unhandled input kind
|
||||
}
|
||||
// A Moonlight guest is driving the box — drop any standing suspend veto so their own "Sleep"
|
||||
// reaches logind (see `sleep_inhibit`). Past the `is_empty` gate on purpose: a keepalive is
|
||||
// the one thing a passive viewer DOES send, and it must not read as someone being there.
|
||||
crate::sleep_inhibit::note_input();
|
||||
|
||||
// Forward to the dedicated injector thread (it opens the backend on the first event and
|
||||
// coalesces redundant motion) — each event past one mask test against the exhaustive
|
||||
|
||||
@@ -3,37 +3,34 @@
|
||||
|
||||
use super::Host;
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use mdns_sd::ServiceInfo;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
}
|
||||
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
|
||||
// keeps the record pointed at the host's current address.
|
||||
use crate::discovery::Advert;
|
||||
|
||||
const SERVICE: &str = "_nvstream._tcp.local.";
|
||||
|
||||
pub fn advertise(host: &Host) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
|
||||
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
|
||||
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
let service = ServiceInfo::new(
|
||||
"_nvstream._tcp.local.",
|
||||
&host.hostname,
|
||||
&host_name,
|
||||
host.local_ip,
|
||||
host.http_port,
|
||||
props,
|
||||
)
|
||||
.context("build mDNS ServiceInfo")?;
|
||||
daemon.register(service).context("register mDNS service")?;
|
||||
let instance = host.hostname.clone();
|
||||
let port = host.http_port;
|
||||
tracing::info!(
|
||||
service = "_nvstream._tcp",
|
||||
port = host.http_port,
|
||||
port,
|
||||
host = %host_name,
|
||||
"mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
// The advertised address is supplied per-registration so the record follows the host onto a
|
||||
// network that only came up after boot — see [`crate::discovery::advertise_live`].
|
||||
crate::discovery::advertise_live(SERVICE, move |ip| {
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
|
||||
.context("build mDNS ServiceInfo")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ pub struct Host {
|
||||
pub hostname: String,
|
||||
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
|
||||
pub uniqueid: String,
|
||||
pub local_ip: IpAddr,
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
|
||||
@@ -155,13 +154,25 @@ impl Host {
|
||||
Ok(Host {
|
||||
hostname: hostname_string(),
|
||||
uniqueid: load_or_create_uniqueid()?,
|
||||
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: os.chain.clone(),
|
||||
os_name: os.pretty.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort primary LAN IP, re-read on every call.
|
||||
///
|
||||
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
|
||||
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
|
||||
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
|
||||
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
|
||||
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
|
||||
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
|
||||
/// into. Loopback here means "still no LAN address", not a stale one.
|
||||
pub fn local_ip(&self) -> IpAddr {
|
||||
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
|
||||
@@ -408,6 +419,29 @@ pub fn serve(
|
||||
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
//
|
||||
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
|
||||
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
|
||||
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
|
||||
// leaves a first-run window where the console starts, finds no native pair, and serves the
|
||||
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
|
||||
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
|
||||
// (It does not write one on an upgraded host whose native clients pinned the legacy cert —
|
||||
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
|
||||
// (native clients paired, but the cert they pinned is gone from disk) the old order let
|
||||
// `load_or_create` mint a BRAND-NEW cert.pem that `load_or_adopt` then adopted while logging
|
||||
// that it was preserving their pins — stranding them silently. Reading the dir first means
|
||||
// that case reaches the branch written for it.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
#[cfg(feature = "gamestream")]
|
||||
let state = {
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
@@ -415,24 +449,14 @@ pub fn serve(
|
||||
};
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
let state = Arc::new(AppState::new(host, stats.clone()));
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
|
||||
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
|
||||
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
|
||||
let _ = state.access.set(np.clone());
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
ip = %state.host.local_ip(),
|
||||
native_port = native.port,
|
||||
require_pairing = native.require_pairing,
|
||||
gamestream,
|
||||
@@ -656,10 +680,43 @@ fn load_or_create_uniqueid() -> Result<String> {
|
||||
|
||||
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
|
||||
/// local address the OS would route through. No packets are actually sent.
|
||||
fn primary_local_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
sock.local_addr().ok().map(|a| a.ip())
|
||||
///
|
||||
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
|
||||
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
|
||||
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
|
||||
/// network dependency), so it falls back to the first non-loopback interface address, which the
|
||||
/// NIC has as soon as it is configured even if the default route is not installed yet.
|
||||
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
|
||||
let routed = UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|sock| {
|
||||
sock.connect("8.8.8.8:80")?;
|
||||
sock.local_addr()
|
||||
})
|
||||
.ok()
|
||||
.map(|a| a.ip())
|
||||
.filter(|ip| usable_lan_ip(*ip));
|
||||
routed.or_else(first_lan_ipv4)
|
||||
}
|
||||
|
||||
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
|
||||
///
|
||||
/// Split out because this is the branch the boot race actually takes, and the one nothing would
|
||||
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
|
||||
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
|
||||
/// back to loopback for good.
|
||||
fn first_lan_ipv4() -> Option<IpAddr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.map(|i| i.ip())
|
||||
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
|
||||
}
|
||||
|
||||
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
|
||||
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
|
||||
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
|
||||
fn usable_lan_ip(ip: IpAddr) -> bool {
|
||||
!ip.is_loopback() && !ip.is_unspecified()
|
||||
}
|
||||
|
||||
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
|
||||
@@ -716,6 +773,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the operator's per-client display labels persist, keyed by certificate fingerprint.
|
||||
///
|
||||
/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a
|
||||
/// bare `Vec<Vec<u8>>` of certificate DERs — giving it a shape would be a migration on the one file
|
||||
/// that decides who may connect — and a label is not part of that trust decision, so a corrupt or
|
||||
/// missing label file must never be able to lock anybody out. Losing this file loses names, nothing
|
||||
/// else.
|
||||
///
|
||||
/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME
|
||||
/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity
|
||||
/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical
|
||||
/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint.
|
||||
fn labels_path() -> Option<std::path::PathBuf> {
|
||||
Some(pf_paths::config_dir().join("client-labels.json"))
|
||||
}
|
||||
|
||||
/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would
|
||||
/// otherwise race on a whole-file rewrite and silently drop one of the two names.
|
||||
static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a
|
||||
/// label is cosmetic, so every failure degrades to "no names" and never to an error).
|
||||
pub(crate) fn load_client_labels() -> std::collections::BTreeMap<String, String> {
|
||||
let Some(path) = labels_path() else {
|
||||
return Default::default();
|
||||
};
|
||||
let Ok(raw) = std::fs::read(&path) else {
|
||||
return Default::default();
|
||||
};
|
||||
serde_json::from_slice(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names");
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored
|
||||
/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree
|
||||
/// regardless of how the caller cased the path parameter.
|
||||
pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option<String> {
|
||||
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fp = fp_hex.to_ascii_lowercase();
|
||||
let mut labels = load_client_labels();
|
||||
let stored = match label {
|
||||
Some(l) => {
|
||||
let clean = crate::native_pairing::sanitize_device_name(l, &fp);
|
||||
labels.insert(fp, clean.clone());
|
||||
Some(clean)
|
||||
}
|
||||
None => {
|
||||
labels.remove(&fp);
|
||||
None
|
||||
}
|
||||
};
|
||||
save_client_labels(&labels);
|
||||
stored
|
||||
}
|
||||
|
||||
/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the
|
||||
/// file cannot grow without bound as devices come and go, and so a re-pairing of the same
|
||||
/// certificate starts unnamed rather than inheriting a stranger's name.
|
||||
pub(crate) fn retain_client_labels(still_paired: &[Vec<u8>]) {
|
||||
use sha2::{Digest, Sha256};
|
||||
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let live: std::collections::BTreeSet<String> = still_paired
|
||||
.iter()
|
||||
.map(|der| hex::encode(Sha256::digest(der)))
|
||||
.collect();
|
||||
let mut labels = load_client_labels();
|
||||
let before = labels.len();
|
||||
labels.retain(|fp, _| live.contains(fp));
|
||||
if labels.len() != before {
|
||||
save_client_labels(&labels);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write
|
||||
/// cannot truncate it.
|
||||
fn save_client_labels(labels: &std::collections::BTreeMap<String, String>) {
|
||||
let Some(path) = labels_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
let bytes = match serde_json::to_vec(labels) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "serializing client labels failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) {
|
||||
tracing::warn!(error = %e, "persisting client labels failed (temp write)");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
tracing::warn!(error = %e, "persisting client labels failed (rename)");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_name_tests {
|
||||
use super::sanitize_display_name;
|
||||
@@ -740,6 +897,52 @@ mod host_name_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_ip_tests {
|
||||
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn loopback_and_unspecified_are_never_advertisable() {
|
||||
// The bug: a host that started before its network did advertised these as its address and
|
||||
// kept doing so for the life of the process.
|
||||
for unusable in [
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
] {
|
||||
assert!(
|
||||
!usable_lan_ip(unusable),
|
||||
"{unusable} must not be advertised"
|
||||
);
|
||||
}
|
||||
for usable in [
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
|
||||
] {
|
||||
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_no_address_rather_than_loopback() {
|
||||
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
|
||||
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
|
||||
// advert keep retrying instead of freezing a wrong answer in place.
|
||||
assert!(primary_local_ip().is_none_or(usable_lan_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_fallback_never_offers_loopback() {
|
||||
// The branch a cold boot takes, before the default route exists. It may legitimately find
|
||||
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
|
||||
// do is hand back the loopback that `get_if_addrs` also reports.
|
||||
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -748,7 +951,6 @@ mod session_tests {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -250,7 +250,7 @@ async fn h_launch(
|
||||
fps = session.fps,
|
||||
rikeyid = session.rikeyid,
|
||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
);
|
||||
xml(session_url_xml(&st, "gamesession")).into_response()
|
||||
}
|
||||
@@ -405,7 +405,7 @@ fn gamestream_admission(
|
||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||
st.host.local_ip
|
||||
st.host.local_ip()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,13 +485,11 @@ fn error_xml() -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn test_state() -> Arc<AppState> {
|
||||
let host = super::super::Host {
|
||||
hostname: "t".into(),
|
||||
uniqueid: "id".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
uniqueid = host.uniqueid,
|
||||
https_port = host.https_port,
|
||||
http_port = host.http_port,
|
||||
local_ip = host.local_ip,
|
||||
local_ip = host.local_ip(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ mod tests {
|
||||
let host = Host {
|
||||
hostname: "test".into(),
|
||||
uniqueid: "uid".into(),
|
||||
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
http_port: 47989,
|
||||
https_port: 47984,
|
||||
os_chain: "linux".into(),
|
||||
|
||||
@@ -1111,6 +1111,21 @@ fn spawn_sender(
|
||||
|
||||
use crate::send_pacing::percentile;
|
||||
|
||||
/// How long to ignore further keyframe requests after emitting one.
|
||||
///
|
||||
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
|
||||
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
|
||||
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
|
||||
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
|
||||
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
|
||||
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
|
||||
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
|
||||
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
|
||||
/// on a saturated link.
|
||||
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
|
||||
(frame_interval * 2).max(Duration::from_millis(100))
|
||||
}
|
||||
|
||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1194,6 +1209,11 @@ fn stream_body(
|
||||
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
|
||||
// and a deeper ring is free pipelining the fallback would forfeit.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
|
||||
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
|
||||
// Both sites that swap `enc` re-bind `frame` with it, so this is always
|
||||
// `(frame.format, frame.width, frame.height)` right after one.
|
||||
let mut enc_src = (frame.format, frame.width, frame.height);
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
@@ -1273,9 +1293,9 @@ fn stream_body(
|
||||
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
|
||||
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
|
||||
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
|
||||
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
|
||||
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = frame_interval * 2;
|
||||
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
|
||||
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
|
||||
let mut last_keyframe: Option<Instant> = None;
|
||||
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
|
||||
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
|
||||
@@ -1362,6 +1382,7 @@ fn stream_body(
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
@@ -1375,6 +1396,82 @@ fn stream_body(
|
||||
}
|
||||
}
|
||||
let t_cap = tick.elapsed();
|
||||
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
|
||||
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
|
||||
// the virtual display, or an HDR flip changing the format), and the encoder is the one
|
||||
// component that cannot follow a resolution change in place. Every `submit` below then
|
||||
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
|
||||
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
|
||||
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
|
||||
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
|
||||
//
|
||||
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
|
||||
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
|
||||
// That is the same bargain the first open above already takes whenever the captured size
|
||||
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
|
||||
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
|
||||
// Taking it here too is strictly better than the alternative, which is ending every stream
|
||||
// the moment a game changes mode.
|
||||
if enc_src != (frame.format, frame.width, frame.height) {
|
||||
match encode::open_video(
|
||||
cfg.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
|
||||
cursor_blend, // same capture cursor mode — see the first open
|
||||
cfg.slices, // client slicing ceiling — see the first open
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
|
||||
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
|
||||
negotiated = ?(cfg.width, cfg.height),
|
||||
"gamestream: the capture source changed mode mid-stream — reopened the \
|
||||
encoder at the delivered size (the client is not told; a strict decoder \
|
||||
may not follow — see the note at this guard)"
|
||||
);
|
||||
enc = e;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// A rebuilt encoder starts unconfigured — same reasons as the first open.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
// The old encoder died with its in-flight submissions — their AUs will never
|
||||
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
|
||||
// the capture rebuild above). Restart the stall clock for the fresh encoder and
|
||||
// give it the full reset budget.
|
||||
enc_inflight = 0;
|
||||
encoder_resets = 0;
|
||||
last_au_at = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
|
||||
// this is exactly the kind of event that leaves the driver settling, which is
|
||||
// what the submit ladder's backoff exists for. Spend the shared reset budget at
|
||||
// the same exponential pace, re-entering this guard each round — the old encoder
|
||||
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS {
|
||||
return Err(e).context("reopen encoder at the source's new mode");
|
||||
}
|
||||
let backoff = frame_interval
|
||||
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"gamestream: reopening the encoder at the source's new mode failed — retrying");
|
||||
next_frame = Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
@@ -1716,6 +1813,27 @@ mod tests {
|
||||
assert_eq!(t.game.title, "/opt/game/run");
|
||||
}
|
||||
|
||||
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
|
||||
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
|
||||
/// arrives far slower than two frame intervals and so passes the gate every time.
|
||||
#[test]
|
||||
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
|
||||
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
|
||||
// pre-floor window was 16.7 ms, so every request became a full IDR.
|
||||
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
|
||||
assert!(
|
||||
at_120 >= Duration::from_millis(100),
|
||||
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
|
||||
);
|
||||
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
|
||||
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
|
||||
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
|
||||
assert_eq!(
|
||||
keyframe_coalesce_window(Duration::from_millis(200)),
|
||||
Duration::from_millis(400)
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
||||
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
||||
#[test]
|
||||
|
||||
@@ -55,7 +55,14 @@ pub struct DetectSpec {
|
||||
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
|
||||
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
|
||||
/// sharpest signal available: Steam wraps every launch — native or Proton — in
|
||||
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
|
||||
/// `reaper SteamLaunch AppId=<appid>`.
|
||||
///
|
||||
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
|
||||
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
|
||||
/// and only the last of them is the game. Reading the first as the game is what dropped a
|
||||
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
|
||||
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
|
||||
/// window before believing any of them.
|
||||
pub steam_appid: Option<u32>,
|
||||
/// A launcher-stamped environment marker.
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
|
||||
@@ -105,6 +105,9 @@ mod plugins;
|
||||
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||
mod procscan;
|
||||
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
|
||||
// where `procscan` can only look at the process table.
|
||||
mod runstate;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
|
||||
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
clients::list_paired_clients,
|
||||
clients::unpair_all_clients
|
||||
))
|
||||
.routes(routes!(clients::unpair_client));
|
||||
// DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above.
|
||||
.routes(routes!(clients::unpair_client, clients::rename_client));
|
||||
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
|
||||
// build's API (and its OpenAPI document) simply has no such endpoints.
|
||||
#[cfg(feature = "gamestream")]
|
||||
@@ -372,6 +373,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
library::reconcile_provider_entries,
|
||||
library::delete_provider_entries
|
||||
))
|
||||
.routes(routes!(library::report_provider_running))
|
||||
.routes(routes!(library::get_library_art))
|
||||
.routes(routes!(stats::stats_capture_start))
|
||||
.routes(routes!(stats::stats_capture_stop))
|
||||
|
||||
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
(&Method::DELETE, "/api/v1/library/custom/{}"),
|
||||
(&Method::PUT, "/api/v1/library/provider/{}"),
|
||||
(&Method::DELETE, "/api/v1/library/provider/{}"),
|
||||
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
|
||||
// report through the catalog, so a plugin can only ever speak about entries it published,
|
||||
// and the worst a defective one can do to someone else's session is nothing at all.
|
||||
(&Method::PUT, "/api/v1/library/provider/{}/running"),
|
||||
// Stats / telemetry.
|
||||
(&Method::POST, "/api/v1/stats/capture/start"),
|
||||
(&Method::POST, "/api/v1/stats/capture/stop"),
|
||||
|
||||
@@ -11,7 +11,17 @@ pub(crate) struct PairedClient {
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: String,
|
||||
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
|
||||
///
|
||||
/// Do not display this as a device name. Every moonlight-common-c client self-signs with that
|
||||
/// same fixed subject, so it identifies the *protocol*, not the device — a list of paired
|
||||
/// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show.
|
||||
subject: Option<String>,
|
||||
/// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).
|
||||
///
|
||||
/// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because
|
||||
/// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device.
|
||||
#[schema(example = "Living Room TV")]
|
||||
label: Option<String>,
|
||||
/// Certificate validity start (unix seconds).
|
||||
not_before_unix: Option<i64>,
|
||||
/// Certificate validity end (unix seconds).
|
||||
@@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients(
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
Json(ders.iter().map(|der| client_info(der)).collect())
|
||||
// One read of the label sidecar for the whole list, not one per row.
|
||||
let labels = crate::gamestream::load_client_labels();
|
||||
Json(ders.iter().map(|der| client_info(der, &labels)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
|
||||
pub(crate) fn client_info(
|
||||
der: &[u8],
|
||||
labels: &std::collections::BTreeMap<String, String>,
|
||||
) -> PairedClient {
|
||||
let fingerprint = hex::encode(Sha256::digest(der));
|
||||
let label = labels.get(&fingerprint).cloned();
|
||||
match x509_parser::parse_x509_certificate(der) {
|
||||
Ok((_, x509)) => PairedClient {
|
||||
fingerprint,
|
||||
subject: Some(x509.subject().to_string()),
|
||||
not_before_unix: Some(x509.validity().not_before.timestamp()),
|
||||
not_after_unix: Some(x509.validity().not_after.timestamp()),
|
||||
label,
|
||||
fingerprint,
|
||||
},
|
||||
Err(_) => PairedClient {
|
||||
fingerprint,
|
||||
subject: None,
|
||||
not_before_unix: None,
|
||||
not_after_unix: None,
|
||||
label,
|
||||
fingerprint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of `PATCH /clients/{fingerprint}` — the device's display name.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RenameClient {
|
||||
/// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and
|
||||
/// the device goes back to being listed by fingerprint alone.
|
||||
///
|
||||
/// Scrubbed before storage by the same sanitizer the native plane runs on device names:
|
||||
/// control characters and Unicode bidi overrides are stripped (they could make one paired
|
||||
/// device impersonate another in this very list), whitespace collapsed, and the result capped
|
||||
/// at 64 characters.
|
||||
#[schema(example = "Living Room TV")]
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
/// Rename a paired client
|
||||
///
|
||||
/// Sets or clears the operator-visible display name for one paired Moonlight client. This is
|
||||
/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to
|
||||
/// tell paired devices apart: every moonlight-common-c client self-signs with the identical
|
||||
/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable
|
||||
/// only by fingerprint. The name is stored beside the pairing store and survives host restarts;
|
||||
/// unpairing the device forgets it.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/clients/{fingerprint}",
|
||||
tag = "clients",
|
||||
operation_id = "renameClient",
|
||||
params(
|
||||
("fingerprint" = String, Path,
|
||||
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
|
||||
),
|
||||
request_body = RenameClient,
|
||||
responses(
|
||||
(status = OK, description = "The client as it now reads", body = PairedClient),
|
||||
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn rename_client(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Json(body): Json<RenameClient>,
|
||||
) -> Response {
|
||||
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
|
||||
);
|
||||
}
|
||||
// Only name a device that is actually paired: a label for an unknown fingerprint would be
|
||||
// invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup
|
||||
// only ever removes labels whose device WAS paired.
|
||||
let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(der) = paired
|
||||
.iter()
|
||||
.find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint))
|
||||
.cloned()
|
||||
else {
|
||||
return api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no paired client with that fingerprint",
|
||||
);
|
||||
};
|
||||
drop(paired);
|
||||
// An all-whitespace name is a cleared name, not a device called " ": the sanitizer would
|
||||
// otherwise turn it into the "device <fp8>" fallback and the row would look renamed.
|
||||
let wanted = body
|
||||
.label
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty());
|
||||
crate::gamestream::set_client_label(&fingerprint, wanted);
|
||||
let labels = crate::gamestream::load_client_labels();
|
||||
(StatusCode::OK, Json(client_info(&der, &labels))).into_response()
|
||||
}
|
||||
|
||||
/// Unpair a client
|
||||
///
|
||||
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
|
||||
@@ -119,6 +214,9 @@ pub(crate) async fn unpair_client(
|
||||
// restart, which now also matters below: a resurrected pairing would silently
|
||||
// re-open the control port.
|
||||
crate::gamestream::save_paired(&paired);
|
||||
// Forget this device's display name with it, so the file can't grow without bound and a
|
||||
// later re-pairing of the same certificate starts unnamed.
|
||||
crate::gamestream::retain_client_labels(&paired);
|
||||
drop(paired);
|
||||
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
|
||||
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
|
||||
@@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Resp
|
||||
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
|
||||
// silently re-open the control port.
|
||||
crate::gamestream::save_paired(&paired);
|
||||
// Nothing is paired any more, so no label can still belong to anyone.
|
||||
crate::gamestream::retain_client_labels(&paired);
|
||||
drop(paired);
|
||||
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
|
||||
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
|
||||
|
||||
@@ -23,13 +23,16 @@ pub(crate) struct Health {
|
||||
abi_version: u32,
|
||||
}
|
||||
|
||||
/// Host identity and advertised capabilities (static for the life of the process).
|
||||
/// Host identity and advertised capabilities (static for the life of the process, except
|
||||
/// `local_ip`).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HostInfo {
|
||||
hostname: String,
|
||||
/// Stable per-host id (persisted across restarts), matched on pairing.
|
||||
uniqueid: String,
|
||||
/// Best-effort primary LAN IP.
|
||||
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
|
||||
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
|
||||
/// host that moves networks reports the new one. Poll it rather than caching it.
|
||||
local_ip: String,
|
||||
/// `punktfunk-host` crate version.
|
||||
version: String,
|
||||
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
|
||||
Json(HostInfo {
|
||||
hostname: h.hostname.clone(),
|
||||
uniqueid: h.uniqueid.clone(),
|
||||
local_ip: h.local_ip.to_string(),
|
||||
local_ip: h.local_ip().to_string(),
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
app_version: APP_VERSION.into(),
|
||||
|
||||
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
if removed > 0 {
|
||||
tracing::info!(provider, removed, "library provider entries removed");
|
||||
}
|
||||
// Its entries are gone, so its opinions about them are meaningless — and a lease must
|
||||
// never be held open by a provider that no longer exists.
|
||||
crate::runstate::forget(&provider);
|
||||
Json(ProviderRemoved { removed }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One running title in a provider's liveness report.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RunningTitle {
|
||||
/// The provider's own stable id for the title — the same key its reconcile payload uses.
|
||||
pub external_id: String,
|
||||
/// The process id the provider started for it, when it knows one. Optional, and never trusted
|
||||
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
|
||||
/// signalled, so a stale or recycled pid simply contributes nothing.
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Request body for `reportProviderRunning`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningInput {
|
||||
/// Every title of this provider's that is running **right now**. The full set, not a delta:
|
||||
/// anything absent from it is reported as stopped.
|
||||
#[serde(default)]
|
||||
pub running: Vec<RunningTitle>,
|
||||
}
|
||||
|
||||
/// The result of a liveness report.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ProviderRunningAccepted {
|
||||
/// How many reported titles matched an entry this provider currently publishes.
|
||||
matched: usize,
|
||||
/// How many were ignored because no such entry exists (a report that raced a reconcile).
|
||||
unknown: usize,
|
||||
/// Seconds this report stays authoritative without being restated — re-report inside it while
|
||||
/// anything is running.
|
||||
ttl_s: u64,
|
||||
}
|
||||
|
||||
/// Report which of a provider's titles are running
|
||||
///
|
||||
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
|
||||
/// recognize* a title's process, this one says *it is running now* (design §9,
|
||||
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
|
||||
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
|
||||
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
|
||||
/// game, a manually added one) could not derive at all.
|
||||
///
|
||||
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
|
||||
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
|
||||
/// report rather than drifting.
|
||||
///
|
||||
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
|
||||
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
|
||||
/// dies with a game running stops counting shortly after, and the host falls back to process
|
||||
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
|
||||
/// inside the window.
|
||||
///
|
||||
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
|
||||
/// a report may legitimately race its own reconcile.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}/running",
|
||||
tag = "library",
|
||||
operation_id = "reportProviderRunning",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
request_body = ProviderRunningInput,
|
||||
responses(
|
||||
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn report_provider_running(
|
||||
Path(provider): Path<String>,
|
||||
ApiJson(input): ApiJson<ProviderRunningInput>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
|
||||
// titles by `external_id`; a lease knows them by the library id the catalog assigned
|
||||
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
|
||||
// this authorization-safe, since a provider can only ever speak about entries it published.
|
||||
let mine: Vec<(String, String)> = crate::library::load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
|
||||
.filter_map(|e| {
|
||||
let external = e.external_id.clone()?;
|
||||
Some((external, crate::library::library_id_for(&e)))
|
||||
})
|
||||
.collect();
|
||||
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
|
||||
|
||||
let mut running = std::collections::HashMap::new();
|
||||
let mut unknown = 0usize;
|
||||
for t in &input.running {
|
||||
match mine.iter().find(|(external, _)| *external == t.external_id) {
|
||||
Some((_, id)) => {
|
||||
running.insert(id.clone(), t.pid);
|
||||
}
|
||||
None => unknown += 1,
|
||||
}
|
||||
}
|
||||
let matched = running.len();
|
||||
tracing::debug!(
|
||||
provider,
|
||||
owned = owned.len(),
|
||||
matched,
|
||||
unknown,
|
||||
"provider liveness report"
|
||||
);
|
||||
crate::runstate::report(&provider, owned, running);
|
||||
Json(ProviderRunningAccepted {
|
||||
matched,
|
||||
unknown,
|
||||
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
|
||||
@@ -47,7 +47,6 @@ use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
os_chain: "linux/arch/steamos".into(),
|
||||
@@ -821,6 +819,54 @@ async fn status_reflects_runtime_state() {
|
||||
assert!(!body.to_string().contains("gcm"));
|
||||
}
|
||||
|
||||
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
|
||||
/// value back on drop even if an assertion panics.
|
||||
///
|
||||
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
|
||||
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
|
||||
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
|
||||
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
|
||||
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
|
||||
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
|
||||
/// while this still holds the lock.
|
||||
struct ConfigDirOverride {
|
||||
tmp: tempfile::TempDir,
|
||||
prev: Option<std::ffi::OsString>,
|
||||
_serial: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl ConfigDirOverride {
|
||||
fn new() -> ConfigDirOverride {
|
||||
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
|
||||
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
|
||||
// that reads or writes this variable.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
|
||||
ConfigDirOverride { tmp, prev, _serial }
|
||||
}
|
||||
|
||||
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
|
||||
/// subdirectory appended.
|
||||
fn path(&self) -> &std::path::Path {
|
||||
self.tmp.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigDirOverride {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
|
||||
// runs under the same serialization as the `set_var` in `new`.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
|
||||
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
|
||||
// need the executor while we hold it.
|
||||
@@ -830,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
|
||||
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
|
||||
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
|
||||
// The guard restores the previous value even if an assertion below panics.
|
||||
struct EnvGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
|
||||
// serializes every test that writes or reads this variable in the binary.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
|
||||
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
|
||||
// writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
|
||||
let tmp = ConfigDirOverride::new();
|
||||
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
@@ -1003,6 +1030,137 @@ async fn paired_clients_list_and_unpair() {
|
||||
assert_eq!(body["unpaired"], 0);
|
||||
}
|
||||
|
||||
/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup.
|
||||
///
|
||||
/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight
|
||||
/// devices — their certificates all carry the same subject — so "the name silently didn't stick"
|
||||
/// is indistinguishable from "the device is the other one" in the console.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() {
|
||||
let tmp = ConfigDirOverride::new();
|
||||
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
let stand_in = crate::identity::ephemeral().unwrap();
|
||||
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
|
||||
let der = pem.contents.clone();
|
||||
let fingerprint = hex::encode(Sha256::digest(&der));
|
||||
{
|
||||
let mut p = state.paired.lock().unwrap();
|
||||
p.clear();
|
||||
p.push(der.clone());
|
||||
}
|
||||
|
||||
let patch = |fp: String, body: serde_json::Value| {
|
||||
axum::http::Request::patch(format!("/api/v1/clients/{fp}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Unnamed until somebody names it — the field is absent, not an empty string.
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert!(body[0]["label"].is_null());
|
||||
|
||||
// Name it (uppercase fingerprint must match too — the path is documented case-insensitive).
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.to_uppercase(),
|
||||
serde_json::json!({ "label": "Living Room TV" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["label"], "Living Room TV");
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert_eq!(body[0]["label"], "Living Room TV");
|
||||
|
||||
// The scrub runs: a bidi override could make one paired device read like another in the very
|
||||
// list an operator uses to decide what to unpair, and the whitespace collapse keeps the name
|
||||
// one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.)
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["label"], "Deckevil x");
|
||||
|
||||
// Whitespace-only clears rather than storing a device called " " (or the sanitizer's
|
||||
// "device <fp8>" fallback, which would look like a successful rename).
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(fingerprint.clone(), serde_json::json!({ "label": " " })),
|
||||
)
|
||||
.await;
|
||||
assert!(body["label"].is_null());
|
||||
|
||||
// …and an explicit null clears too.
|
||||
send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": "Bedroom" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let (_, body) = send(
|
||||
&app,
|
||||
patch(fingerprint.clone(), serde_json::json!({ "label": null })),
|
||||
)
|
||||
.await;
|
||||
assert!(body["label"].is_null());
|
||||
|
||||
// Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not
|
||||
// paired would write a label nothing can ever list or clean up).
|
||||
assert_eq!(
|
||||
send(
|
||||
&app,
|
||||
patch("zz".into(), serde_json::json!({ "label": "x" }))
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
send(
|
||||
&app,
|
||||
patch("aa".repeat(32), serde_json::json!({ "label": "x" }))
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
|
||||
// Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of
|
||||
// the same certificate.
|
||||
send(
|
||||
&app,
|
||||
patch(
|
||||
fingerprint.clone(),
|
||||
serde_json::json!({ "label": "Living Room TV" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let on_disk: std::collections::BTreeMap<String, String> =
|
||||
std::fs::read(tmp.path().join("client-labels.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!on_disk.contains_key(&fingerprint),
|
||||
"unpair must forget the device's label, got {on_disk:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gamestream")]
|
||||
#[tokio::test]
|
||||
async fn submit_pin_validates_and_requires_pending_pairing() {
|
||||
@@ -1380,6 +1538,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
// roster's read permission must never carry over to emptying it.
|
||||
("DELETE", "/api/v1/clients", false, false),
|
||||
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
|
||||
// Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than
|
||||
// the roster's read permission: the label is the only thing distinguishing one paired
|
||||
// Moonlight device from another in the console, so anything that could set it could dress
|
||||
// its own device up as the operator's TV — and be trusted, or spared an unpair, on that
|
||||
// basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway.
|
||||
("PATCH", "/api/v1/clients/{fingerprint}", false, false),
|
||||
("GET", "/api/v1/native/clients", true, false),
|
||||
("DELETE", "/api/v1/native/clients", false, false),
|
||||
(
|
||||
@@ -1440,6 +1604,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
("PUT", "/api/v1/library/provider/{provider}", true, false),
|
||||
("DELETE", "/api/v1/library/provider/{provider}", true, false),
|
||||
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
|
||||
// and for the same reason — the host maps the report through the catalog, so a provider can
|
||||
// only ever speak about entries it published. Never the cert lane: a streaming client has
|
||||
// no titles of its own to report on.
|
||||
(
|
||||
"PUT",
|
||||
"/api/v1/library/provider/{provider}/running",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// ---- stats.
|
||||
("POST", "/api/v1/stats/capture/start", true, false),
|
||||
("POST", "/api/v1/stats/capture/stop", true, false),
|
||||
@@ -2935,3 +3109,54 @@ async fn provider_reconcile_validation() {
|
||||
let (s, _) = send(&app, del).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Liveness reporting: the provider id is validated like every other provider write, and a title
|
||||
/// the provider does not publish is *counted*, not refused.
|
||||
///
|
||||
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
|
||||
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
|
||||
/// would throw away the liveness of every other running title, which is precisely the failure the
|
||||
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
|
||||
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
|
||||
/// the case being pinned.
|
||||
#[tokio::test]
|
||||
async fn provider_running_report_validation() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |provider: &str, body: serde_json::Value| {
|
||||
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
assert!(json["error"].as_str().unwrap().contains("reserved"));
|
||||
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An unreported provider is a legitimate report of "nothing is running".
|
||||
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 0);
|
||||
assert!(json["ttl_s"].as_u64().unwrap() > 0);
|
||||
|
||||
// An id this provider does not publish is ignored, not an error.
|
||||
let (s, json) = send(
|
||||
&app,
|
||||
put(
|
||||
"playnite",
|
||||
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(json["matched"], 0);
|
||||
assert_eq!(json["unknown"], 1);
|
||||
|
||||
// A report leaves no opinion behind about a title nobody published, so nothing this test did
|
||||
// can hold a real lease open.
|
||||
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
|
||||
crate::runstate::forget("playnite");
|
||||
}
|
||||
|
||||
@@ -156,9 +156,33 @@ pub struct Punktfunk1Options {
|
||||
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
|
||||
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
|
||||
/// window in which a concurrent session could steal a fixed port.
|
||||
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
///
|
||||
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
|
||||
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
|
||||
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
|
||||
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
|
||||
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
|
||||
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
|
||||
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
|
||||
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
|
||||
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
|
||||
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
|
||||
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
|
||||
fn bind_data_socket(
|
||||
data_port: Option<u16>,
|
||||
local_ip: Option<std::net::IpAddr>,
|
||||
) -> std::io::Result<(std::net::UdpSocket, bool)> {
|
||||
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
|
||||
// socket that will `connect` to a v4 peer — the families have to match.
|
||||
let local_ip = local_ip.map(|ip| match ip {
|
||||
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
|
||||
v4 => v4,
|
||||
});
|
||||
let wildcard = |ip: Option<std::net::IpAddr>| {
|
||||
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
|
||||
};
|
||||
if let Some(p) = data_port.filter(|p| *p != 0) {
|
||||
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
|
||||
Ok(sock) => return Ok((sock, true)),
|
||||
Err(e) => tracing::warn!(
|
||||
data_port = p,
|
||||
@@ -168,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
|
||||
Ok(sock) => Ok((sock, false)),
|
||||
// The control plane arrived on this address moments ago, so a failure here means it just
|
||||
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
|
||||
// routing table can route to — degraded, not dead — so take it and say why.
|
||||
Err(e) if local_ip.is_some() => {
|
||||
tracing::warn!(
|
||||
local_ip = ?local_ip,
|
||||
error = %e,
|
||||
"could not bind the data plane to the address the control connection arrived on \
|
||||
— falling back to the wildcard. On a multi-homed host video may now egress from \
|
||||
a different interface than the client dialed, which it silently drops."
|
||||
);
|
||||
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||
@@ -365,7 +405,6 @@ pub(crate) async fn serve(
|
||||
match crate::gamestream::Host::detect() {
|
||||
Ok(h) => crate::discovery::advertise_native(
|
||||
&h.hostname,
|
||||
h.local_ip,
|
||||
opts.port,
|
||||
&fingerprint_hex(&fingerprint),
|
||||
opts.require_pairing,
|
||||
@@ -1404,6 +1443,12 @@ async fn serve_session(
|
||||
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
|
||||
// trace of why).
|
||||
let cadence_behind_score = Arc::new(AtomicU32::new(0));
|
||||
// Delivery truth, control task → data plane: the packet count the client reports having
|
||||
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
|
||||
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
|
||||
// blames the client for a stream that never reached it.
|
||||
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
|
||||
let client_packets_received_ctl = client_packets_received.clone();
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1535,6 +1580,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
client_packets_received_ctl,
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
reconfig_tx,
|
||||
@@ -2050,6 +2096,10 @@ async fn serve_session(
|
||||
// stages ride the same per-session trace; resizes write their totals into the shared slot.
|
||||
let bringup_dp = bringup.clone();
|
||||
let resize_ms_dp = resize_ms.clone();
|
||||
// The address the control connection arrived on, for the data plane's source-address check
|
||||
// below — the one comparison that distinguishes "the client is filtering our video" from
|
||||
// "the video never left". Captured here because the send loop runs on a blocking thread.
|
||||
let control_local_ip = conn.local_ip();
|
||||
let result: Result<()> = async {
|
||||
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||
@@ -2084,15 +2134,64 @@ async fn serve_session(
|
||||
}
|
||||
};
|
||||
bringup_dp.mark("punch_done");
|
||||
// Post-`connect`, `local_addr` reports the source address the kernel will actually
|
||||
// stamp on every video datagram — the number that has to match the host IP the client
|
||||
// dialed, because its data socket is connected and its kernel drops anything else
|
||||
// before userspace. Logged unconditionally: a black-screen report is unanswerable
|
||||
// without it (this session's showed only the port).
|
||||
let local = transport.local_addr().ok();
|
||||
tracing::info!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
direct,
|
||||
punched,
|
||||
local = ?local,
|
||||
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||
address with no hole-punch; else punched=true → the client's observed source, \
|
||||
false → no punch seen, the reported address)"
|
||||
);
|
||||
// A video source address that isn't the one the control plane arrived on means the
|
||||
// client will discard every datagram we send, however healthy this end looks.
|
||||
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
|
||||
let c = match c {
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
|
||||
}
|
||||
v4 => v4,
|
||||
};
|
||||
if !l.is_unspecified() && l != c {
|
||||
tracing::warn!(
|
||||
video_source_ip = %l,
|
||||
control_local_ip = %c,
|
||||
"the video data plane egresses from a DIFFERENT host address than the one \
|
||||
this client connected to — its data socket is connected to the address it \
|
||||
dialed, so its kernel drops every video datagram before userspace: black \
|
||||
screen, zero reported loss, healthy control plane. Usual cause is two \
|
||||
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
|
||||
a VPN/overlay adapter claiming the route)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
|
||||
// data port the client cannot reach INBOUND, and every client punches (5/s for the
|
||||
// first three seconds, then every two). Video then goes to an address the client only
|
||||
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
|
||||
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
|
||||
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
|
||||
// this port is ephemeral and different every session (fixed by the program-scoped rule
|
||||
// `service install` now adds — an install predating it still has the old rules).
|
||||
// `direct` skips the punch by operator choice, so it is not a failure there.
|
||||
if !direct && !punched {
|
||||
tracing::warn!(
|
||||
%client_udp,
|
||||
udp_port,
|
||||
"no hole-punch reached this host's data port — inbound UDP to it looks \
|
||||
BLOCKED, so video is being sent to the address the client reported without \
|
||||
any confirmed return path. If the picture stays black while the session is \
|
||||
otherwise healthy, this line is the reason: allow inbound UDP for the host \
|
||||
executable (any port), or pin --data-port and open that one"
|
||||
);
|
||||
}
|
||||
let mut session = Session::new(cfg, Box::new(transport))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
match source {
|
||||
@@ -2127,6 +2226,7 @@ async fn serve_session(
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
@@ -2487,7 +2587,7 @@ mod tests {
|
||||
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
|
||||
// caller hole-punches.
|
||||
for req in [None, Some(0)] {
|
||||
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
|
||||
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
|
||||
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
|
||||
assert_ne!(sock.local_addr().unwrap().port(), 0);
|
||||
}
|
||||
@@ -2504,13 +2604,14 @@ mod tests {
|
||||
.port();
|
||||
|
||||
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
|
||||
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
|
||||
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
|
||||
assert!(direct, "a fixed --data-port must stream direct");
|
||||
assert_eq!(held.local_addr().unwrap().port(), free);
|
||||
|
||||
// While it's held, a second session on the same fixed port can't bind it → it must fall
|
||||
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
|
||||
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
|
||||
let (fallback, direct2) =
|
||||
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
|
||||
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
|
||||
assert_ne!(
|
||||
fallback.local_addr().unwrap().port(),
|
||||
@@ -2519,6 +2620,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The multi-homed black screen: video must egress from the address the client's control
|
||||
/// connection arrived on, because the client's data socket is connected to the host address it
|
||||
/// dialed and its kernel drops every datagram from any other source — silently, before
|
||||
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
|
||||
/// choose a different interface whenever the host has two paths to the client.
|
||||
#[test]
|
||||
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
|
||||
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
|
||||
let (sock, direct) =
|
||||
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
|
||||
assert!(!direct);
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
|
||||
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
|
||||
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
|
||||
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
|
||||
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
|
||||
|
||||
// No reported local address (platform can't say) keeps the old wildcard behaviour.
|
||||
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
|
||||
assert!(sock.local_addr().unwrap().ip().is_unspecified());
|
||||
}
|
||||
|
||||
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
|
||||
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
|
||||
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
|
||||
|
||||
@@ -30,6 +30,10 @@ pub(super) async fn run(
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
cadence_behind_score: Arc<AtomicU32>,
|
||||
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
|
||||
// the packets the client says it has received all session (`u32::MAX` = a client too old to
|
||||
// send one, the pre-seeded value).
|
||||
client_packets_received: Arc<AtomicU32>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
@@ -162,6 +166,16 @@ pub(super) async fn run(
|
||||
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
|
||||
// What the client has actually RECEIVED — published unconditionally, because it
|
||||
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
|
||||
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
|
||||
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
|
||||
// Saturated into the u32 bridge; the value only ever matters near zero.
|
||||
client_packets_received.store(
|
||||
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
} else if let Ok(rep) = LossReport::decode(&msg) {
|
||||
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
|
||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||
|
||||
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
// no punch-wait); otherwise a random ephemeral port + hole-punch.
|
||||
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
|
||||
// video from the host IP it dialed (see `bind_data_socket`).
|
||||
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
|
||||
let udp_port = data_sock.local_addr()?.port();
|
||||
|
||||
// The session's video geometry (see the `shard_payload` field below). Resolved before the
|
||||
|
||||
@@ -962,7 +962,15 @@ pub(super) fn input_thread(
|
||||
} else {
|
||||
pads.feedback_poll_interval()
|
||||
};
|
||||
match rx.recv_timeout(poll) {
|
||||
let arrived = rx.recv_timeout(poll);
|
||||
// Every plane's input funnels through here, so this is where the box learns someone is
|
||||
// driving it: any arrival drops a standing suspend veto, so the next press being "Sleep"
|
||||
// in Steam's power menu reaches logind instead of being refused (see `sleep_inhibit`).
|
||||
// Stamped before the grant tests below — a denied event still means a person is there.
|
||||
if arrived.is_ok() {
|
||||
crate::sleep_inhibit::note_input();
|
||||
}
|
||||
match arrived {
|
||||
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
|
||||
// wakes for gyro samples instead of making them wait out the feedback poll interval.
|
||||
// Guarded on the pad grant like every gamepad arm below — see the `grants` parameter.
|
||||
|
||||
@@ -1319,6 +1319,14 @@ pub(super) struct SessionContext {
|
||||
/// of what held it there — the score is the missing discriminator between "the detector's
|
||||
/// budget is wrong" and "this encoder genuinely can't hold cadence").
|
||||
pub(super) cadence_behind_score: Arc<AtomicU32>,
|
||||
/// Data-plane packets the CLIENT says it has received all session, from the latest
|
||||
/// [`punktfunk_core::quic::DeliveryReport`] ([`u32::MAX`] = a client too old to send one).
|
||||
///
|
||||
/// The one signal that distinguishes "the link is clean" from "nothing is arriving": both look
|
||||
/// like `loss_ppm = 0`, because loss is a ratio over the packets that DID arrive. Read by the
|
||||
/// keyframe-cadence diagnosis below, which without it accuses the client of being too slow for
|
||||
/// a stream it has never received a byte of.
|
||||
pub(super) client_packets_received: Arc<AtomicU32>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1598,6 +1606,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -1866,6 +1875,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
mut cur_display_gen,
|
||||
built_bitrate,
|
||||
) = pipe;
|
||||
// What `enc` was opened against. The capture source can change format/size UNDER this loop with
|
||||
// no client `Reconfigure` at all — the IDD-push capturer re-opens its ring on a confirmed
|
||||
// display-descriptor change (a fullscreen game mode-setting the virtual display, an HDR flip) —
|
||||
// and every backend's `submit` then refuses the frame. Tracked so the loop can FOLLOW the
|
||||
// source (see the guard in the submit path) instead of dying against an error no in-place
|
||||
// encoder reset can fix. Every site below that swaps `enc` re-binds `frame` with it, so this is
|
||||
// always `(frame.format, frame.width, frame.height)` immediately after one.
|
||||
let mut enc_src = (frame.format, frame.width, frame.height);
|
||||
// The display exists now, so the portal has answered: settle the cursor plan against what it
|
||||
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
|
||||
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
|
||||
@@ -2604,6 +2621,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
|
||||
// running monitor doesn't advertise and the host falls back to the actual display
|
||||
@@ -2686,6 +2704,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
trace.as_ref(),
|
||||
true,
|
||||
) {
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
@@ -3006,16 +3025,73 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// subsystems while the real chain was: client refused the codec → demoted to
|
||||
// a slower decode rung → could not sustain the rate → standing queue.
|
||||
// Perfect periodicity argues FOR a software cooldown, not against it.
|
||||
if matches_client_flush_cadence(period) {
|
||||
tracing::warn!(
|
||||
let client_rx = client_packets_received.load(Ordering::Relaxed);
|
||||
// The client has TOLD us it has received nothing all session (a v1 client
|
||||
// leaves the `u32::MAX` seed, so this only fires on an explicit zero). That
|
||||
// outranks both cadence verdicts below, which are about a client drowning in
|
||||
// frames — the opposite failure, and indistinguishable by period alone because
|
||||
// a client that got no picture re-asks on its own no-video timer at very
|
||||
// nearly the same spacing. Diagnosing this as "too slow" cost a 2026-08-20
|
||||
// field investigation days: the host was blameless-looking (`sent` climbing,
|
||||
// `loss_ppm = 0`, FEC decayed to the floor) while not one byte of video ever
|
||||
// reached the client.
|
||||
if client_rx == 0 {
|
||||
tracing::error!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries match the client's jump-to-live cooldown \
|
||||
— the CLIENT cannot sustain the stream and is shedding a standing \
|
||||
receive queue (check its log for 'receive backlog stopped draining' \
|
||||
with queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT a \
|
||||
host display disturbance"
|
||||
frames_sent = sent,
|
||||
"THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT — it reports 0 \
|
||||
packets received all session while this host has sent the frames \
|
||||
counted here, so the picture is black and every keyframe we force is \
|
||||
wasted. The control plane is healthy (this report arrived on it), so \
|
||||
the session looks alive: audio, input and the library keep working. \
|
||||
READ THE 'data plane bound' LINE ABOVE — it says which leg failed, \
|
||||
and this line cannot. `punched=false`: the client's hole-punch never \
|
||||
arrived, so inbound UDP to this host's per-session data port is \
|
||||
blocked — open it (the ports are ephemeral, so the rule must be \
|
||||
program-scoped, not port-scoped). `punched=true`: inbound is FINE and \
|
||||
the failure is on the return leg — compare that line's `local=` \
|
||||
source address against the host address this client dialed, because \
|
||||
its data socket is connected and its kernel silently drops video from \
|
||||
any other source. If those match, the datagrams left this host \
|
||||
correctly and the client either never received them (a hop on the \
|
||||
path) or received them and could not open them: this counter is \
|
||||
incremented AFTER decrypt and replay checks, so a session whose every \
|
||||
datagram failed to open reports exactly this same zero"
|
||||
);
|
||||
} else if matches_client_recovery_cooldown(period) {
|
||||
if client_rx == u32::MAX {
|
||||
// This client predates the delivery count, so the period alone has to
|
||||
// carry the verdict — and it CANNOT: both client cooldowns live in this
|
||||
// band and they mean opposite things. Say so instead of picking one.
|
||||
// The old confident wording sent a field investigation after the
|
||||
// decoder for days while the real fault was that nothing arrived.
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
frames_sent = sent,
|
||||
"client keyframe recoveries land on a client software cooldown, \
|
||||
but this client is too old to report whether any video reached \
|
||||
it — so this is EITHER a client that cannot sustain the stream \
|
||||
and is shedding a standing receive queue, OR a client that has \
|
||||
received nothing at all and is re-asking on its no-video timer. \
|
||||
They are opposite faults; the host cannot tell them apart from \
|
||||
the period. Its log does: 'receive backlog stopped draining' \
|
||||
(with queue_depth) means the first, 'no video received … into \
|
||||
the session' means the second. Upgrading the client makes this \
|
||||
line decide on its own"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
client_packets_received = client_rx,
|
||||
"client keyframe recoveries match the client's jump-to-live \
|
||||
cooldown, and it confirms video IS arriving — the CLIENT cannot \
|
||||
sustain the stream and is shedding a standing receive queue \
|
||||
(check its log for 'receive backlog stopped draining' with \
|
||||
queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT \
|
||||
a host display disturbance"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
@@ -3322,6 +3398,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_display_gen;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// The rebuild re-ran `create`, so the portal answered again — possibly a different
|
||||
// backend's portal (the retarget above), possibly with a different verdict. Settle
|
||||
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
|
||||
@@ -3584,6 +3661,106 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// exactly that volume, so host apps already tone-mapped the content into it and the honest
|
||||
// mastering description IS the client's panel. (The IDD capturer only knows the generic
|
||||
// baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.)
|
||||
// Follow an AUTONOMOUS source change — one no client `Reconfigure` announced. The IDD-push
|
||||
// capturer re-opens its ring on a confirmed display-descriptor change: a fullscreen game
|
||||
// mode-setting the virtual display (2026-08-22 field report: a 4K60 HEVC session, the game
|
||||
// switched the display to 1080p mid-play), or an HDR flip changing the frame format. The
|
||||
// encoder is the one component that cannot follow that in place (same note as
|
||||
// `try_inplace_resize`), so every `submit` below refuses the frame — and the submit-error
|
||||
// path only rebuilds the encoder IN PLACE, at the SAME configured size, which cannot fix a
|
||||
// size the source has already left. All five resets burn on it and the session ends while
|
||||
// audio keeps running. Reopen at what the source actually delivers instead; the client
|
||||
// learns the new mode from the `Reconfigured` below and its decoder from the opening IDR.
|
||||
if enc_src != (frame.format, frame.width, frame.height) {
|
||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||
// Same per-mode pin the client-initiated resize re-resolves: PyroWave's Automatic rate
|
||||
// IS a function of the mode, so carrying the old one across a source-driven mode change
|
||||
// hands it the wrong operating point. H.26x rates are mode-independent (ABR owns them),
|
||||
// and an explicit client rate is never second-guessed.
|
||||
let src_kbps = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &actual, plan.chroma, plan.bit_depth)
|
||||
} else {
|
||||
bitrate_kbps
|
||||
};
|
||||
let opened = crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
actual.refresh_hz,
|
||||
src_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
plan.max_slices,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the capture source changed to {}x{} {:?} mid-session and the encoder could not \
|
||||
be reopened at it",
|
||||
frame.width, frame.height, frame.format
|
||||
)
|
||||
});
|
||||
let mut new_enc = match opened {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
// Don't spend the session on the FIRST failed open. The mode-set that triggered
|
||||
// this is exactly the kind of event that leaves the driver settling — the same
|
||||
// transient the submit path's backoff exists for ("NVENC session open failing
|
||||
// after a codec switch", 2026-07) — so spend the shared reset budget on it at
|
||||
// the same exponential pace, re-entering this guard each round. The old encoder
|
||||
// is still installed and still mismatched; it simply keeps failing submit until
|
||||
// an open succeeds or the budget runs out.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS {
|
||||
return Err(e).context("encoder reopen at the source's new mode");
|
||||
}
|
||||
let backoff = std::cmp::max(
|
||||
interval,
|
||||
std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)),
|
||||
);
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"reopening the encoder at the source's new mode failed — retrying");
|
||||
next = std::time::Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(c) = plan.wire_chunk {
|
||||
new_enc.set_wire_chunking(c);
|
||||
}
|
||||
// A rebuilt encoder starts with the ring bound unset — re-report it, as every other
|
||||
// rebuild site does, or an in-place backend can encode a texture the capturer has
|
||||
// already rotated and overwritten.
|
||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
tracing::info!(
|
||||
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
|
||||
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
|
||||
"the capture source changed mode mid-session with no client reconfigure — reopened \
|
||||
the encoder at the delivered size"
|
||||
);
|
||||
enc = new_enc;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
adopt_built_bitrate(&mut bitrate_kbps, src_kbps, &live_bitrate, &retarget_tx);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
// A fresh encoder opens on an IDR — anchor the cooldown.
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
// The client's mode slot still says the old size, and its stats/aspect follow it.
|
||||
// Publish what it is really decoding now, exactly as an accepted resize does.
|
||||
live_mode.store(
|
||||
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let _ = reconfig_result_tx.send(Reconfigured {
|
||||
accepted: true,
|
||||
mode: actual,
|
||||
});
|
||||
}
|
||||
let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m));
|
||||
enc.set_hdr_meta(hdr_meta);
|
||||
let mut resend_meta = hdr_meta != last_hdr_meta;
|
||||
@@ -4191,6 +4368,26 @@ fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
|
||||
period.abs_diff(flush) < flush / 10
|
||||
}
|
||||
|
||||
/// The client's OTHER re-ask cooldown: it has received no video whatsoever and is asking for a
|
||||
/// keyframe on its no-video timer. Kept separate from [`matches_client_flush_cadence`] because the
|
||||
/// two describe opposite faults — drowning in frames versus receiving none — and only the client's
|
||||
/// reported delivery count can say which. Both are host-side-irrelevant either way: a fixed
|
||||
/// software cooldown is never the periodic *disturbance* the metronomic branch reports.
|
||||
///
|
||||
/// Compared against the SHARED constant, never a copy of the number — the same discipline
|
||||
/// [`matches_client_flush_cadence`] follows, and the one that was missing when the two cooldowns
|
||||
/// were both 2000 ms and the host could not even tell that it was guessing.
|
||||
fn matches_client_no_video_cadence(period: std::time::Duration) -> bool {
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
period.abs_diff(no_video) < no_video / 10
|
||||
}
|
||||
|
||||
/// Either client cooldown — the band in which a period tells us about the CLIENT's software, not
|
||||
/// about anything physical on this host.
|
||||
fn matches_client_recovery_cooldown(period: std::time::Duration) -> bool {
|
||||
matches_client_flush_cadence(period) || matches_client_no_video_cadence(period)
|
||||
}
|
||||
|
||||
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||
type Pipeline = (
|
||||
@@ -5068,6 +5265,29 @@ mod tests {
|
||||
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
/// The two client cooldowns must stay TELLABLE APART by period, and both must stay out of the
|
||||
/// display-disturbance branch. While they were both 2000 ms a black-screen field case (nothing
|
||||
/// ever reached the client) was reported as "the client cannot sustain the stream" — the exact
|
||||
/// opposite fault — because the periods were identical and the host guessed.
|
||||
#[test]
|
||||
fn the_two_client_cooldowns_are_distinguishable_and_both_excluded_from_display_blame() {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
assert_ne!(
|
||||
flush, no_video,
|
||||
"identical cooldowns make the host's verdict a coin flip"
|
||||
);
|
||||
// Neither may fall inside the other's ±10% band, or the period stops discriminating.
|
||||
assert!(!matches_client_flush_cadence(no_video));
|
||||
assert!(!matches_client_no_video_cadence(flush));
|
||||
// Both are client software cooldowns: never the metronomic display-disturbance branch.
|
||||
assert!(matches_client_recovery_cooldown(flush));
|
||||
assert!(matches_client_recovery_cooldown(no_video));
|
||||
// A real periodic disturbance still reaches that branch.
|
||||
assert!(!matches_client_recovery_cooldown(flush * 3));
|
||||
assert!(!matches_client_recovery_cooldown(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
|
||||
@@ -87,6 +87,26 @@ pub fn resolve(pid: u32) -> Option<ProcRef> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Short names for the processes a lease adopted, in `procs` order.
|
||||
///
|
||||
/// Diagnostics only — nothing decides anything on these, and they are deliberately not part of
|
||||
/// [`ProcRef`], which is compared for equality. They exist because a launch that adopted the game
|
||||
/// and a launch that adopted a *pre-launch* tree logged identically (`procs=1`), which is what left
|
||||
/// the 2026-08-22 field report unclosable from its log: the one question worth asking of that line
|
||||
/// is which process the lease latched onto.
|
||||
pub fn names(procs: &[ProcRef]) -> Vec<String> {
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
let scanner = Scanner::system();
|
||||
procs.iter().map(|p| scanner.name_of(*p)).collect()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
let _ = procs;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
|
||||
///
|
||||
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
|
||||
|
||||
@@ -126,6 +126,14 @@ impl Scanner {
|
||||
Some(ProcRef { pid, start })
|
||||
}
|
||||
|
||||
/// This process's `comm` — its short name, as `ps` shows it. Diagnostics only (see
|
||||
/// [`super::names`]); `?` for a process that has already gone, which is routine.
|
||||
pub fn name_of(&self, p: ProcRef) -> String {
|
||||
std::fs::read_to_string(self.root.join(p.pid.to_string()).join("comm"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "?".into())
|
||||
}
|
||||
|
||||
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
|
||||
/// so a recycled pid is never reported alive (rule 2).
|
||||
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
|
||||
@@ -180,16 +188,29 @@ impl Scanner {
|
||||
if let Some(tok) = steam_tok {
|
||||
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
|
||||
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
|
||||
//
|
||||
// …with one exception, because the reaper is *not* only the game's: Steam wraps its
|
||||
// shader pre-caching for a title in the same `SteamLaunch AppId=<appid>` reaper it
|
||||
// wraps the game in, so that job satisfies this recipe exactly while the game has
|
||||
// not started yet. Adopting it points the lease at a tree that exits when the
|
||||
// compile finishes, which reads as the game exiting — on Linux that dropped a
|
||||
// Rocket League stream 10 s into a launch, mid-"Processing Vulkan shaders", and the
|
||||
// player had to launch a second time to get a session that stayed up (field report
|
||||
// 2026-08-22). The payload names itself: `fossilize_replay` is Steam's replayer and
|
||||
// is never a game.
|
||||
let mut launch = false;
|
||||
let mut appid = false;
|
||||
let mut shader = false;
|
||||
for arg in cmdline.split(|&b| b == 0) {
|
||||
if arg == b"SteamLaunch" {
|
||||
launch = true;
|
||||
} else if arg == tok.as_bytes() {
|
||||
appid = true;
|
||||
} else if program_name(arg) == b"fossilize_replay" {
|
||||
shader = true;
|
||||
}
|
||||
}
|
||||
if launch && appid {
|
||||
if launch && appid && !shader {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -247,6 +268,15 @@ impl Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// The last `/`-separated component of an argv entry — the program's own name, when the entry is a
|
||||
/// path to one. Bytes rather than `str` because an argv entry is not required to be UTF-8.
|
||||
fn program_name(arg: &[u8]) -> &[u8] {
|
||||
match arg.iter().rposition(|&b| b == b'/') {
|
||||
Some(i) => &arg[i + 1..],
|
||||
None => arg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
|
||||
/// or the file is unreadable — both routine during a scan.
|
||||
fn read_capped(path: &Path) -> Option<Vec<u8>> {
|
||||
@@ -472,6 +502,42 @@ mod tests {
|
||||
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
|
||||
}
|
||||
|
||||
/// The 2026-08-22 field report: Steam's **shader pre-caching** runs under the game's own
|
||||
/// `SteamLaunch AppId=` reaper, so it satisfies the appid recipe while the game has not started.
|
||||
///
|
||||
/// Adopting it is what dropped a Rocket League stream 10 s into a launch — the lease called that
|
||||
/// tree the game, and its exit (the compile finishing) the game exiting. The reaper's payload is
|
||||
/// the whole tell, and it is only ever Steam's replayer.
|
||||
#[test]
|
||||
fn steam_shader_pre_caching_is_not_the_game() {
|
||||
let td = fake_proc_root(
|
||||
1000.0,
|
||||
&[
|
||||
// The shader job for this very appid — the game is still being brought up.
|
||||
FakeProc::new(35, 50_000).cmdline(&[
|
||||
"/home/p/.steam/ubuntu12_32/reaper",
|
||||
"SteamLaunch",
|
||||
"AppId=252950",
|
||||
"--",
|
||||
"/home/p/.steam/steamapps/common/SteamLinuxRuntime/fossilize_replay",
|
||||
"/home/p/.steam/steamapps/shadercache/252950/fozpipelinesv6/steamapprun_pipeline_cache.foz",
|
||||
]),
|
||||
// The game itself, same appid, same reaper. This one IS the game.
|
||||
FakeProc::new(36, 50_000).cmdline(&[
|
||||
"/home/p/.steam/ubuntu12_32/reaper",
|
||||
"SteamLaunch",
|
||||
"AppId=252950",
|
||||
"--",
|
||||
"/home/p/.steam/steamapps/common/Proton/proton",
|
||||
"waitforexitandrun",
|
||||
"/home/p/.steam/steamapps/common/rocketleague/RocketLeague.exe",
|
||||
]),
|
||||
],
|
||||
);
|
||||
let s = scanner(td.path());
|
||||
assert_eq!(pids(s.find(&DetectSpec::steam(252_950), None)), vec![36]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_env_marker_by_exact_value_or_presence() {
|
||||
let td = fake_proc_root(
|
||||
|
||||
@@ -120,6 +120,14 @@ impl Scanner {
|
||||
Some(ProcRef { pid, start })
|
||||
}
|
||||
|
||||
/// This process's image file name. Diagnostics only (see [`super::names`]); `?` for a process
|
||||
/// that has already gone or cannot be opened, which is routine.
|
||||
pub fn name_of(&self, p: ProcRef) -> String {
|
||||
process_start_and_image(p.pid)
|
||||
.and_then(|(_, image)| image.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
/// Which of `procs` are still the same live processes — pid present **and** creation time
|
||||
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
|
||||
/// this check is what makes signalling a remembered pid safe at all.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user