Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ceab2f6c | ||
|
|
1df39d9617 | ||
|
|
e4f8c64b9f | ||
|
|
690ff7016b | ||
|
|
6cffe29b13 | ||
|
|
44c87d7ac1 | ||
|
|
9089651406 | ||
|
|
2a2427afc8 | ||
|
|
d237646c66 | ||
|
|
69728b6f4e | ||
|
|
3bb87d260e | ||
|
|
be57587572 | ||
|
|
8f1c34c6bf | ||
|
|
1ef212a78d | ||
|
|
e044f68500 | ||
|
|
8c94e2517e | ||
|
|
08525e618e | ||
|
|
fa2bcd9dbb | ||
|
|
353091270d | ||
|
|
86bb09e2cf | ||
|
|
deeb8b6700 | ||
|
|
b1e0525872 | ||
|
|
4adea10557 | ||
|
|
4edb662b63 | ||
|
|
10a0ef3283 | ||
|
|
cabd011f1d | ||
|
|
d939c7c14e | ||
|
|
be86cfcdc0 | ||
|
|
e9e1ec7dc5 | ||
|
|
cacfe04a93 | ||
|
|
5fda4d4805 | ||
|
|
959d3eb604 | ||
|
|
b2f08b1a73 | ||
|
|
7f82bca9c0 | ||
|
|
9043332002 | ||
|
|
fca9f42c44 | ||
|
|
3d20f2c0e5 | ||
|
|
7798401f06 | ||
|
|
244cafe005 | ||
|
|
bae8742e48 | ||
|
|
78a02bc994 | ||
|
|
2b167595aa | ||
|
|
a9e7c033c3 | ||
|
|
167e35f720 | ||
|
|
bfed711921 | ||
|
|
c43769282a | ||
|
|
f926bab9f7 | ||
|
|
74270109dd | ||
|
|
70e6b80200 | ||
|
|
12a5318397 | ||
|
|
1482e6b373 | ||
|
|
79afa9ce79 | ||
|
|
bb9f482b2c | ||
|
|
dc116d28ca | ||
|
|
d25a20a233 | ||
|
|
de1bfdc0c5 | ||
|
|
3be7d1d4f8 | ||
|
|
e8a7a1e6af | ||
|
|
f0702f3e06 | ||
|
|
a85e845255 | ||
|
|
fd6241a24f | ||
|
|
834b244301 | ||
|
|
42e4d47743 | ||
|
|
5aeb8d2552 | ||
|
|
3a4c94ad79 | ||
|
|
ee61e8c9ba | ||
|
|
af4d265168 | ||
|
|
a29e366b3e | ||
|
|
f4dda9074b | ||
|
|
1c54d0999b | ||
|
|
4c587ef531 | ||
|
|
6d0a389dd2 | ||
|
|
7a4cdac5b7 | ||
|
|
e81ab1ff2b | ||
|
|
8f1081719f | ||
|
|
979ed8f426 | ||
|
|
4ddc0e311d | ||
|
|
f351eb01e9 | ||
|
|
1eab4b6626 | ||
|
|
5a4305c072 | ||
|
|
19c9165d4b | ||
|
|
c64cdc4ef7 | ||
|
|
6b4be28d24 | ||
|
|
669176982d | ||
|
|
0170da2a5f | ||
|
|
d996449a82 | ||
|
|
efb7f99129 | ||
|
|
a8a4b11f5c | ||
|
|
8805a18375 | ||
|
|
e9ddc3c561 | ||
|
|
a1b286245c | ||
|
|
8551e88fcb | ||
|
|
bca63cd9ff | ||
|
|
eb9203b115 | ||
|
|
32ff93199f | ||
|
|
0ee690f8bc | ||
|
|
6348334eff | ||
|
|
7cab7ae6bc | ||
|
|
aaa58ad817 | ||
|
|
ebc2f1cf92 | ||
|
|
c16e07d746 | ||
|
|
52cd42e911 | ||
|
|
5c4969fd6b | ||
|
|
8c274d6256 | ||
|
|
16e506f943 | ||
|
|
7f822d9c41 | ||
|
|
2eed9823e5 | ||
|
|
9db2c06140 | ||
|
|
d2d5058d0b | ||
|
|
8e8d30202c | ||
|
|
428377eaa1 | ||
|
|
26b0819f5c | ||
|
|
7e34ca3d5e | ||
|
|
cc53b3d6b0 | ||
|
|
5922cbe325 | ||
|
|
ed98814145 | ||
|
|
0cf76af0c2 | ||
|
|
adf92939ec | ||
|
|
f6de620f34 | ||
|
|
b166c53cc2 | ||
|
|
9582da07b4 | ||
|
|
48339326ae | ||
|
|
c06ee55b61 | ||
|
|
7993386900 | ||
|
|
eaa87ca0cc | ||
|
|
0e40b374e7 | ||
|
|
769a8c049d | ||
|
|
2fb80073e6 | ||
|
|
ca667cb79a | ||
|
|
cf68d33e2c | ||
|
|
9e9bb9f466 | ||
|
|
a183cac8aa | ||
|
|
fb1a0a61e9 | ||
|
|
70de58f446 | ||
|
|
f674a06a08 | ||
|
|
49fa344c92 | ||
|
|
c34e1412fb | ||
|
|
6c4df043fe | ||
|
|
cbfa03b7ad | ||
|
|
c010139e6e | ||
|
|
77797a9e20 | ||
|
|
c0f8f051c3 | ||
|
|
43e713ecca | ||
|
|
ce5047f3ad | ||
|
|
31bb3ca7f7 | ||
|
|
d49f955171 | ||
|
|
507ea58da8 | ||
|
|
2830cbe912 | ||
|
|
4834c2ee51 | ||
|
|
413a3e3e76 | ||
|
|
f06b3d9d04 | ||
|
|
99a59fb5c7 | ||
|
|
15dddf7ec0 | ||
|
|
3870cdd1da | ||
|
|
f7ef41b45b | ||
|
|
55aba3e936 | ||
|
|
1f54b75c5c | ||
|
|
79c72fa64d | ||
|
|
64655c5275 | ||
|
|
c6b183450a | ||
|
|
4a621de6b1 | ||
|
|
3a35773b70 | ||
|
|
5aa1ca392e | ||
|
|
0b160a4e22 | ||
|
|
b245cb6b29 | ||
|
|
f49f22a292 | ||
|
|
f1e7ec3535 | ||
|
|
03ee3e55d7 | ||
|
|
152047051c | ||
|
|
ef8214415f | ||
|
|
1535d69852 | ||
|
|
745864423b | ||
|
|
f702f27bd3 |
@@ -160,6 +160,22 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml
|
||||
# lints `--workspace` on the host, where `clients/android/native` and every
|
||||
# `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever
|
||||
# built. Discovered in 2026-08 with five lints already resident — code no gate had ever read.
|
||||
#
|
||||
# Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint
|
||||
# takes, not the full three-ABI build first. It shares sccache and the target dir with the
|
||||
# build that follows, so the compile is not paid twice.
|
||||
#
|
||||
# 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.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
|
||||
# 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
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
|
||||
+206
-8
@@ -48,7 +48,26 @@ on:
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
|
||||
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
|
||||
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
|
||||
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
|
||||
# whole transaction at once, so it did not merely block our package, it blocked those users'
|
||||
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
|
||||
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
|
||||
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
|
||||
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
|
||||
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
|
||||
required: false
|
||||
default: ''
|
||||
pkgrel:
|
||||
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
|
||||
required: false
|
||||
default: '2'
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
@@ -94,7 +113,52 @@ jobs:
|
||||
}
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
|
||||
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
|
||||
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
|
||||
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
|
||||
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
|
||||
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
|
||||
# on this one axis. So heal it in-job and shout, instead of building a dead package.
|
||||
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
|
||||
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
|
||||
run: |
|
||||
export LC_ALL=C # `Provides` is a localized field name
|
||||
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
|
||||
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
|
||||
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
|
||||
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
|
||||
mkdir -p /tmp/pf-archsync
|
||||
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
|
||||
exit 0
|
||||
fi
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
|
||||
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
|
||||
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
|
||||
if [ "$HAVE" = "$WANT" ]; then
|
||||
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
|
||||
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
|
||||
pacman -Syu --noconfirm || true
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
if [ "$HAVE" != "$WANT" ]; then
|
||||
echo "::error::builder still links $HAVE while Arch ships $WANT."
|
||||
echo "::error::Building on would publish a package no Arch box can install."
|
||||
exit 1
|
||||
fi
|
||||
echo "healed: builder now links $HAVE"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
|
||||
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
|
||||
# = checkout's own default, i.e. the triggering ref, for every other trigger.
|
||||
ref: ${{ github.event.inputs.release_tag }}
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
@@ -127,12 +191,30 @@ jobs:
|
||||
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
|
||||
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
|
||||
# it to `1.` would sort canaries ABOVE the release and is not an option.)
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
|
||||
# top): same upstream version, higher pkgrel, straight into the stable repo.
|
||||
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
|
||||
# advertises by STRING order (the same trap the canary zero-padding below exists for),
|
||||
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
|
||||
V="${RELEASE_TAG#v}"
|
||||
R="${REBUILD_PKGREL:-2}"
|
||||
REPO=punktfunk
|
||||
case "$R" in
|
||||
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
|
||||
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
fi
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
@@ -173,6 +255,31 @@ jobs:
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
|
||||
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
|
||||
# each into `libavcodec.so=<soname>-<arch>` from the built binary's DT_NEEDED; if that
|
||||
# rewrite ever stops happening — Arch dropping the soname `provides`, someone "tidying" the
|
||||
# entries out of `depends`, a makepkg change — the dep silently degrades to an unversioned
|
||||
# name that ANY ffmpeg satisfies. That is precisely the 2026-08-08 state in which `pacman
|
||||
# -Syu` walked every Arch/CachyOS install across the FFmpeg 8 -> 9 soname bump and left the
|
||||
# host unable to start (exit 127 before main(), restart loop). The failure is invisible in a
|
||||
# green build and only shows up as a bricked box weeks later, so assert it here.
|
||||
- name: Assert the host pins the FFmpeg soname
|
||||
run: |
|
||||
PKG="$(ls "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst | head -1)"
|
||||
DEPS="$(bsdtar -xOf "$PKG" .PKGINFO | sed -n 's/^depend = //p')"
|
||||
echo "$DEPS" | sed 's/^/ depend = /'
|
||||
for lib in libavcodec libavutil; do
|
||||
echo "$DEPS" | grep -qE "^$lib\.so=[0-9]+-[0-9]+$" || {
|
||||
echo "::error::punktfunk-host declares no VERSIONED $lib.so dependency."
|
||||
echo "::error::makepkg did not expand the bare soname from DT_NEEDED, so pacman can"
|
||||
echo "::error::upgrade FFmpeg across a soname break and brick the install."
|
||||
echo "::error::See the depends comment in packaging/arch/PKGBUILD."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
||||
|
||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||
# completely different dependency set, published into the same repo so `pacman -S
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
@@ -210,6 +317,63 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
|
||||
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
|
||||
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
|
||||
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
|
||||
# one transaction, their whole system upgrade stopped there. So ask the only question that
|
||||
# matters before publishing: would a real, up-to-date Arch box install this?
|
||||
#
|
||||
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
|
||||
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
|
||||
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
|
||||
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
|
||||
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
|
||||
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
|
||||
- name: Assert every package installs on an up-to-date Arch box
|
||||
run: |
|
||||
export LC_ALL=C
|
||||
mkdir -p /tmp/pf-instcheck
|
||||
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::error::could not sync the Arch db — cannot prove these packages install"
|
||||
exit 1
|
||||
fi
|
||||
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
|
||||
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
|
||||
}
|
||||
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
|
||||
rc=0
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
rc=1
|
||||
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
|
||||
# install is dropped from the upload with a warning, never a reason to withhold the
|
||||
# packages this workflow exists to publish. (It is also the one package that can be
|
||||
# restored from a cache older than the current Arch snapshot.)
|
||||
for pkg in dist-gamescope/*.pkg.tar.zst; do
|
||||
[ -e "$pkg" ] || continue
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
rm -f "$pkg"
|
||||
fi
|
||||
done
|
||||
if [ "$rc" != 0 ]; then
|
||||
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
|
||||
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
|
||||
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
|
||||
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
@@ -237,14 +401,48 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
|
||||
# rebuild attaches to that SAME release object: the release page is a distribution surface
|
||||
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
|
||||
# someone the exact break the rebuild exists to fix.
|
||||
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||
RID=$(ensure_release "$TAG" "$TAG" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
|
||||
# upsert_asset only replaces by name — the superseded set would survive untouched.
|
||||
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
|
||||
#
|
||||
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
|
||||
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
|
||||
# so it can only ever offer the helpers that existed when that tag was cut. A helper
|
||||
# added for this feature is therefore guaranteed ABSENT in the one code path that
|
||||
# calls it: the first attempt failed with `prune_release_assets: command not found`
|
||||
# after publishing perfectly. Only the workflow file itself is taken from the ref you
|
||||
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
|
||||
# that tag — the PKGBUILD is the tag's too.
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
|
||||
# An UNMATCHED glob would come through literally and match nothing in the keep set —
|
||||
# i.e. "delete every pacman asset on the release". Skip entirely instead.
|
||||
case "$KEEP" in *'*'*) KEEP="" ;; esac
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -n "$KEEP" ]; then
|
||||
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
|
||||
| while read -r id name; do
|
||||
[ -n "$id" ] || continue
|
||||
echo "dropping superseded release asset: $name"
|
||||
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
|
||||
-H "Authorization: token $GITEA_TOKEN" || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -66,6 +66,13 @@ jobs:
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
|
||||
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
|
||||
# here, on the same config and pinned biome version the plugins use.
|
||||
- name: Lint & format
|
||||
working-directory: plugin-kit
|
||||
run: bun run check
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
|
||||
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope).
|
||||
name: sbom
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
|
||||
@@ -150,13 +150,6 @@ jobs:
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
|
||||
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
|
||||
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
|
||||
# (the daemon env only refreshes on a runner-task restart).
|
||||
if (-not $env:VBCABLE_DIR) {
|
||||
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
@@ -406,7 +399,6 @@ jobs:
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
|
||||
+717
@@ -0,0 +1,717 @@
|
||||
# Changelog
|
||||
|
||||
Protocol, ABI, driver and embedder detail, one section per stable release, newest first.
|
||||
|
||||
This is the **technical** half of a release. The other half — what changed for people who *use*
|
||||
Punktfunk — is `docs/releases/vX.Y.Z.md`, and it deliberately contains no internal names. The two
|
||||
were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown
|
||||
long enough to bury the user-facing half it was appended to. See `docs/releases/README.md`.
|
||||
|
||||
If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start
|
||||
with the version table of the release you are moving to, then read **Breaking changes**.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0
|
||||
|
||||
407 commits since v0.24.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.24.0 | v0.25.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged — every addition below is optional or capability-gated |
|
||||
| C ABI | 14 | **17** | three steps; see below |
|
||||
| Workspace crate dirs | 22 | **26** | `pf-bitstream` (+ vendored `cros-codecs`), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec` added; `pf-ffvk` removed |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.23.0 | **0.24.0** | tracks API edits, lags one release by convention |
|
||||
|
||||
`crates/pf-driver-proto` is byte-for-byte identical to v0.24.0 — if you ship the virtual-display
|
||||
driver or the gamepad channel, nothing in this release touches you.
|
||||
|
||||
**Why the wire did not move.** It grew a lot and still did not break: an optional trailing
|
||||
`max_shard_payload: u16` on `Hello` (absent/0 = legacy, doubling as the renegotiation capability
|
||||
flag and the jumbo receive ceiling); two control messages `ShardPayloadChanged` (`0x08`) and
|
||||
`ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` beside the plain `0xC9`; a
|
||||
controller-audio plane at `0xD1`; a new `0xCD` kind `0x06`; arrival flag bits 8/9; and
|
||||
`MAX_DATAGRAM_BYTES` 2048 → 9216. Old peers never send or read any of it. Bump `WIRE_VERSION` only
|
||||
when the handshake or planes change *incompatibly* — riding a C-ABI bump onto the wire once locked
|
||||
every new client out of every deployed host (`ABI mismatch: client 3 host 2`, observed live).
|
||||
|
||||
### C ABI 14 → 17
|
||||
|
||||
- **v15 — the rumble policy engine's C surface.** `punktfunk_connection_next_rumble_cmd`,
|
||||
`punktfunk_connection_set_rumble_quirks`, `PUNKTFUNK_RUMBLE_QUIRK_*`. These symbols are **not
|
||||
new**: they landed while the constant still read 7 and no bump was made, so every core since has
|
||||
exported them while advertising a version that never promised them. A shipped binary says what it
|
||||
says, so this cannot be corrected retroactively — **v15 is the floor that guarantees them.** At or
|
||||
above 15 the surface is present; below it, probe for the symbol. No code changed with this bump.
|
||||
- **v16 — the controller-audio client surface.** `punktfunk_connection_next_pad_audio` (the `0xD1`
|
||||
per-gamepad DualSense haptics/speaker plane), `punktfunk_connection_set_pad_audio_caps`, and the
|
||||
`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors.
|
||||
- **v17 — session end reason.** `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*`
|
||||
vocabulary: after a session ends, ask *why* — this client closed it, the host's launched game
|
||||
exited (its close carried `APP_EXITED_CLOSE_CODE`, which the host had been sending for a long time
|
||||
with nothing consuming it), the host ended it cleanly, the host reported a failure, or the
|
||||
connection was lost. Purely a read of state the core already had: **no new call is required of an
|
||||
embedder**, a client that never calls it is unchanged, and the host sends identical bytes either
|
||||
way.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**1. 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in
|
||||
header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and
|
||||
the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the
|
||||
header.
|
||||
|
||||
*What to do:* add the prefix. Values are identical; the change is mechanical.
|
||||
|
||||
*It cannot break silently.* The old spellings cease to exist, so this is always an
|
||||
undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A
|
||||
colliding `#define` does **not** fail to compile: the preprocessor silently takes the last
|
||||
definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at
|
||||
runtime. Associated constants are untouched; the generator already qualifies those by type name.
|
||||
|
||||
**2. Linux hosts: the virtual Steam Deck controller moved to its own `punktfunk` group.** The
|
||||
capability rode on `input`, which every gamepad guide tells users to join — but it can emulate
|
||||
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
||||
attaching. Ordinary virtual gamepads are unaffected.
|
||||
|
||||
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
||||
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
||||
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
||||
|
||||
**4. Plugin UIs moved to their own origin** on a second listener (default `PORT + 1`,
|
||||
`PUNKTFUNK_UI_PLUGIN_PORT`). Reverse proxies and firewalls must forward that port; a self-signed
|
||||
console needs it trusted separately.
|
||||
|
||||
### Capability bits
|
||||
|
||||
Four added, all in the handshake's client/host capability bytes:
|
||||
|
||||
| Bit | Constant | Meaning |
|
||||
|---|---|---|
|
||||
| client `0x04` | `CLIENT_CAP_AUDIO_RED` | can decode the redundant desktop-audio plane |
|
||||
| host `0x20` | `HOST_CAP_AUDIO_RED` | is sending it |
|
||||
| client `0x08` | `CLIENT_CAP_PAD_AUDIO` | can render controller audio |
|
||||
| host `0x40` | `HOST_CAP_PAD_AUDIO` | is sending it |
|
||||
|
||||
⚠ **Pressure worth watching:** `client_caps` has four bits free; **`host_caps` is down to its last
|
||||
one (`0x80`)**; `video_caps` has been full since 0.23.0 (`VIDEO_CAP_MULTI_SLICE = 0x80`). The next
|
||||
video capability needs a second byte *and* an ABI bump — plan for it rather than discovering it.
|
||||
|
||||
### Wire planes
|
||||
|
||||
- **Controller audio, `0xD1`** — `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`,
|
||||
one Opus frame per datagram behind a 15-byte header. `PAD_AUDIO_KIND_HAPTICS = 0` is the pad's
|
||||
BACK channel pair (the voice coils) at 5 ms frames; `PAD_AUDIO_KIND_SPEAKER = 1` is the FRONT pair
|
||||
at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker,
|
||||
silence is a frozen sequence under the mic-mute discipline, host gating at −60 dBFS with a 250 ms
|
||||
hangover. `0xD2` (redundant desktop audio) deliberately skipped `0xD1` to reserve it for this.
|
||||
- **`HidOutput::AudioCtl`** — `0xCD` kind `0x06`, carrying the DualSense output report's
|
||||
volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind.
|
||||
- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host.
|
||||
- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant;
|
||||
the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value,
|
||||
so the struct layout is byte-identical). A zero-length effect body is now rejected rather than
|
||||
decoding as an empty — that is, a *release* — effect.
|
||||
- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate
|
||||
bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it
|
||||
would use to subscript its own array. The client also clamps the host's rumble lease receive-side
|
||||
at 5 s, where the ceiling had been sender-side only.
|
||||
|
||||
### Host environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. |
|
||||
| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows |
|
||||
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` | `client_only`/`host_and_client`/`follow_default`. **Windows host only.** |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | on | `0` disables controller audio host-wide |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1` | max 4; multi-pad needs an operator to raise it |
|
||||
| `PUNKTFUNK_PAD_AUDIO_STAMPS` | unset | debug bisect hook |
|
||||
| `PUNKTFUNK_WIRE_MTU` | unset | pins on-wire IP MTU for all sessions; above 1500 also enables jumbo |
|
||||
| `PUNKTFUNK_JUMBO` | unset (off) | fixed 9000-MTU profile |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | `PORT + 1` | the plugin-UI origin |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` |
|
||||
| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. |
|
||||
| `PUNKTFUNK_VAAPI_DEVICE` | client | **new** — pin the VAAPI render node |
|
||||
| `PUNKTFUNK_DUMP_VIDEO` / `PUNKTFUNK_AU_DUMP` | client | **new** — capture exact decoder input / the AU as it arrived from the host |
|
||||
| `PUNKTFUNK_AU_FAULT=drop\|truncate\|flip[:period]` | client | **new** — deliberate decoder-input corruption for recovery testing; native rungs only |
|
||||
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1` | host | **new** — opt-in live split-encode arbitration (Linux-wired) |
|
||||
| `PUNKTFUNK_NO_AUDIO_MINT` | host (Win) | **new** — opt out of minted endpoints; restores the name ladder |
|
||||
| `PUNKTFUNK_GPU_PRIORITY` | host (Win) | **removed** — superseded by `PUNKTFUNK_GPU_PRIORITY_CLASS`, a strict superset |
|
||||
| `PUNKTFUNK_FFMPEG_LOG` | client | **removed** with the av_log machinery |
|
||||
|
||||
Legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to
|
||||
`host_and_client` and `follow_default`; `follow_default` wins if both are set. New devtest command:
|
||||
`punktfunk-host pad-endpoint ensure|remove|status`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Origin isolation.** A second listener serves `/plugin-ui/**` and nothing else; the console origin
|
||||
refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different
|
||||
origin (scheme+host+port) so same-origin policy *is* the boundary; same site so the `SameSite=Lax`
|
||||
session cookie still flows. Bind failure disables plugin UIs rather than falling back.
|
||||
`x-pf-listener` is stripped inbound and set by the entry; active ports republish as
|
||||
`*_PORT_ACTIVE`; the plugin origin's CSP names the console as its only `frame-ancestors`; the proxy
|
||||
allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`.
|
||||
⚠ The kit's `postMessage(..., "*")` is **load-bearing** — narrowing it to `location.origin` would
|
||||
target the plugin's own origin and drop every message.
|
||||
- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of
|
||||
permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live
|
||||
route table and **fails the build on any unclassified route** — the block-list it replaces let new
|
||||
endpoints through silently. Field authority is tracked separately from route reachability:
|
||||
requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are
|
||||
operator-token only.
|
||||
- **Art serving** gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC
|
||||
refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before*
|
||||
canonicalization so `%2e%2e` cannot hide. Validation also runs at write time, so an unservable path
|
||||
can no longer be persisted.
|
||||
|
||||
### Native decode — FFmpeg is gone from the client
|
||||
|
||||
268 files, +129k / −25k. `cargo tree -p punktfunk-client-session` finds zero `ffmpeg`. **The host
|
||||
keeps `libavcodec` unconditionally** (pf-encode); no host workflow, packaging script or licence file
|
||||
was touched.
|
||||
|
||||
| Platform | v0.24.0 | v0.25.0 |
|
||||
|---|---|---|
|
||||
| Linux desktop | ffmpeg-next: Vulkan hwcontext (`pf-ffvk`) → VAAPI → libavcodec sw | `pf-vkdecode` (ash, presenter's own `VkDevice`, zero-copy) → `pf-vaadec` (dlopen'd libva, DRM-PRIME dmabuf) → `openh264` + `rav1d` |
|
||||
| Windows desktop | ffmpeg-next Vulkan → libavcodec D3D11VA half | `pf-vkdecode` → `pf-dxvadec` (plans into `ID3D11VideoDecoder`) → `openh264` + `rav1d` |
|
||||
| Android | MediaCodec (never had FFmpeg) | unchanged |
|
||||
| Apple | VideoToolbox (never had FFmpeg) | unchanged |
|
||||
|
||||
**Workspace members:** added `pf-bitstream` (+ vendored `cros-codecs`, compiler-enforced
|
||||
`unsafe`-free), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec`; removed `pf-ffvk`. **Deleted:**
|
||||
`video_vulkan.rs`, `video_vaapi.rs`, `video_libav.rs`, the libavcodec half of `video_d3d11.rs`, the
|
||||
`av_log` machinery, `ffmpeg::codec::Id` as decoder vocabulary, `DecodedImage::VkFrame`/`::Dmabuf`,
|
||||
the `ffmpeg-fallback` feature, and swscale — and with it the BT.601 default its correction code
|
||||
existed to undo.
|
||||
|
||||
**Software rung:** `openh264 = "0.9"` (BSD-2) and `rav1d = { version = "1", default-features =
|
||||
false, features = ["bitdepth_8"] }` (BSD-2). `dav1d-sys` was rejected because it is `system-deps`-
|
||||
only and would add a system library plus a `.pc` to every client package. `default-features = false`
|
||||
drops `asm` — rav1d's `build.rs` *panics* without nasm, unlike openh264-sys2, which degrades quietly.
|
||||
**`bitdepth_8` only** ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before
|
||||
any byte reaches the decoder.
|
||||
|
||||
**⚠ HEVC has no CPU floor.** An HEVC session that exhausts its hardware rungs tears down and re-dials
|
||||
advertising HEVC-less caps, and the host picks H.264 (`last_rung_verdict` / `NoSoftwareRung`). This is
|
||||
a first-class path, not a failure.
|
||||
|
||||
**Rung × codec × hardware evidence** (`native_evidence`) — the admission filter is driven by this, so
|
||||
an unproven rung yields only to one that is both verified for the codec and usable on the device:
|
||||
|
||||
| Rung | Codecs | Evidence |
|
||||
|---|---|---|
|
||||
| `native-vulkan` | H.264, H.265 Main/Main10/4:4:4 | **yes** — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak |
|
||||
| | AV1 | **yes** — 250/250 bit-identical on one vendor, no soak |
|
||||
| `native-d3d11va` | H.264, H.265 | **yes** — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak |
|
||||
| | AV1 | **not proven** — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter |
|
||||
| `native-vaapi` | H.264, H.265, AV1 | **NO — has never decoded a frame anywhere**; no VAAPI hardware was reachable |
|
||||
| `software` | H.264 (openh264), AV1 (rav1d) | **not proven**; openh264 has never run on glass. No HEVC at all. |
|
||||
|
||||
Vendor order (unchanged): Linux NVIDIA/AMD `vk → vaapi → sw`; Linux Intel/unknown
|
||||
`vaapi → vk → sw`; Windows NVIDIA/AMD `vk → d3d11va → sw`; Windows Intel/unknown
|
||||
`d3d11va → vk → sw`.
|
||||
|
||||
**AV1 advertisement** now answers from device facts (`av1_hardware_decodable`: Vulkan `DECODE_AV1`
|
||||
queue op, or the Windows D3D11 import path) rather than `ffmpeg::decoder::find(AV1)`, which was true
|
||||
on any build linking libdav1d. **Settings migration:** stored `vulkan`/`vaapi`/`d3d11va` migrate to
|
||||
`native-*` at decoder construction *and* at each dialog's lookup — the second is load-bearing, since
|
||||
an unmatched value renders as "Automatic" and a save would silently rewrite the preference.
|
||||
|
||||
### The three decode data-loss bugs
|
||||
|
||||
**AV1 sub-frame truncation — shipped in v0.24.0, host-side.** NVENC sub-frame readback has two halves
|
||||
armed by *different* conditions: `build_init_params` arms the writer from `subframe_on` alone, while
|
||||
the chunked reader additionally requires `slices >= 2` — and `resolve_slices` returns `1` for AV1
|
||||
unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to
|
||||
publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header
|
||||
declaring two tile rows plus a single Tile Group OBU with `tg_start = tg_end = 0`; libdav1d rejected
|
||||
**835/836** AUs. NVIDIA's *hardware* decoder accepts it (so Vulkan Video looked healthy at 60 fps);
|
||||
its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half
|
||||
the picture. Fixed by disarming sub-frame for AV1 while leaving `split_mode` untouched — AV1 keeps
|
||||
every engine. Arming the reader instead is *not* a drop-in: the reader cuts at
|
||||
`bitstreamSizeInBytes` on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not.
|
||||
Post-fix 654/654 clean. The test that had pinned the old behaviour as *correct* is replaced by one
|
||||
pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison
|
||||
nothing made.
|
||||
|
||||
**HEVC DPB from the level ceiling — new in this release, client-side.** `dpb_limit` computed
|
||||
`max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1)`. HEVC equation A-2 is a **ceiling on
|
||||
what an SPS may legally signal**, not a statement of need, and it branches on picture size against
|
||||
the *level's* `MaxLumaPs`. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds
|
||||
L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields
|
||||
16 frames / **17 slots** — one more than NVIDIA's `maxDpbSlots` of 16 — so every AU fell outside
|
||||
device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and
|
||||
there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that
|
||||
falls through to the honest answer. Fixed to `buffering.min(16)`: the `max()` bought no tolerance,
|
||||
since `Dpb::needs_bumping` already evicts at the signalled depth — it only over-allocated ten
|
||||
surfaces per 1080p session. **H.264 escaped by luck** (its ceiling lands at 13 for 1080p) and is left
|
||||
alone, because H.264's DPB size genuinely *is* level-derived absent a VUI `bitstream_restriction`.
|
||||
|
||||
**rav1d aborts the process — new in this release, client-side.** rav1d 1.1.0 `abort()`s on *any*
|
||||
decode error while holding one frame context: the `c.fc.len() == 1` branch decodes inline, always
|
||||
finishes in `rav1d_decode_frame_exit` which unconditionally takes `frame_hdr`, then on `Err` re-enters
|
||||
an `on_error` whose first act is `frame_hdr.as_ref().unwrap()` on the `None` it just left. The panic
|
||||
unwinds into `dav1d_send_data`, which is `extern "C"` ⇒ `panic_cannot_unwind` ⇒ `abort()`. **No
|
||||
`catch_unwind`, no rung demotion and no refusal can catch it**, and every `rav1d_*` entry is
|
||||
`pub(crate)`, so no in-process guard is possible. 4K was only *where* the first error happened — the
|
||||
CPU rung does 35–39 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to
|
||||
live, and the next AU referenced undecoded frames. Fixed by opening with `n_fc >= 2` and asking
|
||||
`dav1d_get_frame_delay` what the settings actually bought. Decode now drains **past** the first
|
||||
`EAGAIN`, which is why two frame contexts cost no latency (20–42 ms/unit at `n_fc=2` vs 21–53 at
|
||||
`n_fc=1`). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204
|
||||
frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as **rav1d#1497**
|
||||
with a reproducer. Does **not** make the CPU rung panic-proof.
|
||||
|
||||
**Settings loader BOM — shipped in v0.24.0, client-side.** `.and_then(|s| from_str(&s).ok())` turned
|
||||
every parse failure into `Default`. `Set-Content -Encoding UTF8` writes `EF BB BF`, serde_json
|
||||
correctly rejects at byte 0, and every setting vanished silently. A shared `load_json_or_default` now
|
||||
strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a
|
||||
BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately
|
||||
still `Default`, never an error.
|
||||
|
||||
### Other decode/encode
|
||||
|
||||
- **Intel Arc pNext ordering.** `vkGetPhysicalDeviceVideoCapabilitiesKHR` was called with the codec
|
||||
caps struct chained *before* `VkVideoDecodeCapabilitiesKHR` (`push_next` prepends). Arc/Windows
|
||||
fills those two **by position, not by sType**, and returned them swapped — we read a level as a
|
||||
capability bitmask. Measured A/B: `decode_flags_raw=12 max_level_idc=1` before,
|
||||
`decode_flags_raw=1 max_level_idc=12` after. NVIDIA and RADV dispatch by sType, which is why the
|
||||
fleet stayed green. ⚠ **This does not yet give Arc Vulkan Video** — the refusal only moves down: the
|
||||
device advertises only COINCIDE, and its NV12 coincide entry does not advertise `SAMPLED` usage,
|
||||
which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint.
|
||||
- **NVENC split encode.** The 10-bit rule sat *above* the pixel-rate arm and took no codec, so it
|
||||
vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an
|
||||
HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and
|
||||
Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89× — **split wins at every mode on both
|
||||
architectures, including the configuration the veto came from.** New order: env override →
|
||||
pixel-rate arm (now taking `max_forced_split_mode(engines)`, not a hard-coded 2) →
|
||||
HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because **the driver
|
||||
honours an over-ask and silently encodes narrower**. Also newly logged: HEVC + plain AUTO +
|
||||
sub-frame is **silently single-engine** — the fleet's default shape, and nothing said so.
|
||||
⚠ **Unvalidated consequence:** 5120×1440@240 Main10 now clears the pixel-rate bar and *will* be
|
||||
forced to split — the exact configuration the old veto came from. `PUNKTFUNK_SPLIT_ENCODE=0` is the
|
||||
escape.
|
||||
- **PyroWave on Windows stamped over the host's GPU scheduling policy.** It raised the process WDDM
|
||||
class to HIGH at every session open, while `auto_priority_gate` already owns that process-wide —
|
||||
starting at HIGH, *upgrading* to REALTIME once safe, and leaving a monitor that drops back when VRAM
|
||||
tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave
|
||||
stamped HIGH back and **orphaned the monitor's decision**. Removed rather than reconciled.
|
||||
- **A `pf-vkdecode` AV1 use-after-free fix had stabilised the wrong pointer** —
|
||||
`OwnedStdAv1SequenceHeader` kept the Std struct *inline*, so `pStdSequenceHeader` was a dead stack
|
||||
address; it worked only because NVIDIA happened to retain `pColorConfig` instead. Std structs are
|
||||
now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters
|
||||
assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265.
|
||||
|
||||
### A/V sync — it did not previously exist
|
||||
|
||||
The host has always stamped `pts_ns` on every audio datagram. **Every client decoded it into
|
||||
`AudioPacket` / `AudioPCM` and never read it.** Video's `pts_ns` was used end to end; audio free-ran
|
||||
at whatever depth its jitter ring reached; nothing compared them. The A/V offset was an emergent
|
||||
property of buffer depths — it moved whenever the ring ratcheted under underrun pressure, and it got
|
||||
**worse every time video got faster**, because a quicker decoder lowers the video leg and leaves
|
||||
audio's where it was. That is why shaving milliseconds off the audio budget had never helped.
|
||||
|
||||
Two host defects were prerequisites:
|
||||
- **`pts_ns` was stamped at encode time**, inside the loop draining an already-accumulated chunk, so
|
||||
every frame of a chunk carried near-identical timestamps describing *when we got round to
|
||||
encoding*. Now derived from the chunk's arrival instant minus queued-frame duration, re-anchored
|
||||
per chunk.
|
||||
- **The host did not pace.** One capture callback hands over a whole quantum (5 ms honoured, **21.3 ms
|
||||
on a VM**, where stock PipeWire raises `min-quantum` to 1024), drained into back-to-back
|
||||
`send_datagram` calls — a 4–5 frame burst then ~21 ms of nothing, which a ring could only absorb by
|
||||
standing a burst period deep. Frames now leave on the audio clock (`FRAME_INTERVAL` 5 ms,
|
||||
`PACE_MAX_SLEEP` 10 ms, `PACE_REANCHOR` 100 ms). Costs no average latency.
|
||||
|
||||
```
|
||||
audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
|
||||
av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture)
|
||||
```
|
||||
|
||||
`AvSync` EWMAs it (`AV_EWMA_TAU_MS = 2000`), ignores anything inside `AV_DEADBAND_MS = 10`, waits
|
||||
`AV_MIN_OBSERVATIONS = 100` before a first correction, and **refuses rather than clamps** beyond
|
||||
`AV_SANE_LIMIT_MS = 1000` — a wall-clock step must not steer the ring.
|
||||
|
||||
⭐ **Video is the master, and continuity outranks sync.** `JitterPolicy::set_sync_target` takes only a
|
||||
*request*, clamped between the existing underrun-driven adaptive floor and the hard cap: a link whose
|
||||
jitter genuinely needs more buffer than the picture is away keeps its buffer, and the residual is
|
||||
reported rather than forced. `None`/`nil` reproduces prior behaviour bit-identically, which is how
|
||||
the four rings adopted it one at a time.
|
||||
|
||||
Per client: the Rust desktop reference is a new `video_e2e_ns` atomic beside `clock_offset`, written
|
||||
by the presenter and read by the audio thread. **Android** publishes `OnFrameRendered` — the one
|
||||
place that knows a frame *latched* — **raw, not floor-shaved** (the HUD shaves the OS present floor;
|
||||
sound must reach the ear when light reaches the eye), and stays inert below API 33 rather than
|
||||
substituting the release instant, which targets a future vsync 8–21 ms ahead of glass. **Apple**
|
||||
publishes its `LatencyMeter` sample as an *expiring level*, because that client has a backgrounded
|
||||
keep-alive that keeps audio playing while dropping video decode; its clamp raises the ceiling to the
|
||||
floor rather than `min(max(…))`, which on a device whose callback quantum alone exceeds the hard cap
|
||||
would otherwise hand back the cap, silently below the continuity floor.
|
||||
|
||||
Escape hatches: `PUNKTFUNK_NO_AV_SYNC=1` everywhere, plus
|
||||
`adb shell setprop debug.punktfunk.no_av_sync 1` on Android (a launcher-started app inherits no
|
||||
environment). Observability: `buffer_ms`/`target_ms` had only ever been a `tracing::debug!` line —
|
||||
and on a Deck the client runs under Steam's `reaper` with stdout on a pipe nobody can read, so the
|
||||
one number identifying a deep ring was unobtainable *on the device reporting the latency*. Now on the
|
||||
HUD and in the 1 Hz stats log on every client.
|
||||
|
||||
### Decode-target aliasing — caught before it shipped
|
||||
|
||||
⚠ **None of this ever shipped.** `git ls-tree v0.24.0 crates/` has no `pf-vkdecode`, `pf-dxvadec`,
|
||||
`pf-vaadec` or `pf-bitstream`; v0.24.0's decode rungs were libavcodec. This was a ship-blocker for
|
||||
the new stack, cleared — not a field bug.
|
||||
|
||||
Three of the four native rungs released a picture's surface **inside the plan→submission
|
||||
conversion**, then assigned the decode target a slot. `SlotMap::assign` returns the *lowest free
|
||||
slot* — the one just vacated. The submission then named one surface as both decode target and its own
|
||||
reference: `CurrPicTextureIndex == RefFrameMapTextureIndex[k]` on DXVA, or `pSetupReferenceSlot`
|
||||
sharing an array layer with `pReferenceSlots` on Vulkan. **Decode into the surface you are predicting
|
||||
from.**
|
||||
|
||||
- **AV1 / D3D11VA** — AV1 applies `refresh_frame_flags` *after* decode (7.20), so "read a slot then
|
||||
overwrite it" is the ordinary case: **268 of the vendored vector's 274 frames**, first at frame 6.
|
||||
- **H.264 / both Vulkan and D3D11VA** — `H264Planner` snapshots `dpb_refs` in `begin_picture`, before
|
||||
8.2.5 marking and the C.4.5.3 bump, so a picture the sliding window unmarks and the bump evicts
|
||||
lands in *both* `dpb_refs` and `dpb.removed`. Both conditions coincide only in low-delay H.264 —
|
||||
and NVENC guarantees it (`max_num_ref_frames = 3` alongside `max_dec_frame_buffering = 3`, plus
|
||||
`max_num_reorder_frames = 0`). Result: **297 of every 300 access units of every stream a punktfunk
|
||||
host emits**, at every resolution, on both rungs.
|
||||
- **H.265 is exempt, now measured rather than argued** — 0 of 120 aliases, with a counterfactual that
|
||||
moves the snapshot one call earlier and reproduces 115 of 120.
|
||||
- **VAAPI's exemption was incidental**: the precondition is fully present (117 of 120 AUs) but
|
||||
`plan_to_va` never invents a surface. That held only because three call sites happened to write
|
||||
`free_surface()` and `surface_table()` adjacently; `acquire_target` now returns index, surface and
|
||||
table together so a later edit cannot split them.
|
||||
|
||||
Fix is uniform: the plans grow `release_after_decode`, conversions hand removals back, callers
|
||||
release once the decode op is issued. Costs no slot (`SlotMap::new` allocates `max_dpb_frames + 1`).
|
||||
Both rungs hold the `Result` rather than `?`-ing it so the deferred release runs on failure paths —
|
||||
seven exits sat between conversion and release, each of which would have leaked a slot.
|
||||
|
||||
**Why four gates missed it**, all recorded: the conformance vector is *structurally blind* (level 1.3,
|
||||
no VUI `bitstream_restriction` ⇒ a 7-frame DPB against 2 reference frames, and it reorders) and
|
||||
passed 250/250 for two milestones; **a test had encoded the bug as correct**; another assertion was
|
||||
*vacuous* (it asserted the decode target was never also a reference while handing every picture its
|
||||
own never-reused surface id — distinct integers cannot collide); and **it streamed clean** — *"the
|
||||
2026-08-07 field sessions that looked clean were looking at wrong pixels."*
|
||||
|
||||
`gpu_parity` is now **11 legs** (not 9 — that note was written mid-PR): each decodes a vendored stream,
|
||||
reads back every output frame's NV12, crops to the display region and SHA-256s in *display order*
|
||||
against libavcodec goldens, frame count and flush tail included. The three new legs are our own
|
||||
encoder's output rather than conformance vectors — H.264 because the vector is blind to the shape,
|
||||
H.265 because an exemption with no stream behind it is how the H.264 defect survived two milestones,
|
||||
AV1 because the vector is one tile on all 274 frames while our encoder splits 4K into two tile rows,
|
||||
so every tile array the conversions fill had only ever been written at index 0. `video_vaapi_native`
|
||||
parity is new entirely: 7 legs, bit-identical on RDNA3.
|
||||
|
||||
⚠ Promoting D3D11VA AV1 to `verified` **changes rung selection** on Windows Intel/unknown vendors, not
|
||||
just a label. VAAPI stays `verified = false` deliberately — one vendor, never soaked; flipping it
|
||||
would move `auto` off Vulkan Video on every Linux AMD/Intel client including the Deck.
|
||||
|
||||
### FFmpeg 9, and the Arch soname trap
|
||||
|
||||
`pf-encode` now builds against **FFmpeg 9**. The host still links libavcodec unconditionally; the
|
||||
client has none (see above).
|
||||
|
||||
⚠ **`pacman` is the only one of our packaging formats that does not derive dependencies from ELF
|
||||
`DT_NEEDED`.** rpm auto-generates `libavcodec.so.62()(64bit)`, `dpkg-shlibdeps` emits `libavcodec62`,
|
||||
nix pins the closure — but a bare `depends=('ffmpeg')` let `pacman -Syu` walk the host across a
|
||||
soname bump with no warning and no conflict. FFmpeg 8 → 9 (`2:9.0-5`: libavutil .60→.61, libavcodec
|
||||
.62→.63, libavfilter .11→.12, libavdevice .62→.63, libswscale .9→.10) therefore **bricked every
|
||||
Arch/CachyOS install**: the dynamic loader cannot start the binary, so it is **exit 127 before
|
||||
`main()`** in a systemd restart loop, with nothing in the host's own log to explain it.
|
||||
`ldd /usr/bin/punktfunk-host | grep "not found"` is the one-line diagnosis.
|
||||
|
||||
⭐ The fix is **SONAME deps, not a hand-written version bound**: `depends=(… 'libavcodec.so'
|
||||
'libavutil.so' …)`. Arch's ffmpeg declares matching `provides=(libavcodec.so=63-64 …)`, and makepkg
|
||||
rewrites each bare `libfoo.so` into `libfoo.so=<soname>-<arch>` by reading the built binary's
|
||||
`DT_NEEDED` — so the bound tracks whatever FFmpeg the builder linked against with nothing to
|
||||
maintain across the next bump. A literal `ffmpeg<2:9` would go stale on every bump. pacman now
|
||||
refuses the upgrade instead of bricking the install. All seven libs are listed even though
|
||||
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
|
||||
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
|
||||
|
||||
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
|
||||
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
|
||||
the release tag was pushed four minutes later, while the CI builder image was still being
|
||||
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
|
||||
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
|
||||
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
|
||||
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
|
||||
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
|
||||
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
|
||||
time and could not disagree with itself this way.
|
||||
|
||||
Two guards now stand where only a convention did. arch.yml compares the builder's libav
|
||||
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
|
||||
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
|
||||
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
|
||||
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
|
||||
cheap path; the guards are the backstop.
|
||||
|
||||
### Linux playback filled the buffer ceiling
|
||||
|
||||
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
|
||||
quantum limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (`pw_buffer.requested`).
|
||||
Every cycle queued up to 170 ms of PCM downstream of the ring **and** taught `JitterPolicy` that the
|
||||
device drains 170 ms per callback, so the underrun floor (want + one frame) rose above any depth the
|
||||
A/V sync loop could request: sync measured audio ~280 ms late and was then forbidden — **by its own
|
||||
continuity rule** — from draining it. The first on-glass run of the latency overhaul showed exactly
|
||||
that: `audio buffer 272 ms, a/v +284 ms`, stable. Now honours `requested` (capacity remains both the
|
||||
ceiling and the fallback when `requested == 0`) and logs requested-vs-capacity once per stream.
|
||||
Needs libpipewire ≥ 0.3.49; every ship target clears it.
|
||||
|
||||
### Windows audio substrate
|
||||
|
||||
The host now mints its **own** devnodes from Valve's INFs (`SteamStreamingSpeakers.inf` /
|
||||
`SteamStreamingMicrophone.inf` under `{CommonProgramFiles(x86)}\Steam\drivers\Windows10\…`) instead
|
||||
of bundling VB-CABLE.
|
||||
|
||||
- **Two persistent endpoints**, `Punktfunk Speakers` (client-only loopback sink — the wiring plan
|
||||
parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host
|
||||
stays silent) and `Punktfunk Microphone` (host writes decoded client voice into the render side;
|
||||
the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker.
|
||||
- **Identity is the recorded endpoint id, never the name** — a minted instance is name-identical to
|
||||
Steam's primaries. Durable marker `PunktfunkAudioRole` (1 = Speakers, 2 = Mic) under Device
|
||||
Parameters. Name stamping is device-desc + device-name **only**: a wider stamp set makes
|
||||
`AudioEndpointBuilder` re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure
|
||||
the endpoint still wires and simply keeps the driver's default name.
|
||||
- **Format stamps are per-direction.** Render gets the PCM16-device / float-mix stereo split; capture
|
||||
gets the **device-format key only** — mix and host-format keys are render-engine properties, and
|
||||
stamping them onto a capture endpoint breaks its shared-mode graph (`IsFormatSupported` reports
|
||||
2ch/48k fine, `Initialize` then fails `0x88890008`).
|
||||
- **`MintedIds` is tier-0 in the wiring plan.** The mic takes its minted device outright (paired by
|
||||
provider id — a name search cannot distinguish it from the primary); the loopback prefers the
|
||||
minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries
|
||||
→ cable → real hardware. `PUNKTFUNK_MIC_DEVICE` still beats everything.
|
||||
- **Mic-vs-loopback arbitration**: the mic may hold the Streaming Microphone only while the loopback
|
||||
still gets a non-last-resort pick; otherwise the loopback takes it and `mic_withheld` is set. This
|
||||
fixes a field case where a headless Steam-only host streamed **silence**.
|
||||
- **New `AudioReadiness`** — `Full` / `AudioOnly` / `MicOnly` / `Nothing`, logged on every plan
|
||||
change and surfaced at `GET /api/v1/status` → `RuntimeStatus.audio` (`AudioWiring`, Windows-only,
|
||||
absent before the first wiring pass; a status poll triggers no COM work or `IPolicyConfig` writes).
|
||||
The console Dashboard renders it as an "Audio wiring" card.
|
||||
- **Requires Steam installed** (never running) — without the INFs the host streams video only, and
|
||||
picks the drivers up automatically if Steam is installed later. Opt out entirely with
|
||||
`PUNKTFUNK_NO_AUDIO_MINT`, which restores the previous name-based ladder exactly.
|
||||
- ⚠ **VB-CABLE is no longer bundled but is deliberately NOT uninstalled** — it is a third-party
|
||||
shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was
|
||||
considered and rejected: on a box where minting transiently fails, that would let the Steam
|
||||
Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio
|
||||
audible on the host.
|
||||
- ⚠ **The minted endpoints survive Punktfunk's uninstall by design** (they are plain instances of
|
||||
Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is
|
||||
the devtest `punktfunk-host audio-probe cleanup`.
|
||||
- New devtest: `punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup`.
|
||||
`plan` is the field-triage command; `micpins` maps exclusive+shared `IsFormatSupported` across
|
||||
{1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins.
|
||||
|
||||
### Apple audio
|
||||
|
||||
- **The microphone was never in the render graph.** On the combined (voice-processing) engine — made
|
||||
default a week earlier and never run on a device — the input node carried a tap and **no
|
||||
connection**, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat,
|
||||
and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence
|
||||
tripwire counts *captured* frames, so it never fired. Input now runs through a silent sink into the
|
||||
main mixer at `outputVolume = 0` (Apple's own voice-processing sample topology). Two more: the tap
|
||||
read the input format **before** `prepare()`, and enabling voice processing swaps in the VPIO unit
|
||||
and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the
|
||||
voice-processed engine took the whole uplink down for the session — it now falls back to the split
|
||||
path, because **the mic outranks the AEC**.
|
||||
- **No packet-loss concealment on the one client that decodes Opus in core.** Linux, Windows and
|
||||
Android all feed an `AudioGapTracker` and synthesize libopus PLC; the in-core path had the tracker
|
||||
sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms
|
||||
frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane
|
||||
(`0xD2`) hides single losses, so the survivors were exactly the burstier gaps that most needed
|
||||
concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a
|
||||
DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full
|
||||
concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap).
|
||||
- **The Apple jitter ring never grew.** The shared Rust `JitterPolicy` has an adaptive target floor;
|
||||
the hand-written Apple mirror mirrored the *shed* half but not the *growth* half, pinning its
|
||||
target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than
|
||||
one delivery stall, so the ring re-primed through every stall for the whole session. Now the full
|
||||
`note_read` mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70),
|
||||
30 s of quiet steps back, and the write-side hard trim follows the grown target.
|
||||
|
||||
### Clients
|
||||
|
||||
- **Nothing in the desktop console had ever been clickable.** `SkiaOverlay::handle_event` matched
|
||||
only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console
|
||||
into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while
|
||||
browsing. New `Overlay::handle_pointer` carries mouse/touch in swapchain pixels; the run loop
|
||||
converts (it owns the window and hence display scale); the console hit-tests the rects it drew last
|
||||
frame. Only **direct** touch devices are offered — an indirect trackpad already drives the mouse.
|
||||
Widgets act on **press**, not release, because both carousels scroll the focused item toward centre
|
||||
and what you pressed would slide out from under your finger. Host menu on Up from a saved tile;
|
||||
`UpdateHost` edits **in place** (remove-and-re-add would silently drop the fingerprint, learned MAC,
|
||||
pinned cards and profile binding), and `ForgetHost` arms on first press and fires on second.
|
||||
- **Discovery went permanently deaf three ways**, each needing an app relaunch: a failed resolve was
|
||||
never retried (`browseResultsChangedHandler` fires only when the result *set* changes, and a host
|
||||
whose resolve failed is still in the set); a stuck resolve never ended (`NWConnection` has no
|
||||
timeout, so the throwaway UDP flow could sit in `.preparing` forever, and a service with a
|
||||
connection in flight was skipped); and an `NWBrowser` parking in `.waiting` was ignored — **which is
|
||||
exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not
|
||||
revive the browser that was already waiting.** A 1 Hz sweep now times out stuck resolves, retries
|
||||
failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every
|
||||
browse report. `discovery::Rescan` forces a fresh mdns-sd query — the browse otherwise re-queries on
|
||||
a doubling backoff **capped at one hour**, so a long-lived browse is effectively passive. ⚠
|
||||
`clients/windows/src/discovery.rs` is a **second copy** of the browse that the earlier IPv4 pinning
|
||||
missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a
|
||||
card that failed on every click.
|
||||
- **Phone gyro mirror**, off by default, player 1 / wire pad 0 only, and only while that pad has no
|
||||
motion source of its own. iOS/iPadOS only on Apple (`DeviceGyro` wraps `CMDeviceMotion` at ~100 Hz
|
||||
on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source);
|
||||
Android phones with a gyroscope at ~200 Hz with `maxReportLatencyUs = 0`, since batching is poison
|
||||
for gyro aim. Both rotate from the device's natural frame into the controller frame by interface
|
||||
orientation, and both send **one zero-gyro sample on stand-down** — the host holds motion as state
|
||||
and re-emits it, so a leftover nonzero angular velocity reads as endless rotation.
|
||||
- **Safe-area resolution** is purely a *sizing* change — no layout change, no input change; pointer
|
||||
mapping follows for free since both clients derive the picture rect from the live host mode. Full
|
||||
native height, width less left+right safe insets. Portrait settings screens report the housing on
|
||||
`top` with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status
|
||||
bar never fabricates one). Android adds the rounded-corner radius, which it does not count as
|
||||
cutout. Both even-floor and clamp, because `validate_dimensions` rejects odd dimensions and an inset
|
||||
subtraction lands odd about half the time.
|
||||
- **Gamepad UI**: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input
|
||||
on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one
|
||||
shared `ui_palette` key, Violet keeping its explicit sixteen colours so existing installs are an
|
||||
identity transform. Presentation only → **device preference, never part of a profile**. Palette
|
||||
maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language;
|
||||
`every_palette_is_multi_tone` fails under 45° hue spread and caught Ember at 35° and Graphite at 3°.
|
||||
Three render-only findings: additive blending blows out over a pale ground, a white scrim at the
|
||||
dark field's strength bleaches the gradient, and white glass over a bright field needs more body.
|
||||
|
||||
### Session and game lifetime
|
||||
|
||||
- **`PunktfunkEndReason` replaces a single "closed" bit** (ABI 17, additive, wire untouched). Five
|
||||
values — local, game exited, host ended, host error, lost — classified by the connection watcher
|
||||
from close codes already on the wire (`APP_EXITED_CLOSE_CODE` had been sent for a long time with
|
||||
nothing consuming it). **Latched before the shutdown flag**, because the two are read by different
|
||||
threads and the reason must never arrive second. Exposed as `punktfunk_connection_end_reason` +
|
||||
`is_normal()`. Shells fall back to the old wording when there is no verdict (older core, or a close
|
||||
that raced the read).
|
||||
- **The Steam `Running` registry hint was an unbounded veto.** Honouring it reset the absence window
|
||||
every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented —
|
||||
pinned a lease in `running` for the life of the host process. The absence timer now runs
|
||||
regardless; past `VETO_LIMIT` (30 s) with nothing of the game on the box, the session ends anyway
|
||||
and logs at WARN. Extracted as a pure `exit_confirmed(gone_for, hint_running)` with tests — the
|
||||
watch loop polls a live process table and cannot be unit-tested, which is exactly how the
|
||||
unbounded veto shipped.
|
||||
- **New `launchreg.rs`: one record per `(client fingerprint, library id)`**, written at launch and
|
||||
independent of the termination policy. The old fingerprint-keyed reclaim only ran under
|
||||
`GameOnSessionEnd::Always`, so under the default `Keep` nothing was recorded — and a client retry
|
||||
re-sent `Hello::launch` verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it
|
||||
(the launcher just focuses the running copy) but a `gog:`/`custom:` target genuinely started a
|
||||
second instance over the same save files. The same retry also minted a fresh `launch_stamp`, so
|
||||
procscan refused to adopt a game older than 2 s and **a reconnected session lost game-exit
|
||||
detection for the rest of its life.** Identity now flows backwards from the watcher, which
|
||||
publishes the concrete `ProcRef`s it adopted; liveness is `Scanner::alive` over that recorded set,
|
||||
re-verified by `(pid, start)`. Tradeoffs: a `custom:` command with no detection hints stays
|
||||
`Unknown` forever (trading exit detection for not double-spawning), and `IN_FLIGHT_WINDOW` is a
|
||||
fixed 90 s, deliberately not `disconnect_grace_seconds`.
|
||||
- **A launcher entry is `LeaseKind::Untracked` unconditionally**, checked ahead of
|
||||
`nested`/`child`/`spec`. Its lifetime previously depended on invisible state: launcher not running
|
||||
→ live child → `Child` lease → quitting the launcher ended the session; launcher already running →
|
||||
command forwards and exits inside `SHIM_WINDOW` → `Untracked` → session persists. Steam Big Picture
|
||||
is a *mode*, not a process (and on a Deck it is always running); Heroic is single-instance
|
||||
Electron. The real trap was the GameStream path, whose `GsApp` intermediate silently dropped the
|
||||
field.
|
||||
|
||||
### Library and plugins
|
||||
|
||||
- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2
|
||||
`{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation.
|
||||
`PUT /library/provider/{p}?store=<s>` claims a store; entries then surface as
|
||||
`<store>:<external_id>` rather than `custom:<id>`, so entry ids, GameStream app ids, client art
|
||||
caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is
|
||||
held the matching built-in scanner is skipped, so the two never double-list.
|
||||
- `GET/PUT /library/scanners` is now a **sources** endpoint over the same disabled-set file.
|
||||
- New entry fields: `role: game|launcher`; launch kinds `steam_ui` (`bigpicture|desktop`),
|
||||
`launcher_ui` (platform-gated, 400 on invalid) and `xbox`.
|
||||
- **Plugin kit 0.3.0** adds a `./library` subpath: `defineLibraryPlugin` plus ported total parsers —
|
||||
text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable
|
||||
SQLite, a registry wrapper that refuses HKCU, path-confinement joins. `GET/PUT /__config` returns
|
||||
`{schema, value}` and persists raw, so a plugin with settings need not ship an SPA.
|
||||
|
||||
### Platform and packaging
|
||||
|
||||
- **The client's config writer** falls back to an in-place write when the atomic replace is
|
||||
unavailable, verifies it by reading the bytes back, and records the last persistence failure
|
||||
centrally so the UI can surface it. Scratch files are now per-process, closing a real collision
|
||||
between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one
|
||||
could previously rename its half-written temp over another's target.
|
||||
- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at
|
||||
the pacing rate with a 100 ms absolute ceiling rather than compressed into one frame interval.
|
||||
Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment
|
||||
escape hatch, and the GameStream-compatible path is untouched.
|
||||
- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video
|
||||
stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those
|
||||
sessions get the next-session clamp only and are excluded from jumbo. The ABR decode-cap latch
|
||||
likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design.
|
||||
- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream
|
||||
revision as the host's own packaged build — bump both together. ~4 MB of app content replaces a
|
||||
94 MB external extension, and Flathub is no longer needed at install time. ⚠ `subprojects/vkroots`
|
||||
is a gamescope **submodule** and flatpak-builder clones submodules by default; declaring it again
|
||||
as an explicit source breaks the build during extraction. `glm` and `stb` are `.wrap` files, not
|
||||
submodules, and *do* need explicit sources.
|
||||
- **Build-container images push to an authenticated registry endpoint**, and `:latest` is reconciled
|
||||
against the content key on every push to main — an out-of-band tag move is detected and repaired
|
||||
rather than silently inherited.
|
||||
- **Windows pad drivers** publish their sequence counters with release ordering (the host was already
|
||||
loading with acquire and pairing with nothing) and serialize the output-ring publish. The
|
||||
`/dev/uhid` event ABI, previously transcribed into all five Linux gamepad backends, is consolidated
|
||||
into one module.
|
||||
|
||||
### Verification status
|
||||
|
||||
Honest about what has and has not been on hardware, because several things in this release have not:
|
||||
|
||||
- **Controller audio has never run on a real DualSense.** Its entire verification is unit tests and
|
||||
compile checks, and its rumble arbitration rests on an explicitly retracted assumption about
|
||||
whether the voice coils and the rumble motors are the same actuators. The evidence-based 500 ms
|
||||
idle window is correct either way, but the underlying exclusivity is unsettled. Android's arbiter
|
||||
is the evidence-based one; the desktop twin and the coil restore on Android's stop path are owed.
|
||||
Some Android OEM kernels refuse the isochronous claim outright, which degrades to ordinary rumble.
|
||||
- The **plugin-UI origin split** is validated against a fake console and a fake plugin, not yet in a
|
||||
real browser.
|
||||
- The **packaging default-on changes** have had no installer run or package build.
|
||||
- **No launcher tile has been clicked on a real host** — the first source that would publish one does
|
||||
not exist yet.
|
||||
- Desktop-audio, packet-sizing and iPad-pointer work is build-verified only.
|
||||
- ⚠ **The FFmpeg-deletion milestone itself has never executed on a GPU.** It was gated on
|
||||
cross-clippy, 160 tests, a workspace check and an `ffmpeg` count of 0 in the client / 2 in the host.
|
||||
The software on-glass check, the D3D11 and VAAPI AV1 hardware legs and the field bake were all owed
|
||||
at merge; later commits closed some of that but not all. The "no FFmpeg" claim is verified by
|
||||
`cargo tree` and a notices-generator mention count, not by inspecting a shipped binary.
|
||||
- ⚠ **`pf-vaadec` has never decoded a frame anywhere** — no VAAPI hardware was reachable. It is the
|
||||
*first* rung on Linux/Intel and unknown vendors; the evidence filter bars it there in favour of
|
||||
`pf-vkdecode`, but an explicit pin reaches it.
|
||||
- **openh264 has never run on glass**; the H.264 software rung is unit-tested only.
|
||||
- **`native-d3d11va` AV1 is deliberately `verified = false`** — one 25 s 4K60 session, no parity.
|
||||
- **Split arbitration is opt-in and Linux-wired only**; the Windows arm is built and unit-tested but
|
||||
not on hardware. The 5120×1440@240 Main10 behaviour flip is explicitly unvalidated and is named as
|
||||
the first thing to re-measure.
|
||||
- **Software throughput is unmeasured in general** — the CPU rung does 35–39 fps at 4K AV1 against a
|
||||
60 fps stream, which is why the backlog flush that triggered the rav1d abort happens at all.
|
||||
- **The Apple mic fix is a proven root cause, not a verified session.** Its own commits call it "a
|
||||
strong inference plus one proven logic defect rather than a confirmed fix" and close "awaiting the
|
||||
reporter's on-device confirmation" — which nothing later in the range records. It also leaves a
|
||||
known gap: nothing reports whether the uplink actually opened, so the HUD still offers a Mute
|
||||
Microphone button over a session that may be sending nothing.
|
||||
- **The Windows audio substrate is, by contrast, well-evidenced on hardware** — repeated "measured on
|
||||
the target box", a live bisect on a fresh endpoint, and a `micpitch` proof reading 440 Hz in →
|
||||
440 Hz out at exact peak. The one thing not evidenced is a real client speaking through the minted
|
||||
microphone end to end; the pitch proof is probe-driven.
|
||||
- **The phone-gyro mirror is not recorded as hardware-verified** — remap matrices are pinned by unit
|
||||
tests in both languages, but there is no "played a game with a clip-on pad" evidence in the tree.
|
||||
- **The iOS gamepad-UI pale-palette sweep on glass is still owed**, per its own commit.
|
||||
- ⚠ **The CI runner scripts are hand-installed** (`/usr/local/bin/ci-docker-prune.sh`,
|
||||
`/usr/local/sbin/ci-docker-reclaim.sh`). Merging does not deploy them — both runner hosts need the
|
||||
files copied out of `scripts/ci/`, and the missing `192.168.1.58:5011` insecure-registry entry on
|
||||
one host is routed around, not fixed.
|
||||
Generated
+44
-43
@@ -647,9 +647,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.65"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -1290,9 +1290,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-next"
|
||||
version = "8.1.0"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
|
||||
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"ffmpeg-sys-next",
|
||||
@@ -1301,9 +1301,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sys-next"
|
||||
version = "8.1.0"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
|
||||
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
@@ -1341,9 +1341,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -2358,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3051,7 +3051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3069,7 +3069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3090,7 +3090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3100,7 +3100,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3124,7 +3124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3136,7 +3136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3150,11 +3150,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3183,19 +3183,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-vkdecode",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
@@ -3204,7 +3205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3212,7 +3213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3224,7 +3225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3233,7 +3234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3266,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3277,7 +3278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3289,7 +3290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3512,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3523,7 +3524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3541,7 +3542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3558,7 +3559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3573,7 +3574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3592,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3624,7 +3625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3709,7 +3710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3723,7 +3724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3746,7 +3747,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -83,7 +83,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbc 0.1.2 — MIT OR Apache-2.0 — https://github.com/RustCrypto/block-modes
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cexpr 0.6.0 — Apache-2.0/MIT — https://github.com/jethrogb/rust-cexpr
|
||||
cfg-expr 0.20.8 — MIT OR Apache-2.0 — https://github.com/EmbarkStudios/cfg-expr
|
||||
@@ -148,12 +148,12 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastbloom 0.14.1 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fdeflate 0.3.7 — MIT OR Apache-2.0 — https://github.com/image-rs/fdeflate
|
||||
ffmpeg-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg
|
||||
ffmpeg-sys-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
ffmpeg-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg
|
||||
ffmpeg-sys-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
field-offset 0.3.6 — MIT OR Apache-2.0 — https://github.com/Diggsey/rust-field-offset
|
||||
filetime 0.2.29 — MIT/Apache-2.0 — https://github.com/alexcrichton/filetime
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flate2 1.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/flate2-rs
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
@@ -628,7 +628,7 @@ Crates whose package did not embed a license file (SPDX + source only)
|
||||
atomig-macro 0.4.0 — MIT/Apache-2.0 — https://github.com/LukasKalbertodt/atomig/
|
||||
cookie-factory 0.3.3 — MIT — https://github.com/rust-bakery/cookie-factory
|
||||
defmt-parser 1.0.0 — MIT OR Apache-2.0 — https://github.com/knurling-rs/defmt
|
||||
ffmpeg-sys-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
ffmpeg-sys-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
jni-sys-macros 0.4.1 — MIT OR Apache-2.0 — https://github.com/jni-rs/jni-sys
|
||||
ndk 0.9.0 — MIT OR Apache-2.0 — https://github.com/rust-mobile/ndk
|
||||
ndk-sys 0.6.0+11769913 — MIT OR Apache-2.0 — https://github.com/rust-mobile/ndk
|
||||
@@ -2266,7 +2266,7 @@ SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: asn1-rs 0.6.2, asn1-rs-derive 0.5.1, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, der-parser 9.0.0, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.9, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.0, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.3, hashbrown 0.16.1, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.6, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.46, num-iter 0.1.45, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.7.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, roxmltree 0.21.1, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.23.4, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.16.0, xattr 1.6.1
|
||||
The following license (LICENSE-APACHE) applies to: asn1-rs 0.6.2, asn1-rs-derive 0.5.1, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, der-parser 9.0.0, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.0, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.3, hashbrown 0.16.1, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.6, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.46, num-iter 0.1.45, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.7.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, roxmltree 0.21.1, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.23.4, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.16.0, xattr 1.6.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -4011,7 +4011,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
@@ -6183,7 +6183,7 @@ DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE) applies to: ffmpeg-next 8.1.0
|
||||
The following license (LICENSE) applies to: ffmpeg-next 9.0.0
|
||||
----------------------------------------------------------------------------
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
+181
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.24.0"
|
||||
"version": "0.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -997,7 +997,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1021,13 +1021,13 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unified library across all stores",
|
||||
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
"$ref": "#/components/schemas/OperatorGameEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1301,6 +1301,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/hidden/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Hide or un-hide one library title",
|
||||
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
|
||||
"operationId": "setLibraryEntryHidden",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The library entry id (e.g. `steam:70`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored; the entry's visibility after the call",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Empty entry id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -4045,6 +4118,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AudioWiring": {
|
||||
"type": "object",
|
||||
"description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).",
|
||||
"required": [
|
||||
"readiness",
|
||||
"mic_withheld",
|
||||
"last_resort"
|
||||
],
|
||||
"properties": {
|
||||
"last_resort": {
|
||||
"type": "boolean",
|
||||
"description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes."
|
||||
},
|
||||
"loopback": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable."
|
||||
},
|
||||
"mic": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable."
|
||||
},
|
||||
"mic_withheld": {
|
||||
"type": "boolean",
|
||||
"description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable."
|
||||
},
|
||||
"narrowing": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does."
|
||||
},
|
||||
"readiness": {
|
||||
"type": "string",
|
||||
"description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.",
|
||||
"example": "full"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AvailableCompositor": {
|
||||
"type": "object",
|
||||
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
||||
@@ -5508,6 +5626,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenState": {
|
||||
"type": "object",
|
||||
"description": "What `setLibraryEntryHidden` echoes back.",
|
||||
"required": [
|
||||
"id",
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Its visibility after the call."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The entry id the call addressed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryEntryHidden`.",
|
||||
"required": [
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this title should be hidden from every play surface."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookEntry": {
|
||||
"type": "object",
|
||||
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
|
||||
@@ -6294,6 +6443,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OperatorGameEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
|
||||
},
|
||||
"PairedClient": {
|
||||
"type": "object",
|
||||
"description": "A paired (certificate-pinned) Moonlight client.",
|
||||
@@ -6805,6 +6971,17 @@
|
||||
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||
"minimum": 0
|
||||
},
|
||||
"audio": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AudioWiring",
|
||||
"description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime."
|
||||
}
|
||||
]
|
||||
},
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
|
||||
+20
-1
@@ -9,7 +9,26 @@
|
||||
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
|
||||
# the gamescope cache already embraces ("a stale binary against newer system libs is the
|
||||
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
|
||||
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
|
||||
# bumping the date in this line (refreshed: 2026-08-08) — re-keys and re-snapshots it.
|
||||
#
|
||||
# ⚠ That staleness has a sharp edge, and 2026-08-08 is why the date above moved: this snapshot is
|
||||
# what decides which FFmpeg the HOST links, and arch.yml deliberately runs no -Syu, so the builder
|
||||
# stayed frozen on ffmpeg 8 (libavcodec 62) even after Arch shipped 2:9.0-5 (libavcodec 63) to
|
||||
# every user. A canary built from the old snapshot therefore CANNOT satisfy the soname dep that
|
||||
# packaging/arch/PKGBUILD now derives from the link (libavcodec.so=62-64 against a box that has
|
||||
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
|
||||
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
|
||||
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
|
||||
#
|
||||
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
|
||||
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
|
||||
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
|
||||
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
|
||||
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
|
||||
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
|
||||
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
|
||||
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
|
||||
# backstop, not the plan.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
|
||||
@@ -45,6 +45,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
#
|
||||
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
|
||||
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
|
||||
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
|
||||
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
|
||||
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
|
||||
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
|
||||
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
|
||||
@@ -13,7 +13,9 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
|
||||
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
|
||||
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
|
||||
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \
|
||||
libavdevice-dev \
|
||||
# capture / audio / display stacks (+xkbcommon for the wlr input backend)
|
||||
|
||||
@@ -49,7 +49,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
bytes 1.12.0 — MIT — https://github.com/tokio-rs/bytes
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cfg-if 1.0.4 — MIT OR Apache-2.0 — https://github.com/rust-lang/cfg-if
|
||||
cfg_aliases 0.2.1 — MIT — https://github.com/katharostech/cfg_aliases
|
||||
@@ -88,7 +88,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastbloom 0.14.1 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
fnv 1.0.7 — Apache-2.0 / MIT — https://github.com/servo/rust-fnv
|
||||
@@ -1390,7 +1390,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.9, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.10, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -2435,7 +2435,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -126,6 +127,8 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
|
||||
@@ -159,7 +162,7 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
@@ -445,12 +448,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
@@ -598,6 +602,18 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
|
||||
// mirror FROM, which a TV box lacks.
|
||||
if (hasGyroscope) {
|
||||
toggle(
|
||||
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
|
||||
"When the controller has no gyro of its own, send this phone's motion " +
|
||||
"sensors as controller 1's — for clip-on pads without one.",
|
||||
s.gyroOnPhone,
|
||||
) { update(s.copy(gyroOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
|
||||
@@ -158,6 +158,16 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
/**
|
||||
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
|
||||
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
|
||||
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
|
||||
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
|
||||
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
|
||||
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
|
||||
* without a gyroscope (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val gyroOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
@@ -300,6 +310,7 @@ class SettingsStore(context: Context) {
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
@@ -340,6 +351,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
@@ -390,6 +402,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_GYRO_ON_PHONE = "gyro_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
|
||||
@@ -77,6 +77,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -849,7 +850,8 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
"every connected one is forwarded as its own player. An X-Box type has no " +
|
||||
"gyroscope, so pick a DualSense-class one if you want motion.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
@@ -888,6 +890,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
|
||||
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
if (hasGyroscope) {
|
||||
ToggleRow(
|
||||
title = "Gyro from this phone",
|
||||
subtitle = "When the controller has no gyro, send this phone's motion " +
|
||||
"sensors as controller 1's",
|
||||
checked = s.gyroOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
|
||||
@@ -18,12 +18,13 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
|
||||
* audioAvOffsetMs]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
|
||||
@@ -44,7 +45,7 @@ import kotlin.math.roundToInt
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* and the excluded-floor line when one was measured.
|
||||
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@@ -178,10 +179,42 @@ internal fun StatsOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (detailed) {
|
||||
audioLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio plane's own latency from the live gauges at 33/34 — `audio buffer 42 ms · a/v +18 ms`,
|
||||
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
|
||||
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
|
||||
* before any audio has been queued (buffer 0 — audio off, or the ring not yet primed) and on an
|
||||
* older native layout.
|
||||
*
|
||||
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour — the
|
||||
* underrun-driven floor earned that buffer — and only the offset distinguishes it from a ring that
|
||||
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
|
||||
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
|
||||
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
|
||||
* is way too high" report had no instrument behind it).
|
||||
*
|
||||
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy —
|
||||
* metrics report what Punktfunk controls — but sound has to reach the ear when the light reaches
|
||||
* the eye, so the sync loop aligns against the RAW capture→displayed time (see the native
|
||||
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
|
||||
* report an alignment the listener is not getting.
|
||||
*/
|
||||
private fun audioLine(s: DoubleArray): String? {
|
||||
if (s.size < 35) return null
|
||||
val bufferMs = s[33].roundToInt()
|
||||
if (bufferMs <= 0) return null
|
||||
val avOffset = s[34].roundToInt()
|
||||
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
|
||||
return "audio buffer $bufferMs ms$avTerm"
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
|
||||
@@ -67,11 +67,13 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -137,6 +139,19 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
micHint = null
|
||||
}
|
||||
}
|
||||
// A captured pad has a gyro this session's virtual controller cannot carry (see
|
||||
// GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise
|
||||
// completely silent — the gyro simply does nothing, which from the couch is indistinguishable
|
||||
// from a broken sensor — and the fix is a setting, so the notice has to name it.
|
||||
var motionHint by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(motionHint) {
|
||||
if (motionHint) {
|
||||
// Longer than the mic chord's 1.6 s: that one confirms something the user just did,
|
||||
// this one explains something they did not, in a sentence they have to read.
|
||||
delay(6000)
|
||||
motionHint = false
|
||||
}
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
@@ -359,6 +374,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
router.onMicChord = {
|
||||
if (micRunning) {
|
||||
val next = !micMuted
|
||||
@@ -455,9 +473,39 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
|
||||
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
|
||||
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
|
||||
// down whenever pad 0's controller has motion of its own — a capture link below, or a
|
||||
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
|
||||
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
|
||||
// capture links below ever sees it and its motion used to go nowhere at all. No separate
|
||||
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
|
||||
// captures it claims nothing; forwarding being off is the only thing that silences it.
|
||||
val padSensors = if (initialSettings.gamepadForwarding) {
|
||||
PadSensors(router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// session leaks until the session ends), and take its sensor listeners off with it — the
|
||||
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
|
||||
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
|
||||
// the binds. Assigned before the captures are constructed, so their claims land on it.
|
||||
router.onSlotClosed = { deviceId ->
|
||||
feedback.onDeviceRemoved(deviceId)
|
||||
padSensors?.onSlotClosed(deviceId)
|
||||
}
|
||||
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
|
||||
// read too. The pads already connected were swept by PadSensors.start() above — both run
|
||||
// on the main thread with nothing between them, so no controller falls through the gap.
|
||||
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
@@ -587,12 +635,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
|
||||
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
|
||||
// sensors let go of it; before the router is released, so the parks still find slots.
|
||||
padSensors?.stop()
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
@@ -853,6 +906,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
if (motionHint) {
|
||||
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +997,28 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
|
||||
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
|
||||
* HID contract, so every sample would be decoded and dropped host-side).
|
||||
*
|
||||
* It names the setting because that is the whole point: without it the player has a gyro that
|
||||
* silently does nothing and no way to tell that from a broken sensor. Not a control — the setting
|
||||
* applies from the next session, so offering to change it here would promise something this stream
|
||||
* cannot deliver. [GamepadRouter.onMotionUnreachable] raises it.
|
||||
*/
|
||||
@Composable
|
||||
private fun MotionUnreachableHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Motion won't reach this session — set Controller type to DualSense",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
|
||||
@@ -123,7 +123,9 @@ class GamepadPaletteTest {
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
@@ -137,7 +139,9 @@ class GamepadPaletteTest {
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
@@ -24,6 +24,7 @@ class GamepadSettingsRowsTest {
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The stats HUD's audio line — `audio buffer N ms · a/v ±N ms`, from the live gauges at indexes
|
||||
* 33/34 (`design/audio-latency-overhaul.md`).
|
||||
*
|
||||
* Worth pinning because the whole point of the overhaul's stats half is that the audio plane became
|
||||
* OBSERVABLE. Before it, ring depth and A/V offset existed only as a log line, and on a device
|
||||
* launched by a game launcher that goes to a pipe nobody can read — so the single number that
|
||||
* identifies a deep ring was unobtainable on the exact device reporting the latency, and a field
|
||||
* investigation ran to its conclusion without it. A measurement that never reaches a surface is
|
||||
* indistinguishable from no measurement, which is what this asserts.
|
||||
*
|
||||
* `sdk = [36]` for the same reason as the screenshot tests: Robolectric ships android-all jars only
|
||||
* up to API 36 while the app's compileSdk is 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class StatsOverlayAudioTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
/**
|
||||
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
|
||||
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
|
||||
*/
|
||||
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
|
||||
val full = doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
0.1, 0.3, 0.0,
|
||||
bufferMs, avOffsetMs,
|
||||
)
|
||||
return full.copyOf(size)
|
||||
}
|
||||
|
||||
private fun show(s: DoubleArray, verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
compose.setContent { StatsOverlay(s, verbosity = verbosity) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailedShowsDepthAndOffset() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0))
|
||||
// Positive = audio playing BEHIND the picture, and the sign is explicit so a glance tells
|
||||
// which way the loop still has to move.
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v +18 ms").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioAheadOfThePictureReadsNegative() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = -12.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v -12 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Aligned (or not yet measured) drops the offset term; the depth alone is still the triage number. */
|
||||
@Test
|
||||
fun alignedShowsDepthAlone() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Nothing queued (audio off, or the ring not yet primed) — the line has nothing to say. */
|
||||
@Test
|
||||
fun silentPlaneRendersNoLine() {
|
||||
show(stats(bufferMs = 0.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The line is DETAILED-only, like every other per-stage figure. */
|
||||
@Test
|
||||
fun normalTierOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0), verbosity = StatsVerbosity.NORMAL)
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** An older native lib emits 33 doubles; the overlay must omit the line, not index past the end. */
|
||||
@Test
|
||||
fun olderNativeLayoutOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -355,10 +355,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
|
||||
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
|
||||
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
|
||||
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
|
||||
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
|
||||
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
@@ -376,6 +378,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
// The decode term's own split (feed + codec = 0.4), and no overflow — the one
|
||||
// `skipped` above is benign newest-wins pacing, not a decoder falling behind.
|
||||
0.1, 0.3, 0.0,
|
||||
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
|
||||
// loop, i.e. inside the deadband it deliberately leaves alone.
|
||||
28.0, 4.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
|
||||
@@ -67,30 +67,37 @@ fun androidSdkDir(): String {
|
||||
return "${System.getProperty("user.home")}/Library/Android/sdk"
|
||||
}
|
||||
|
||||
// Every cargo-ndk invocation needs the same discovery environment, and they must not drift apart:
|
||||
// a lint that ran against a different toolchain/sysroot than the build is a lint about a different
|
||||
// program. Applied by both `registerCargoNdk` (build) and `registerCargoNdkClippy` (lint).
|
||||
fun Exec.cargoNdkEnvironment() {
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
}
|
||||
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
|
||||
workingDir = repoRoot
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
cargoNdkEnvironment()
|
||||
// Resolve cargo by ABSOLUTE path: Gradle's Exec resolves command[0] via the JVM's
|
||||
// inherited PATH, NOT the environment("PATH", …) set above (that only reaches the spawned
|
||||
// child). A GUI Android Studio launch (and any daemon it started) has no ~/.cargo/bin on
|
||||
@@ -113,6 +120,41 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Lint the ANDROID target. `punktfunk-client-android` and every `#[cfg(target_os = "android")]`
|
||||
// module elsewhere in the workspace were, until this task existed, **completely unlinted**: ci.yml
|
||||
// runs `cargo clippy --workspace` on the HOST, where all of that code is compiled out, and this
|
||||
// workflow only ever ran `build`. The gap was found in 2026-08 with five lints sitting in
|
||||
// clients/android/native (two of them `unnecessary_cast`, which is exactly the class that decides
|
||||
// whether a cast is redundant BY POINTER WIDTH).
|
||||
//
|
||||
// Both widths are linted, and that is the load-bearing part: arm64-v8a is 64-bit and armeabi-v7a is
|
||||
// 32-bit, so a cast that is redundant on one can be required on the other. Linting only the primary
|
||||
// ABI would license "fixes" that break the 32-bit build — the shipping ABI for the many 32-bit
|
||||
// Google TV / Android TV boxes this client targets. x86_64 is deliberately omitted: it is
|
||||
// emulator-only and shares its pointer width with arm64, so it costs a third of the job's lint time
|
||||
// for no signal these two do not already carry.
|
||||
//
|
||||
// `--all-targets` for the same reason ci.yml spells it out: without it the `#[cfg(test)]` modules
|
||||
// are never compiled, and un-compiled test code drifts silently.
|
||||
fun registerCargoNdkClippy(taskName: String) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "verification"
|
||||
description = "clippy (deny warnings) for punktfunk-client-android on both Android widths"
|
||||
workingDir = repoRoot
|
||||
cargoNdkEnvironment()
|
||||
commandLine(
|
||||
// Absolute cargo path for the same reason as the build task above.
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a",
|
||||
"--platform", "28",
|
||||
"clippy", "-p", "punktfunk-client-android", "--all-targets",
|
||||
"--", "-D", "warnings",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkClippy = registerCargoNdkClippy("cargoNdkClippy")
|
||||
|
||||
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
|
||||
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
|
||||
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
|
||||
/**
|
||||
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
|
||||
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
|
||||
* motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body
|
||||
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
|
||||
* rumble-on-phone mirror with the data flowing the other way.
|
||||
*
|
||||
* On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a
|
||||
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
|
||||
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing —
|
||||
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
|
||||
* at all (motion never creates a host pad; a controller must have arrived first).
|
||||
*
|
||||
* Two properties this class enforces itself:
|
||||
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) —
|
||||
* sensor batching would trade the exact latency gyro aim exists to avoid;
|
||||
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
|
||||
* host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
*
|
||||
* Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] —
|
||||
* the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected
|
||||
* once for every sender rather than once per sender that someone remembers. The one thing the
|
||||
* phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
|
||||
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
|
||||
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
|
||||
* correctable in one place if on-glass says otherwise.
|
||||
*/
|
||||
class DeviceGyro(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter,
|
||||
) : SensorEventListener {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
/** For the live rotation; null on contexts without a display association (then portrait). */
|
||||
private val display: Display? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(WindowManager::class.java)?.defaultDisplay
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private val thread = HandlerThread("pf-phone-gyro")
|
||||
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
|
||||
private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
|
||||
/** Register the listeners; a device without a gyroscope makes this a no-op. */
|
||||
fun start() {
|
||||
val sm = sensorManager ?: return
|
||||
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
thread.start()
|
||||
val h = Handler(thread.looper)
|
||||
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
|
||||
// latency: batching is poison for gyro aim.
|
||||
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
|
||||
* mirror was the live writer. Call BEFORE the router is released / the handle freed —
|
||||
* teardown-ordered like the feedback threads.
|
||||
*/
|
||||
fun stop() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = display?.rotation ?: Surface.ROTATION_0
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
return
|
||||
}
|
||||
wasWriting = true
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0,
|
||||
Gamepad.motionGyroWire(v[0]),
|
||||
Gamepad.motionGyroWire(v[1]),
|
||||
Gamepad.motionGyroWire(v[2]),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free-fall. */
|
||||
private fun sendZero() {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Whether this device can source motion at all — gates the settings rows (a TV box
|
||||
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
|
||||
fun available(context: Context): Boolean =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
|
||||
|
||||
/**
|
||||
* ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also
|
||||
* the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+),
|
||||
* so asking for more would only be silently capped. Shared with [PadSensors].
|
||||
*/
|
||||
internal const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
* same way under an in-plane rotation) into the controller frame for [rotation]
|
||||
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
|
||||
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
|
||||
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
|
||||
* the device physically turned counter-clockwise, top to the player's LEFT.
|
||||
*/
|
||||
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
|
||||
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
|
||||
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x
|
||||
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
|
||||
else -> floatArrayOf(x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import android.view.InputDevice
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled
|
||||
* into the wire's units by this pad's own calibration — read once per claim, off the claiming
|
||||
* thread, with the nominal scaling standing in for the millisecond that read is in flight rather
|
||||
* than the UI waiting on a control transfer). The wire slot is claimed when the capture engages,
|
||||
* with the first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
@@ -55,6 +57,13 @@ class DsCapture(
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
/** This pad's factory motion scale, read once per capture on [calReader] and handed to the
|
||||
* link thread, which scales nominally until it lands — see [MotionCalHandoff]. */
|
||||
private val motionCal = MotionCalHandoff()
|
||||
|
||||
/** The thread doing the claim-time calibration read, kept for the teardown wait. */
|
||||
@Volatile private var calReader: Thread? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
@@ -124,6 +133,11 @@ class DsCapture(
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
// Before `model`, which is what lets the link thread into the parse at all: opening the
|
||||
// claim forgets the last pad's calibration, so reports arriving while this pad's own read
|
||||
// (below, off this thread) is in flight fall back to the nominal scaling rather than to
|
||||
// another unit's factory numbers.
|
||||
val claim = motionCal.begin()
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
@@ -135,9 +149,88 @@ class DsCapture(
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
readMotionCalAsync(m, claim)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start this claim's calibration read, on its own thread.
|
||||
*
|
||||
* Off the caller's thread because [startUsb] runs on the main one — stream setup, and the
|
||||
* USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is
|
||||
* there answers in about a millisecond, but one that is stalling takes the link's whole write
|
||||
* timeout, and the interface must wait for neither. The pad is live throughout, its motion
|
||||
* nominally scaled until this lands ([onReport]), so even a pad that never answers costs
|
||||
* precision rather than the UI or the controller.
|
||||
*
|
||||
* One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is
|
||||
* awaited by [awaitCalRead] before the connection it reads from can be closed.
|
||||
*/
|
||||
private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) {
|
||||
val t = Thread({
|
||||
// A read that throws would otherwise leave the capture on the nominal scaling with
|
||||
// nothing in the log to say why — the one outcome that looks identical to a pad whose
|
||||
// calibration is genuinely nominal. Publish the fallback explicitly, and say so.
|
||||
val cal = runCatching { readMotionCal(m) }.getOrElse {
|
||||
Log.w(TAG, "motion calibration read failed — nominal scaling", it)
|
||||
DsDevice.MotionCal.NOMINAL
|
||||
}
|
||||
// Discarded when the claim is already over (unplug, stop, or a re-claim beat us here):
|
||||
// scaling the NEXT pad by this one's factory numbers would be worse than not reading.
|
||||
if (!motionCal.publish(claim, cal)) {
|
||||
Log.i(TAG, "motion calibration arrived after the claim ended — discarded")
|
||||
}
|
||||
}, "pf-ds-cal")
|
||||
calReader = t
|
||||
t.isDaemon = true
|
||||
t.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an in-flight calibration read to let go of the USB connection, before a teardown
|
||||
* closes it.
|
||||
*
|
||||
* Not politeness: the read is a control transfer on the very connection [HidUsbLink.stop] is
|
||||
* about to close, and closing a descriptor with a transfer in flight pulls it out from under
|
||||
* the kernel — the same rule the pad-audio borrow follows. Bounded, and in every case but a
|
||||
* pad that has stopped answering the thread is long gone, so this returns immediately. It can
|
||||
* never deadlock: the reading thread waits on nothing this one holds ([MotionCalHandoff] has
|
||||
* its own monitor, and the read itself takes no lock).
|
||||
*/
|
||||
private fun awaitCalRead() {
|
||||
val t = calReader ?: return
|
||||
calReader = null
|
||||
if (!t.isAlive) return
|
||||
runCatching { t.join(CAL_JOIN_MS) }
|
||||
if (t.isAlive) Log.w(TAG, "calibration read still in flight at teardown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this pad's IMU calibration — the feature report that says how many raw counts this
|
||||
* individual unit puts on a °/s and on a g ([DsDevice.MotionCal]).
|
||||
*
|
||||
* Once, at claim time, and nowhere else: the calibration is fixed for the life of the
|
||||
* connection, so doing it per input report would buy nothing and cost the capture its latency.
|
||||
* A pad that refuses keeps the nominal scaling rather than losing motion altogether.
|
||||
*/
|
||||
private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal {
|
||||
val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen)
|
||||
val cal = DsDevice.MotionCal.parse(blob, m.calReportId)
|
||||
// Worth a line either way: this is the number the owed on-glass check reads back — a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
if (cal === DsDevice.MotionCal.NOMINAL) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"motion calibration 0x%02x unreadable (%d/%d B) — nominal scaling (%s)".format(
|
||||
m.calReportId, blob?.size ?: 0, m.calReportLen, cal,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "motion calibration 0x%02x: %s".format(m.calReportId, cal))
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
@@ -157,6 +250,10 @@ class DsCapture(
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
// End the claim before waiting on it: a calibration that lands after this publishes
|
||||
// nothing, and then the wait makes sure nothing is still reading the connection below.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -168,7 +265,10 @@ class DsCapture(
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
// Nominal scaling until this claim's calibration read lands (see MotionCalHandoff): for
|
||||
// that millisecond the pad behaves as it did before the read existed, which nobody can
|
||||
// feel — unlike a pad whose buttons wait on a control transfer.
|
||||
if (!DsDevice.parseState(m, report, len, state, motionCal.effective)) return
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
@@ -189,7 +289,9 @@ class DsCapture(
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
// hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out
|
||||
// on the rich plane — so a session that cannot carry it is worth saying out loud.
|
||||
val p = router.openExternal(m.pref, hasGyro = true) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
@@ -279,6 +381,10 @@ class DsCapture(
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// As in stop(): end the claim so a late calibration publishes nothing, then wait for the
|
||||
// read to let go of the connection the line below closes.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
@@ -310,8 +416,8 @@ class DsCapture(
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
* on change per slot; motion forwarded every report (already in wire units — the parse applies
|
||||
* this pad's calibration, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
@@ -483,5 +589,9 @@ class DsCapture(
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
|
||||
/** Teardown's budget for an in-flight calibration read. Comfortably past the link's own
|
||||
* EP0 timeout, so it only ever elapses for a pad that has stopped answering entirely. */
|
||||
const val CAL_JOIN_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
@@ -28,14 +30,168 @@ object DsDevice {
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space.
|
||||
* onto the wire's 0..65535 space, and the IMU-calibration feature report it answers
|
||||
* ([MotionCal]): DS5/Edge `0x05` (id + 40 B), DS4 over USB `0x02` (id + 36 B).
|
||||
*/
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
enum class Model(
|
||||
val pref: Int,
|
||||
val outputSize: Int,
|
||||
val touchW: Int,
|
||||
val touchH: Int,
|
||||
val calReportId: Int,
|
||||
val calReportLen: Int,
|
||||
) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080, 0x05, 41),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080, 0x05, 41),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942, 0x02, 37),
|
||||
}
|
||||
|
||||
/**
|
||||
* One pad's own IMU calibration: the factory scale factors that turn its raw motion counts
|
||||
* into the wire's fixed units (`punktfunk_core::input::gamepad` — 20 LSB per °/s, 10000 LSB
|
||||
* per g), read out of the calibration feature report the pad serves on EP0.
|
||||
*
|
||||
* **Why the pad's blob and not a constant.** Measured on glass 2026-08-07: a DualSense flat
|
||||
* and face up arrived as 0.811 g where 1.000 was owed, because this path forwarded the raw
|
||||
* i16s verbatim. The nominal ×10000/8192 rescale that first closed that gap ([NOMINAL]) still
|
||||
* leaves that unit's factory bias — about 1 % — on acceleration, and provably cannot fix gyro
|
||||
* at all: the same still-average showed this pad's gyro calibration is nowhere near identity,
|
||||
* and a near-identity one would mean 1024 LSB per °/s, i.e. ±32 °/s full scale, which no
|
||||
* controller has. The scale is per unit; only the pad knows it.
|
||||
*
|
||||
* The arithmetic is `hid-playstation`'s, and the host's contract test
|
||||
* (`crates/pf-inject/tests/motion_contract.rs`, `SonyImuCalibration`) is the same math read
|
||||
* from the other end — it applies it to the blobs our *virtual* pads declare and asserts they
|
||||
* land on the wire constants. Per axis: gyro `raw × speed_2x × 20 / (|plus − bias| +
|
||||
* |minus − bias|)`, accel `(raw − (plus − range/2)) × 20000 / range`, where `range = plus −
|
||||
* minus` spans 2 g.
|
||||
*/
|
||||
class MotionCal private constructor(
|
||||
/** Per axis: `speed_2x × 20`, over `|plus − bias| + |minus − bias|`. */
|
||||
private val gyroNumer: LongArray,
|
||||
private val gyroDenom: LongArray,
|
||||
/** Per axis: the raw count the pad reads at 0 g, and the raw span of 2 g. */
|
||||
private val accelBias: LongArray,
|
||||
private val accelRange: LongArray,
|
||||
) {
|
||||
/** Raw gyro count on [axis] (0 = pitch, 1 = yaw, 2 = roll) → the wire's 20 LSB per °/s. */
|
||||
fun gyroToWire(axis: Int, raw: Int): Int =
|
||||
clampWire(raw.toLong() * gyroNumer[axis] / gyroDenom[axis])
|
||||
|
||||
/** Raw acceleration count on [axis] → the wire's 10000 LSB per g, zero point removed. */
|
||||
fun accelToWire(axis: Int, raw: Int): Int =
|
||||
clampWire((raw - accelBias[axis]) * ACCEL_NUMER / accelRange[axis])
|
||||
|
||||
/**
|
||||
* The derived resolutions, for the capture's one-line claim log — the number that says
|
||||
* whether a pad's blob was actually read (a real DualSense declares ≈16 LSB/°·s and ≈8192
|
||||
* LSB/g; the [NOMINAL] fallback reads back as exactly 20 and 8192).
|
||||
*/
|
||||
override fun toString(): String = buildString {
|
||||
append("gyro ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(gyroDenom[i] * WIRE_GYRO_LSB_PER_DEG_S / gyroNumer[i])
|
||||
}
|
||||
append(" LSB/°·s, accel ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(accelRange[i] / 2)
|
||||
}
|
||||
append(" LSB/g at ")
|
||||
append(accelBias.joinToString("/"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both conversions are a >1 multiplier on every pad measured so far, so a real ±4 g slam
|
||||
* or a fast flick near full scale would otherwise wrap the i16 and read as an impossible
|
||||
* motion in the opposite direction.
|
||||
*/
|
||||
private fun clampWire(v: Long): Int = v.coerceIn(-32768L, 32767L).toInt()
|
||||
|
||||
companion object {
|
||||
/** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */
|
||||
private const val RAW_ACCEL_LSB_PER_G = 8192L
|
||||
/**
|
||||
* The wire's gyro scale, taken from [Gamepad] rather than restated. These were literal
|
||||
* `20L` / `10000L` until the sensor path hoisted the same numbers into one place; a
|
||||
* second copy of a unit constant is precisely the defect this whole program opened
|
||||
* with, and two of them in one module would be worse than the original.
|
||||
*
|
||||
* `val`, not `const val`, only because the widening to Long is not a compile-time
|
||||
* constant expression. Long here on purpose: the arithmetic below multiplies raw counts
|
||||
* by the calibration's speed term before dividing, which overflows an Int.
|
||||
*/
|
||||
private val WIRE_GYRO_LSB_PER_DEG_S = Gamepad.MOTION_GYRO_LSB_PER_DEG_S.toLong()
|
||||
/** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */
|
||||
private val ACCEL_NUMER = 2L * Gamepad.MOTION_ACCEL_LSB_PER_G
|
||||
/** Bytes the layout below reads; the reports themselves are longer (41 / 37). */
|
||||
private const val MIN_LEN = 35
|
||||
|
||||
/**
|
||||
* What an unreadable pad gets: gyro straight through and accel on the nominal 8192
|
||||
* LSB/g. Wrong by that unit's factory bias, and for gyro wrong by however far its
|
||||
* scale sits from the wire's 20 — but a pad whose calibration cannot be read is far
|
||||
* better off slightly mis-scaled than silent, so this never zeroes motion.
|
||||
*/
|
||||
val NOMINAL = MotionCal(
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3),
|
||||
LongArray(3) { 2 * RAW_ACCEL_LSB_PER_G },
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse a calibration feature report ([Model.calReportId]) — all little-endian i16:
|
||||
* `[0]` report id, `[1..7)` gyro bias (pitch, yaw, roll), `[7..19)` gyro plus/minus
|
||||
* INTERLEAVED (pitch+, pitch−, yaw+, yaw−, roll+, roll−), `[19..23)` the two speed
|
||||
* words, `[23..35)` accel plus/minus (x+, x−, y+, y−, z+, z−).
|
||||
*
|
||||
* ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses
|
||||
* before the three minuses and consumers switch layout on the transport — this path is
|
||||
* USB-only by construction (see the file header), so do not "generalise" it.
|
||||
*
|
||||
* Falls back to [NOMINAL] for a failed read (null), a truncated or foreign reply, and
|
||||
* per axis for a degenerate declaration — a clone or broken pad that declares zeroes
|
||||
* would otherwise divide by zero (`hid-playstation` guards the same case, for the same
|
||||
* reason).
|
||||
*/
|
||||
fun parse(blob: ByteArray?, reportId: Int): MotionCal {
|
||||
if (blob == null || blob.size < MIN_LEN) return NOMINAL
|
||||
if ((blob[0].toInt() and 0xFF) != reportId) return NOMINAL
|
||||
val w = { o: Int ->
|
||||
((blob[o + 1].toInt() shl 8) or (blob[o].toInt() and 0xFF)).toShort().toLong()
|
||||
}
|
||||
val speed2x = w(19) + w(21)
|
||||
val gyroNumer = LongArray(3)
|
||||
val gyroDenom = LongArray(3)
|
||||
val accelBias = LongArray(3)
|
||||
val accelRange = LongArray(3)
|
||||
for (i in 0 until 3) {
|
||||
val bias = w(1 + 2 * i)
|
||||
val denom = abs(w(7 + 4 * i) - bias) + abs(w(9 + 4 * i) - bias)
|
||||
if (speed2x > 0 && denom > 0) {
|
||||
gyroNumer[i] = speed2x * WIRE_GYRO_LSB_PER_DEG_S
|
||||
gyroDenom[i] = denom
|
||||
} else {
|
||||
gyroNumer[i] = 1 // passthrough, as before any calibration existed
|
||||
gyroDenom[i] = 1
|
||||
}
|
||||
val plus = w(23 + 4 * i)
|
||||
val range = plus - w(25 + 4 * i)
|
||||
if (range > 0) {
|
||||
accelBias[i] = plus - range / 2
|
||||
accelRange[i] = range
|
||||
} else {
|
||||
accelBias[i] = 0 // nominal, as NOMINAL above
|
||||
accelRange[i] = 2 * RAW_ACCEL_LSB_PER_G
|
||||
}
|
||||
}
|
||||
return MotionCal(gyroNumer, gyroDenom, accelBias, accelRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
@@ -50,8 +206,9 @@ object DsDevice {
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel arrive in WIRE units — the wire's
|
||||
* `Motion` is a unit passthrough into the virtual pad's report, so the pad's raw counts are
|
||||
* rescaled during the parse by the [MotionCal] handed to [parseState]. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
@@ -59,8 +216,8 @@ object DsDevice {
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val gyro = IntArray(3) // wire i16: 20 LSB per °/s (pitch/yaw/roll)
|
||||
val accel = IntArray(3) // wire i16: 10000 LSB per g
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
@@ -108,15 +265,25 @@ object DsDevice {
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*
|
||||
* [cal] is this pad's own motion calibration, read once when the capture claims it; the
|
||||
* default is the nominal fallback, which is all a caller without a live pad (the tests) can
|
||||
* have.
|
||||
*/
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
fun parseState(
|
||||
model: Model,
|
||||
report: ByteArray,
|
||||
len: Int,
|
||||
out: State,
|
||||
cal: MotionCal = MotionCal.NOMINAL,
|
||||
): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out)
|
||||
parseDs4(report, len, out, cal)
|
||||
} else {
|
||||
parseDs5(model, report, len, out)
|
||||
parseDs5(model, report, len, out, cal)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -152,8 +319,8 @@ object DsDevice {
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 16 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 22 + 2 * i))
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
@@ -162,7 +329,7 @@ object DsDevice {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -188,8 +355,8 @@ object DsDevice {
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 13 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 19 + 2 * i))
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Android gamepad capture → punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host
|
||||
@@ -54,6 +55,31 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion
|
||||
// sender on this client goes through the two converters below, so a scale that ever has to
|
||||
// change changes in ONE place: the gyro program's first finding was a client sending 40× hot
|
||||
// because a second copy of the number had drifted.
|
||||
const val MOTION_GYRO_LSB_PER_DEG_S = 20
|
||||
const val MOTION_ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */
|
||||
const val GRAVITY = 9.80665f
|
||||
|
||||
/** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */
|
||||
const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat()
|
||||
|
||||
/** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */
|
||||
fun motionGyroWire(radPerSec: Float): Int =
|
||||
(radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
/**
|
||||
* One acceleration component, Android's m/s² → the wire's signed-16 raw units. Android reports
|
||||
* specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own
|
||||
* convention — no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly.
|
||||
*/
|
||||
fun motionAccelWire(mPerSecSq: Float): Int =
|
||||
(mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
* threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
|
||||
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
@@ -69,7 +71,18 @@ class GamepadRouter(
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
val mapper: Gamepad.AxisMapper,
|
||||
/**
|
||||
* Whether motion sent for this pad can reach the game at all, asked once at open off the
|
||||
* kind it declared ([NativeBridge.nativePadMotionReaches]). False means the host built it a
|
||||
* backend with no motion plane, so [deviceMotion] drops the sample here rather than paying
|
||||
* to send one the host will decode and discard — at a controller's full sensor rate, for
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
@@ -85,12 +98,33 @@ class GamepadRouter(
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
* deviceIds whose own gyro [PadSensors] is currently reading — see [setDeviceHasSensorMotion].
|
||||
* Written on the main thread, read from the phone-gyro thread, hence a concurrent set.
|
||||
*/
|
||||
private val sensorDevices: MutableSet<Int> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug, a capture link's
|
||||
* [releaseDevice] claim, or session teardown. `StreamScreen` wires this to
|
||||
* `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are
|
||||
* released promptly instead of leaking until the feedback threads stop, and to
|
||||
* [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller — the
|
||||
* hot-plug callback or the first input from a pad the session started without. Not fired for
|
||||
* [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from
|
||||
* its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened].
|
||||
*
|
||||
* Slots opened in `init` (every controller already connected) predate any assignment here, so
|
||||
* a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread
|
||||
* inside one composition block, so nothing can slip between the sweep and the assignment.
|
||||
*/
|
||||
var onSlotOpened: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
@@ -115,6 +149,17 @@ class GamepadRouter(
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
|
||||
* a session whose virtual pad has no motion plane — its motion is not being sent, because every
|
||||
* sample would be decoded and dropped host-side.
|
||||
*
|
||||
* It exists because the failure is otherwise completely silent: the gyro just does nothing, and
|
||||
* from the couch that is indistinguishable from a broken sensor. The fix is the Controller type
|
||||
* setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice.
|
||||
*/
|
||||
var onMotionUnreachable: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -320,13 +365,82 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro
|
||||
* [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense /
|
||||
* Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one
|
||||
* wire pad would fight, and the pad's own IMU is the one attached to the player's hands.
|
||||
* Read from the phone-gyro thread (both tables are concurrent).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) }
|
||||
|
||||
/**
|
||||
* Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation — see
|
||||
* [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the
|
||||
* main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device
|
||||
* rather than by pad index so a controller that changes wire index (a lower one freed up while
|
||||
* it was captured) carries the fact with it.
|
||||
*/
|
||||
fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) {
|
||||
if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId)
|
||||
// This is the first moment we know a Bluetooth pad actually HAS a gyro — `openSlot` only
|
||||
// knows what kind it declared. So it is the honest place to raise the notice when that
|
||||
// gyro has nowhere to go, and the only one that cannot nag about a pad that never had one.
|
||||
if (has && forwarding && slots[deviceId]?.motionReaches == false) {
|
||||
onMotionUnreachable?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its
|
||||
* slot currently holds — [ExternalPad.motion] for pads the input stack still owns. Silently
|
||||
* drops when the slot is gone (unplugged, or claimed by a capture link between the sensor
|
||||
* callback and here) rather than writing to an index that may already belong to someone else.
|
||||
* Called from [PadSensors]' sensor thread.
|
||||
*/
|
||||
fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) {
|
||||
val slot = slots[deviceId] ?: return
|
||||
if (!forwarding) return
|
||||
// The same gate the USB capture path takes: a backend with no motion plane decodes every
|
||||
// sample and discards it, so sending is pure cost. Notified once per pad by
|
||||
// [setDeviceHasSensorMotion], which is where we first know the controller HAS a gyro to
|
||||
// lose — a pad without one must not produce a warning about motion.
|
||||
if (!slot.motionReaches) return
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, slot.index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot of the REAL controllers currently forwarded, as deviceIds — the set [PadSensors]
|
||||
* sweeps at start for the pads that were already connected when the session opened. */
|
||||
fun forwardedDevices(): List<Int> = slots.keys.filter { it >= 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
inner class ExternalPad internal constructor(
|
||||
private val syntheticId: Int,
|
||||
val index: Int,
|
||||
/**
|
||||
* Whether this pad's motion can reach the game at all, asked once at open (see
|
||||
* [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend
|
||||
* without a motion plane, so [motion] drops the sample here instead of paying to send one
|
||||
* the host will decode and discard — at a controller's full report rate, for the whole
|
||||
* session.
|
||||
*/
|
||||
private val motionReaches: Boolean,
|
||||
) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
@@ -357,7 +471,7 @@ class GamepadRouter(
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null && forwarding) {
|
||||
if (slot != null && forwarding && motionReaches) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -373,15 +487,26 @@ class GamepadRouter(
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*
|
||||
* [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion]) —
|
||||
* true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2,
|
||||
* whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries
|
||||
* and which nothing here may second-guess. It gates only the notice: a pad that never sends
|
||||
* motion must not produce a warning about motion.
|
||||
*/
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
// Asked once, 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. 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))
|
||||
return ExternalPad(syntheticId, index)
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,8 +562,18 @@ class GamepadRouter(
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
// 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 slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
// told ([PadSensors]) finds an index to send on rather than dropping its first samples.
|
||||
onSlotOpened?.invoke(dev.id)
|
||||
return slot
|
||||
}
|
||||
|
||||
|
||||
@@ -442,6 +442,42 @@ class HidUsbLink(
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one report back OUT of the device — HID `GET_REPORT`, the EP0 mirror of [sendReport].
|
||||
* [type] is [REPORT_TYPE_FEATURE] (or output), [id] the report number, [len] the report's full
|
||||
* declared size INCLUDING its leading id byte, which a numbered report echoes back in byte 0
|
||||
* (hidapi framing). Returns what arrived — truncated if the device answered short — or null
|
||||
* when the device refuses the request or the link is down.
|
||||
*
|
||||
* ⚠ **Once, at claim time; never per input report.** EP0 is independent of the interrupt
|
||||
* endpoints (see [sendReport]), so this is safe alongside the reader thread — but it BLOCKS the
|
||||
* calling thread for up to [WRITE_TIMEOUT_MS], and a blocking control transfer in the report
|
||||
* path would wreck capture latency. The one caller reads a Sony pad's fixed motion calibration
|
||||
* when the capture engages ([DsCapture]).
|
||||
*/
|
||||
fun getReport(type: Int, id: Int, len: Int): ByteArray? {
|
||||
if (len <= 0) return null
|
||||
val conn = connection ?: return null
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return null
|
||||
val buf = ByteArray(len)
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0xA1, // device→host, class, interface
|
||||
0x01, // GET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifId,
|
||||
buf,
|
||||
buf.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return when {
|
||||
n >= len -> buf
|
||||
n > 0 -> buf.copyOf(n)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
@@ -469,12 +505,13 @@ class HidUsbLink(
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
companion object {
|
||||
private const val READ_TIMEOUT_MS = 100L
|
||||
private const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
private const val ERROR_UNPLUG_MS = 2000L
|
||||
private const val REPORT_TYPE_OUTPUT = 0x02
|
||||
/** HID feature-report type — public for [getReport] callers ([writeRaw] takes a kind). */
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The hand-off of one claim's motion calibration, from the thread that reads it off the pad to the
|
||||
* link thread that scales every input report with it.
|
||||
*
|
||||
* [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread —
|
||||
* it is a blocking EP0 control transfer and the claim runs on the UI's thread — so the value lands
|
||||
* a moment after the capture goes live. Reports in that gap are scaled by
|
||||
* [DsDevice.MotionCal.NOMINAL] and forwarded like any other ([effective]): for about a millisecond
|
||||
* the pad behaves exactly as it did before the calibration read existed — acceleration a little
|
||||
* short, gyro unscaled — which nobody can feel, whereas a pad that ignores its buttons until an
|
||||
* EP0 read comes back is very obvious.
|
||||
*
|
||||
* What the hand-off is actually for is the two things that gap must NOT do, neither of which a
|
||||
* plain field gives:
|
||||
*
|
||||
* - **Fall back to the previous pad's numbers instead of the nominal ones.** Calibration is per
|
||||
* unit, so the last controller's scale factors are simply wrong for this one — more wrong, in
|
||||
* general, than the nominal constants. [begin] forgets them, which is what makes the gap
|
||||
* nominal rather than inherited.
|
||||
* - **Let a read that outlived its claim publish.** An unplug, a [DsCapture.stop] and a fast
|
||||
* re-claim can all land while a read is in flight; [publish] only accepts a value whose token is
|
||||
* still the live claim's, so a straggler can never scale a pad it never read.
|
||||
*
|
||||
* Thread-safe: claimed and ended by the claiming thread, published by the reading thread, read by
|
||||
* the link thread.
|
||||
*/
|
||||
internal class MotionCalHandoff {
|
||||
/** Handed out by [begin] and burned by [end] — never reused, so a straggler can't match. */
|
||||
private var token = 0
|
||||
|
||||
@Volatile private var cal: DsDevice.MotionCal? = null
|
||||
|
||||
/**
|
||||
* The calibration to scale the next report with: the live claim's own, or the nominal fallback
|
||||
* while its read is still in flight. Never null — a report is always forwarded, never held
|
||||
* back waiting for a control transfer.
|
||||
*/
|
||||
val effective: DsDevice.MotionCal get() = cal ?: DsDevice.MotionCal.NOMINAL
|
||||
|
||||
/** Open a claim: forget the previous pad's calibration, and take this claim's token. */
|
||||
@Synchronized
|
||||
fun begin(): Int {
|
||||
cal = null
|
||||
return ++token
|
||||
}
|
||||
|
||||
/** End the live claim. Nothing read under an older token can land after this. */
|
||||
@Synchronized
|
||||
fun end() {
|
||||
cal = null
|
||||
token++
|
||||
}
|
||||
|
||||
/** Publish [value] if [claim] is still the live claim; returns whether it landed. */
|
||||
@Synchronized
|
||||
fun publish(claim: Int, value: DsDevice.MotionCal): Boolean {
|
||||
if (claim != token) return false
|
||||
cal = value
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -264,12 +264,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 35 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -285,7 +285,10 @@ object NativeBridge {
|
||||
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
|
||||
* split `decode` (15) the same way — `feed` = received→queued (hand-off + input-slot wait),
|
||||
* `codec` = queued→decoded, the decoder's own time; 32 is the parked-AU overflow subset of
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing;
|
||||
* 33/34 are the AUDIO plane — the playback ring's live depth in ms and the A/V sync loop's
|
||||
* smoothed offset in ms, positive meaning audio plays BEHIND the picture. Those two are live
|
||||
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -516,6 +519,23 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed
|
||||
* to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped
|
||||
* by a host backend without a motion plane — the X-Box classes have no gyro in their HID
|
||||
* contract.
|
||||
*
|
||||
* Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than
|
||||
* reconstructed here from the session's requested/resolved prefs. The rule is subtler than it
|
||||
* looks (the host builds each pad from its OWN declaration and folds what it cannot build, so
|
||||
* neither the declaration nor the session echo answers it alone) and every way of getting it
|
||||
* wrong is silent, so it lives in one place with one set of tests.
|
||||
*
|
||||
* Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead — "don't
|
||||
* suppress" is the safe answer whenever we cannot tell.
|
||||
*/
|
||||
external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Motion from a controller the Android input stack owns — the Bluetooth pads.
|
||||
*
|
||||
* Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad
|
||||
* claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a
|
||||
* DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither — it arrives as an
|
||||
* ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a
|
||||
* whole class of controller with no motion at all.
|
||||
*
|
||||
* Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a
|
||||
* [android.hardware.SensorManager] scoped to that one controller, carrying the usual
|
||||
* TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller
|
||||
* that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index
|
||||
* through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert —
|
||||
* those pads keep working, minus motion, exactly as they did.
|
||||
*
|
||||
* It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem:
|
||||
* - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
|
||||
* — sensor batching would trade away the exact latency gyro aim exists to avoid, and the main
|
||||
* thread is where Compose recomposition lives;
|
||||
* - a feed torn down while its wire pad is still alive parks the rotation at zero first, because
|
||||
* the host holds motion as STATE and re-emits it in every virtual-pad report: an angular
|
||||
* velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent
|
||||
* forever" finding).
|
||||
*
|
||||
* One writer per pad, three ways:
|
||||
* 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb]
|
||||
* calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires
|
||||
* `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the
|
||||
* controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to
|
||||
* register — but the explicit teardown is what makes the ordering deterministic instead of a
|
||||
* race against the platform's own removal callback.
|
||||
* 2. The phone-gyro mirror stands down: registering flips
|
||||
* [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and
|
||||
* [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge).
|
||||
* 3. Exactly one feed exists per deviceId — [onSlotOpened] is idempotent, and it is the only
|
||||
* thing that ever constructs one.
|
||||
*
|
||||
* Frame: see [gyroToWire] — the mapping is straight through, and NOT yet verified on hardware.
|
||||
*/
|
||||
class PadSensors(private val router: GamepadRouter) {
|
||||
|
||||
/** One controller's live sensor feed: its listener state and the accel it pairs with each
|
||||
* rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */
|
||||
private inner class Feed(private val deviceId: Int) : SensorEventListener {
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample).
|
||||
* Starts at the host's neutral — 1 g on the up axis, NOT [0,0,0], which is free fall. */
|
||||
private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
private val gyro = IntArray(3)
|
||||
|
||||
/** Whether any rotation has gone out on this pad — gates the park on teardown, so a pad
|
||||
* that never sent motion is not handed a sample it did not earn. */
|
||||
@Volatile
|
||||
var wroteMotion = false
|
||||
private set
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel)
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
gyroToWire(event.values, gyro)
|
||||
// One line per controller per session, on the first sample that carries both
|
||||
// planes: it is the cheapest possible version of the frame measurement
|
||||
// [gyroToWire] asks for. Hold the pad flat and still while a stream starts and
|
||||
// the accel triple says which slot gravity lands on — the one thing that
|
||||
// settles whether the straight-through mapping is right.
|
||||
if (!wroteMotion) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"controller $deviceId first motion sample: " +
|
||||
"gyro ${gyro.joinToString()} accel ${accel.joinToString()}",
|
||||
)
|
||||
}
|
||||
wroteMotion = true
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free fall. */
|
||||
fun park() {
|
||||
gyro.fill(0)
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
|
||||
/** deviceId → live feed. Concurrent: the main thread mutates it while the sensor thread is
|
||||
* running (hot-plug, a capture link's claim). */
|
||||
private val feeds = ConcurrentHashMap<Int, Feed>()
|
||||
|
||||
private val thread = HandlerThread("pf-pad-sensors")
|
||||
private var handler: Handler? = null
|
||||
|
||||
/**
|
||||
* Start the sensor thread and attach to every controller the router already forwards — the
|
||||
* pads connected before the session opened, which will never fire a hot-plug callback.
|
||||
* Everything after that arrives through [onSlotOpened]. Main thread.
|
||||
*/
|
||||
fun start() {
|
||||
if (!supported()) return
|
||||
thread.start()
|
||||
handler = Handler(thread.looper)
|
||||
for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot opened for real controller [deviceId] — attach if it has a gyroscope of its own.
|
||||
* Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from
|
||||
* [GamepadRouter.onSlotOpened].
|
||||
*/
|
||||
fun onSlotOpened(deviceId: Int) {
|
||||
val h = handler ?: return
|
||||
if (feeds.containsKey(deviceId)) return
|
||||
// API 31+ only — getSensorManager does not exist below it. Re-checked here rather than
|
||||
// relying on start()'s gate, so the entry point is safe on its own terms.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val dev = InputDevice.getDevice(deviceId) ?: return
|
||||
// Declared non-null: a controller with no sensors gets an empty manager, not a null one.
|
||||
val sm = dev.sensorManager
|
||||
// A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation
|
||||
// is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation
|
||||
// at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the
|
||||
// two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays
|
||||
// on the mirror's terms instead, where at least the accel agrees with the gyro beside it.
|
||||
// Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its
|
||||
// own evdev node, and whether InputReader merges that node onto the gamepad InputDevice
|
||||
// (shared descriptor) or leaves it standing alone is the platform's business, not ours —
|
||||
// and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this
|
||||
// would never see it. Android 12's own controller-sensor documentation cites the DualShock
|
||||
// 4 and DualSense, which says the merge happens; it is not something this code can assert.
|
||||
// If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check.
|
||||
val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
val feed = Feed(deviceId)
|
||||
feeds[deviceId] = feed
|
||||
// ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is
|
||||
// what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway.
|
||||
sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
// The pad sources its own rotation from here on → the phone-gyro mirror stands down for it.
|
||||
router.setDeviceHasSensorMotion(deviceId, true)
|
||||
Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion")
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot for [deviceId] closed — unplug, session teardown, or a capture link claiming the
|
||||
* device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from
|
||||
* [GamepadRouter.onSlotClosed].
|
||||
*
|
||||
* No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback
|
||||
* and has already sent that pad's Remove, so the host tore the virtual pad down and there is no
|
||||
* latched rotation left to clear — while writing to a wire index that is free again would be
|
||||
* addressing whoever claims it next. [stop] is the case where the pad outlives the feed.
|
||||
*/
|
||||
fun onSlotClosed(deviceId: Int) {
|
||||
unregister(deviceId)
|
||||
router.setDeviceHasSensorMotion(deviceId, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister every listener, join the sensor thread, then park at zero each pad that was
|
||||
* rotating. Call BEFORE the router is released and the session handle freed — the same
|
||||
* teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come
|
||||
* AFTER the join for two reasons: a sample still in flight would re-latch the rotation just
|
||||
* cleared, and the join is what publishes the sensor thread's writes to this one.
|
||||
*/
|
||||
fun stop() {
|
||||
val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } }
|
||||
for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
handler = null
|
||||
for ((_, feed) in parked) if (feed.wroteMotion) feed.park()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop [deviceId]'s listeners, returning the feed that held them (null if there was none).
|
||||
* Safe for a controller that is already gone: the sensor manager is reached through the
|
||||
* [InputDevice], and a vanished device simply leaves nothing to unregister — the platform has
|
||||
* stopped calling the listener either way.
|
||||
*/
|
||||
private fun unregister(deviceId: Int): Feed? {
|
||||
val feed = feeds.remove(deviceId) ?: return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed)
|
||||
}
|
||||
return feed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadSensors"
|
||||
|
||||
/** Whether this platform can read a controller's own sensors at all (API 31+). */
|
||||
fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place.
|
||||
*
|
||||
* The axis frame is straight through, and that is now MEASURED rather than assumed.
|
||||
*
|
||||
* The wire is a unit passthrough into a virtual DualSense report, whose frame was measured
|
||||
* over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward
|
||||
* toward the player (roll), right-handed. Android hands a controller's own sensors over in
|
||||
* that same frame — which was the documented expectation, but the numbers pass through a
|
||||
* HID driver and InputFlinger's sensor mapper, either of which could have permuted or
|
||||
* negated without saying so.
|
||||
*
|
||||
* Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming
|
||||
* to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` — exactly 1 g
|
||||
* on slot 1 — and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y
|
||||
* with every rotation driving its correctly-named axis (yaw→RY, pitch→RX, roll→RZ) and the
|
||||
* signs agreeing with gravity's independent witness on 95 of 100 rotating samples.
|
||||
*
|
||||
* So: no remap. If a future device disagrees, the remap belongs HERE with its own
|
||||
* expectations in `PadSensorsTest` — not spread across callers.
|
||||
*/
|
||||
fun gyroToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
|
||||
/**
|
||||
* One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16
|
||||
* components, in place. Same measured frame as [gyroToWire] and the same straight-through
|
||||
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
|
||||
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) — which is precisely
|
||||
* what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat.
|
||||
*/
|
||||
fun accelToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.Surface
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants
|
||||
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
|
||||
* plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis
|
||||
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DeviceGyroTest {
|
||||
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
|
||||
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
|
||||
|
||||
@Test
|
||||
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
|
||||
|
||||
@Test
|
||||
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
|
||||
|
||||
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
|
||||
* player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */
|
||||
@Test
|
||||
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
|
||||
|
||||
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */
|
||||
@Test
|
||||
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
|
||||
|
||||
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
|
||||
@Test
|
||||
fun handednessPreserved() {
|
||||
for (r in listOf(
|
||||
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
|
||||
)) {
|
||||
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
|
||||
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
|
||||
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** The wire contract, shared with pf-client-core / the Swift client and now with every other
|
||||
* Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ⇒ ~1145.9 raw;
|
||||
* 1 g ⇒ 10000 raw. */
|
||||
@Test
|
||||
fun wireUnitConstants() {
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,185 @@ class DsDeviceTest {
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- IMU calibration (the pad's own scale factors) ----
|
||||
|
||||
/**
|
||||
* A calibration feature report in the pads' USB layout: report id, three gyro bias words, six
|
||||
* INTERLEAVED gyro plus/minus words, the two speed words, six accel plus/minus words — all
|
||||
* little-endian i16, exactly what [DsDevice.MotionCal.parse] reads and what
|
||||
* `crates/pf-inject/tests/motion_contract.rs` writes from the other end.
|
||||
*/
|
||||
private fun calBlob(
|
||||
id: Int,
|
||||
gyroBias: IntArray,
|
||||
gyroPlus: IntArray,
|
||||
gyroMinus: IntArray,
|
||||
speed: Int,
|
||||
accelPlus: IntArray,
|
||||
accelMinus: IntArray,
|
||||
len: Int = 41,
|
||||
): ByteArray = ByteArray(len).also { b ->
|
||||
fun put(o: Int, v: Int) {
|
||||
b[o] = (v and 0xFF).toByte()
|
||||
b[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
b[0] = id.toByte()
|
||||
for (i in 0 until 3) {
|
||||
put(1 + 2 * i, gyroBias[i])
|
||||
put(7 + 4 * i, gyroPlus[i])
|
||||
put(9 + 4 * i, gyroMinus[i])
|
||||
put(23 + 4 * i, accelPlus[i])
|
||||
put(25 + 4 * i, accelMinus[i])
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
}
|
||||
|
||||
/**
|
||||
* A realistic DualSense blob: gyro measured at 512 °/s each way over ±8192 counts about a
|
||||
* small factory bias — 16384/1024 = 16 raw LSB per °/s, the ≈±2000 °/s full scale a real pad
|
||||
* has — and accel spanning about ±8192 counts (`DS_ACC_RES_PER_G`) about a per-axis zero point
|
||||
* that is NOT zero. Both are the shape a nominal constant cannot express.
|
||||
*/
|
||||
private fun realisticCal(): DsDevice.MotionCal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(10, -6, 3),
|
||||
gyroPlus = intArrayOf(10 + 8192, -6 + 8192, 3 + 8192),
|
||||
gyroMinus = intArrayOf(10 - 8192, -6 - 8192, 3 - 8192),
|
||||
speed = 512, // speed_plus + speed_minus = 1024
|
||||
accelPlus = intArrayOf(8300, 8200, 8000),
|
||||
accelMinus = intArrayOf(-8100, -8192, -8384),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun calibrationRescalesRawCountsOntoTheWireUnits() {
|
||||
val cal = realisticCal()
|
||||
// 100 °/s at this pad's 16 LSB per °/s = 1600 raw → the wire's 20 LSB per °/s = 2000.
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 1600))
|
||||
assertEquals(-2000, cal.gyroToWire(axis, -1600))
|
||||
assertEquals(0, cal.gyroToWire(axis, 0))
|
||||
}
|
||||
// 1 g = the axis's zero point plus half its declared 2 g range → 10000 wire units.
|
||||
val zero = intArrayOf(100, 4, -192) // plus − range/2, per axis
|
||||
val oneG = intArrayOf(8300, 8200, 8000) // = accelPlus
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(10000, cal.accelToWire(axis, oneG[axis]))
|
||||
assertEquals(0, cal.accelToWire(axis, zero[axis]))
|
||||
assertEquals(-10000, cal.accelToWire(axis, zero[axis] - (oneG[axis] - zero[axis])))
|
||||
}
|
||||
// Both rescales are >1 here, so full-scale raw must clamp rather than wrap the i16.
|
||||
assertEquals(32767, cal.gyroToWire(0, 30000))
|
||||
assertEquals(-32768, cal.gyroToWire(0, -30000))
|
||||
assertEquals(32767, cal.accelToWire(0, 30000))
|
||||
// The capture logs this, and it is the discriminator the owed on-glass check reads: a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
assertTrue(cal.toString().startsWith("gyro 16/16/16 LSB/°·s"))
|
||||
assertTrue(DsDevice.MotionCal.NOMINAL.toString().startsWith("gyro 20/20/20 LSB/°·s"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's own virtual pads declare `DS_FEATURE_CALIBRATION` (`dualsense_proto.rs`) — a blob
|
||||
* that states the wire's units exactly. Reading it back must therefore be a passthrough: if
|
||||
* this ever stops holding, the client and the host disagree about what a motion sample means.
|
||||
*/
|
||||
@Test
|
||||
fun theHostsOwnBlobIsAPassthrough() {
|
||||
val cal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(10000, 10000, 10000),
|
||||
gyroMinus = intArrayOf(-10000, -10000, -10000),
|
||||
speed = 500,
|
||||
accelPlus = intArrayOf(10000, 10000, 10000),
|
||||
accelMinus = intArrayOf(-10000, -10000, -10000),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 2000)) // 100 °/s
|
||||
assertEquals(10000, cal.accelToWire(axis, 10000)) // 1 g
|
||||
assertEquals(-1234, cal.gyroToWire(axis, -1234))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything unusable keeps the pre-calibration behaviour — accel on the nominal 8192 LSB/g,
|
||||
* gyro straight through. A pad with no readable calibration is better off slightly mis-scaled
|
||||
* than silent, so nothing here may zero motion.
|
||||
*/
|
||||
@Test
|
||||
fun unusableCalibrationFallsBackInsteadOfZeroing() {
|
||||
val degenerate = calBlob(
|
||||
id = 0x02,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(0, 0, 0),
|
||||
gyroMinus = intArrayOf(0, 0, 0),
|
||||
speed = 0,
|
||||
accelPlus = intArrayOf(0, 0, 0),
|
||||
accelMinus = intArrayOf(0, 0, 0),
|
||||
len = 37,
|
||||
)
|
||||
val cals = listOf(
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
DsDevice.MotionCal.parse(null, 0x05), // the GET_REPORT failed
|
||||
DsDevice.MotionCal.parse(ByteArray(8) { if (it == 0) 0x05 else 0 }, 0x05), // short reply
|
||||
DsDevice.MotionCal.parse(degenerate, 0x02), // a clone pad's zeroes
|
||||
DsDevice.MotionCal.parse(degenerate, 0x05), // someone else's report id
|
||||
)
|
||||
for (cal in cals) {
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(1234, cal.gyroToWire(axis, 1234)) // passthrough
|
||||
assertEquals(10000, cal.accelToWire(axis, 8192)) // 8192 raw LSB = 1 g
|
||||
assertEquals(-10000, cal.accelToWire(axis, -8192))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The parse applies the calibration at the motion offsets, per model, and defaults to nominal. */
|
||||
@Test
|
||||
fun parseStateAppliesTheCalibration() {
|
||||
val cal = realisticCal()
|
||||
// DS5: gyro at [16..22), accel at [22..28). Pitch = 1600 raw (100 °/s), accel z = 8000 (1 g).
|
||||
val ds5 = ds5Report {
|
||||
it[16] = 0x40; it[17] = 0x06 // 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // 8000
|
||||
}
|
||||
val five = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, five, cal))
|
||||
assertEquals(2000, five.gyro[0])
|
||||
assertEquals(10000, five.accel[2])
|
||||
// DS4: gyro at [13..19), accel at [19..25). Same numbers, same answers.
|
||||
val ds4 = ds4Report {
|
||||
it[13] = 0x40; it[14] = 0x06
|
||||
it[23] = 0x40; it[24] = 0x1F
|
||||
}
|
||||
val four = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4, 64, four, cal))
|
||||
assertEquals(2000, four.gyro[0])
|
||||
assertEquals(10000, four.accel[2])
|
||||
// No calibration argument = the nominal fallback: gyro through, accel ×10000/8192.
|
||||
val nominal = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, nominal))
|
||||
assertEquals(1600, nominal.gyro[0])
|
||||
assertEquals(8000L * 10000 / 8192, nominal.accel[2].toLong())
|
||||
}
|
||||
|
||||
/** Each model asks for the feature report its firmware actually serves over USB. */
|
||||
@Test
|
||||
fun calibrationReportIdentityPerModel() {
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE.calReportLen)
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE_EDGE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE_EDGE.calReportLen)
|
||||
assertEquals(0x02, DsDevice.Model.DUALSHOCK4.calReportId)
|
||||
assertEquals(37, DsDevice.Model.DUALSHOCK4.calReportLen)
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming
|
||||
* thread. Two things are pinned here, and both are about the gap before the read comes back.
|
||||
*
|
||||
* What the gap DOES: the pad streams, scaled by the nominal calibration — the behaviour that
|
||||
* shipped before the read existed. What it must NOT do: inherit the previous pad's factory numbers
|
||||
* (calibration is per unit), or accept a read that outlived its claim, which an unplug, a stop, or
|
||||
* a re-claim can all cause.
|
||||
*/
|
||||
class MotionCalHandoffTest {
|
||||
/**
|
||||
* A calibration whose gyro reads [rawLsbPerDegS] raw LSB per °/s and whose accel sits at
|
||||
* [accelZero] raw counts at 0 g, so two of them are told apart by what they DO — identity
|
||||
* alone would let a regression that returns the wrong instance still look right.
|
||||
*/
|
||||
private fun cal(rawLsbPerDegS: Int, accelZero: Int = 0): DsDevice.MotionCal {
|
||||
val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000
|
||||
val span = rawLsbPerDegS * 1000 // |plus − bias| + |minus − bias| = span
|
||||
val blob = ByteArray(41)
|
||||
fun put(o: Int, v: Int) {
|
||||
blob[o] = (v and 0xFF).toByte()
|
||||
blob[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
blob[0] = 0x05
|
||||
for (i in 0 until 3) {
|
||||
put(7 + 4 * i, span / 2) // gyro plus
|
||||
put(9 + 4 * i, -span / 2) // gyro minus
|
||||
put(23 + 4 * i, accelZero + 8192) // accel plus / minus: 8192 raw LSB per g
|
||||
put(25 + 4 * i, accelZero - 8192)
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
return DsDevice.MotionCal.parse(blob, 0x05)
|
||||
}
|
||||
|
||||
/** One DS5 input report: cross held, sticks centred, gyro pitch 1600 raw, accel z 8000 raw. */
|
||||
private fun report(): ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = (0x08 or 0x20).toByte() // hat neutral | cross
|
||||
it[16] = 0x40; it[17] = 0x06 // gyro pitch = 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // accel z = 8000
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte() // no touch contacts
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a claim scales nominally until its read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val claim = h.begin()
|
||||
assertSame("the read is in flight — scale nominally, do not wait", DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(claim, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of scaling nominally instead of holding reports back: a pad answers its
|
||||
* buttons from the first report, and only its motion changes when the calibration arrives.
|
||||
*/
|
||||
@Test
|
||||
fun `a report in the gap is forwarded, nominally scaled, and rescales once the read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
val r = report()
|
||||
|
||||
val gap = DsDevice.State()
|
||||
assertTrue(
|
||||
"a report must still be parsed while the read is in flight",
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, gap, h.effective),
|
||||
)
|
||||
assertEquals("buttons reach the wire immediately", Gamepad.BTN_A, gap.buttons)
|
||||
assertEquals("and so do sticks", 128, gap.lsX)
|
||||
assertEquals("nominal gyro is the raw count", 1600, gap.gyro[0])
|
||||
assertEquals("nominal accel is ×10000/8192", 8000L * 10000 / 8192, gap.accel[2].toLong())
|
||||
|
||||
assertTrue(h.publish(claim, cal(16, accelZero = 100)))
|
||||
val live = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, live, h.effective))
|
||||
assertEquals("buttons do not depend on the calibration", gap.buttons, live.buttons)
|
||||
assertEquals("1600 raw at 16 LSB/°·s = 100 °/s = 2000 wire", 2000, live.gyro[0])
|
||||
assertNotEquals("the same raw report must convert differently now", gap.gyro[0], live.gyro[0])
|
||||
assertNotEquals(gap.accel[2], live.accel[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read that outlived its claim publishes nothing`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // unplug, or DsCapture.stop, while the read was in flight
|
||||
assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new claim scales nominally rather than inheriting the previous pad's calibration`() {
|
||||
val h = MotionCalHandoff()
|
||||
val first = h.begin()
|
||||
val hot = cal(4, accelZero = 400) // a pad reading 4 raw LSB per °/s, well off nominal
|
||||
assertTrue(h.publish(first, hot))
|
||||
assertSame(hot, h.effective)
|
||||
|
||||
// Re-claimed without an end() in between — the pad was swapped while a read was in flight.
|
||||
val second = h.begin()
|
||||
assertNotEquals(first, second)
|
||||
assertSame(
|
||||
"the next pad starts on the nominal scaling, NOT the last pad's factory numbers",
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
h.effective,
|
||||
)
|
||||
assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
|
||||
// And that fallback is a real difference, not two names for the same numbers: the inherited
|
||||
// calibration would have turned this pad's motion into something else entirely.
|
||||
val r = report()
|
||||
val nominal = DsDevice.State()
|
||||
val inherited = DsDevice.State()
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, nominal, h.effective)
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, inherited, hot)
|
||||
assertNotEquals(inherited.gyro[0], nominal.gyro[0])
|
||||
assertNotEquals(inherited.accel[2], nominal.accel[2])
|
||||
|
||||
val slow = cal(32)
|
||||
assertTrue(h.publish(second, slow))
|
||||
assertSame(slow, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ending a claim twice still refuses every outstanding token`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // DsCapture.stop
|
||||
h.end() // …and the unplug that followed it
|
||||
assertFalse(h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val next = h.begin()
|
||||
assertNotEquals(claim, next)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(next, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
|
||||
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
|
||||
* JVM — the two `*ToWire` functions take plain float arrays and touch no Android class.
|
||||
*
|
||||
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
|
||||
* not drift. The axis mapping is straight through and NOT yet verified against hardware — see
|
||||
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
|
||||
* to make a future remap a deliberate, visible edit rather than a quiet one.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class PadSensorsTest {
|
||||
private fun gyro(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
private fun accel(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
|
||||
@Test
|
||||
fun gyroScaleFromRadiansPerSecond() {
|
||||
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
|
||||
assertEquals(0, gyro(0f, 0f, 0f)[0])
|
||||
}
|
||||
|
||||
/** 10000 LSB/g from Android's m/s²: standard gravity is exactly 1 g. Android reports specific
|
||||
* force, so a pad at rest reads +1 g on the axis pointing up — no sign flip anywhere. */
|
||||
@Test
|
||||
fun accelScaleFromMetresPerSecondSquared() {
|
||||
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(0, accel(0f, 0f, 0f)[1])
|
||||
}
|
||||
|
||||
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
|
||||
* DualSense — 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
|
||||
* not the [0,0,0] that means free fall. */
|
||||
@Test
|
||||
fun restingPadIsTheHostNeutral() {
|
||||
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
|
||||
* planes, with no permutation and no negation. UNVERIFIED against hardware — if a Bluetooth
|
||||
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
|
||||
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
|
||||
*/
|
||||
@Test
|
||||
fun straightThroughFrame() {
|
||||
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
|
||||
assertArrayEquals(
|
||||
intArrayOf(10_000, 20_000, -30_000),
|
||||
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both planes clamp to signed 16 bits rather than wrapping — a flick past 1638 °/s or a knock
|
||||
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
|
||||
@Test
|
||||
fun clampsToSigned16() {
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
|
||||
}
|
||||
|
||||
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
|
||||
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
|
||||
* is a gyro that drifts. */
|
||||
@Test
|
||||
fun roundsToNearestNotTowardZero() {
|
||||
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
|
||||
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
|
||||
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
|
||||
}
|
||||
|
||||
/** A sensor that hands back fewer than three components (or none — the framework reuses one
|
||||
* array across types) contributes zero rather than throwing on the sensor thread. */
|
||||
@Test
|
||||
fun shortSampleIsZeroFilled() {
|
||||
val out = IntArray(3) { 7 }
|
||||
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
|
||||
assertArrayEquals(intArrayOf(3600, 0, 0), out)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
//!
|
||||
//! It is also **A/V synchronised** (`design/audio-latency-overhaul.md`): the decode thread reads the
|
||||
//! host capture `pts_ns` every `AudioPacket` has always carried, compares where this frame will
|
||||
//! actually play against where the picture it belongs with reached glass
|
||||
//! (`decode::DisplayTracker` publishes that), and asks the ring for a depth that closes the gap.
|
||||
//! Only ASKS — `JitterPolicy` clamps the request between its own underrun-driven floor and the hard
|
||||
//! cap, so continuity outranks sync and a link whose jitter genuinely needs more buffer than the
|
||||
//! picture is away keeps its buffer, with the residual reported on the HUD instead of taken out of
|
||||
//! the listener's stream. With no video reference (below API 33 there are no render callbacks, so
|
||||
//! nothing confirms a present) the target stays `None` and the ring behaves exactly as it did.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,6 +44,14 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What one playback open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// sender the decode thread fills and the receiver that returns drained buffers for refill.
|
||||
///
|
||||
/// Named rather than written inline because the closure's return type trips
|
||||
/// `clippy::type_complexity`, which the Android target is now linted for (`:kit:cargoNdkClippy`)
|
||||
/// after years of nothing checking it.
|
||||
type OpenedPlayback = ndk::audio::Result<(AudioStream, SyncSender<Vec<f32>>, Receiver<Vec<f32>>)>;
|
||||
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
@@ -94,15 +112,45 @@ impl AudioDec {
|
||||
|
||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||
///
|
||||
/// The ring's DEPTH is not here: the A/V sync loop needs the same number in the same units, so it
|
||||
/// is published once through [`punktfunk_core::audio::AudioSyncCell`] and read from there by the
|
||||
/// log line below. One publisher, one reading — a second copy is a second thing to go stale.
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
opus_decoded: AtomicU64, // Opus packets decoded OK (~200/s at 5 ms frames)
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Whether the A/V sync loop runs this session. `false` leaves `JitterPolicy`'s sync target at
|
||||
/// `None`, which reproduces the pre-overhaul ring behaviour exactly — the point of the hatch.
|
||||
///
|
||||
/// Two levers because Android has neither of the other clients' launch surfaces. `PUNKTFUNK_NO_AV_SYNC`
|
||||
/// keeps the contract the desktop clients document (and works when the client is driven from a
|
||||
/// shell), but an app started from the launcher inherits no such environment, so the one a field
|
||||
/// tester can actually reach is the sysprop — `adb shell setprop debug.punktfunk.no_av_sync 1`,
|
||||
/// no rebuild, exactly like `debug.punktfunk.presenter`. A loop that steers PLAYBACK has to be
|
||||
/// bisectable on the device that reports the regression, not only on the bench.
|
||||
fn av_sync_enabled() -> bool {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_NO_AV_SYNC").as_deref(),
|
||||
Ok("1") | Ok("true")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.no_av_sync".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
!(n > 0 && matches!(&buf[..n as usize], b"1" | b"true"))
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
pub struct AudioPlayback {
|
||||
_stream: AudioStream, // dropping it stops + closes the AAudio stream
|
||||
@@ -127,15 +175,15 @@ impl AudioPlayback {
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
// The A/V sync hand-off: the realtime callback owns the ring (so it publishes the depth and
|
||||
// consumes the target), the decode thread owns the timestamps (so it computes the target).
|
||||
// Two atomics, because the callback must not block on the thread that decodes Opus.
|
||||
let sync: Arc<punktfunk_core::audio::AudioSyncCell> = Arc::default();
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
// (channels, ring, prime state) is rebuilt per attempt — `open_stream` consumes the builder
|
||||
// AND the callback, so nothing survives a failed try to reuse.
|
||||
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
SyncSender<Vec<f32>>,
|
||||
Receiver<Vec<f32>>,
|
||||
)> {
|
||||
let try_open = |sharing: AudioSharingMode| -> OpenedPlayback {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so
|
||||
// the realtime callback never frees heap (Android's Scudo allocator has unbounded free()
|
||||
@@ -146,6 +194,7 @@ impl AudioPlayback {
|
||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from
|
||||
// a single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
|
||||
let cb_counters = counters.clone();
|
||||
let cb_sync = sync.clone();
|
||||
// Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst
|
||||
// transient before the trim below = the hard cap plus one full channel of 5 ms (480-f32)
|
||||
// frames — the punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a
|
||||
@@ -171,6 +220,13 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// A/V sync: take whatever depth the decode thread's sync loop last asked for, and
|
||||
// publish where the ring actually is so it can measure the result. The policy
|
||||
// clamps the request between its own underrun floor and the hard cap — continuity
|
||||
// outranks sync, always (see `JitterPolicy::set_sync_target`). Read AFTER the
|
||||
// drain, so the depth is everything a frame queued right now must wait behind.
|
||||
policy.set_sync_target(cb_sync.target());
|
||||
cb_sync.publish_depth(ring.len());
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
@@ -201,9 +257,6 @@ impl AudioPlayback {
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
@@ -303,7 +356,7 @@ impl AudioPlayback {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-audio".into())
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels, sync))
|
||||
.ok();
|
||||
|
||||
Some(AudioPlayback {
|
||||
@@ -334,6 +387,7 @@ fn decode_loop(
|
||||
shutdown: Arc<AtomicBool>,
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
sync: Arc<punktfunk_core::audio::AudioSyncCell>,
|
||||
) {
|
||||
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||
@@ -354,9 +408,44 @@ fn decode_loop(
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
|
||||
// A/V sync (audio latency overhaul). This thread is the only place holding all three
|
||||
// ingredients at once: the packet's host capture `pts_ns`, the ring depth (via the sync cell)
|
||||
// and the video plane's end-to-end figure. `pts_ns` arrived in every `AudioPacket` and was
|
||||
// dropped on the floor here for the plane's whole existence, which is why audio ran at whatever
|
||||
// depth its jitter ring settled at with nothing ever placing it against the picture.
|
||||
let av_sync_enabled = av_sync_enabled();
|
||||
let mut av = punktfunk_core::audio::AvSync::new(channels as u8);
|
||||
let video_e2e = client.video_e2e_shared();
|
||||
let av_offset_out = client.audio_av_offset_shared();
|
||||
let buffer_ms_out = client.audio_buffer_ms_shared();
|
||||
if !av_sync_enabled {
|
||||
log::info!("audio: A/V sync disabled (PUNKTFUNK_NO_AV_SYNC / debug.punktfunk.no_av_sync)");
|
||||
}
|
||||
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => {
|
||||
// Place this frame against the picture it belongs with, BEFORE it is queued:
|
||||
// `buffered_ahead` is everything that must still play first, so the depth read here
|
||||
// is exactly what delays it.
|
||||
let depth = sync.depth();
|
||||
// Published unconditionally — the ring's depth is worth seeing even with sync off,
|
||||
// and it is what makes a "the audio delay is way too high" report triageable at all.
|
||||
buffer_ms_out.store((depth / ms.max(1)) as u32, Ordering::Relaxed);
|
||||
if av_sync_enabled {
|
||||
let ve2e = video_e2e.load(Ordering::Relaxed);
|
||||
av.observe(punktfunk_core::audio::AvSyncObservation {
|
||||
pts_ns: pkt.pts_ns,
|
||||
now_local_ns: punktfunk_core::client::now_realtime_ns(),
|
||||
clock_offset_ns: client.clock_offset_now_ns(),
|
||||
buffered_ahead: depth,
|
||||
// 0 = nothing confirmed on the glass yet (no render callback below API 33,
|
||||
// or the stream has not presented a frame); no reference, no correction.
|
||||
video_e2e_ns: (ve2e > 0).then_some(ve2e),
|
||||
});
|
||||
sync.set_target(av.desired_depth(depth));
|
||||
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
|
||||
}
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
@@ -404,12 +493,17 @@ fn decode_loop(
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
// `av_ms` is the sync loop's smoothed placement error (+ = audio behind
|
||||
// the picture); 0 with sync off, or before it has a video reference.
|
||||
// Logged next to the depth because a deep ring on a jittery link is
|
||||
// correct and only the offset separates that from audio held late.
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
(depth / ms.max(1)) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
av.offset_ms(),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,15 @@ pub(super) fn run_async(
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
|
||||
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
|
||||
// and this is the only point that knows when a frame actually reached glass.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
meter.clone(),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
|
||||
@@ -5,7 +5,7 @@ use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::latency::now_realtime_ns;
|
||||
@@ -35,6 +35,16 @@ pub(super) struct DisplayTracker {
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// Where the AUDIO plane reads the video leg it has to land with (ns) — `displayed +
|
||||
/// clock_offset − pts`, published on every confirmed present. Written here, read by
|
||||
/// [`crate::audio`]'s sync loop; the two planes never touch each other directly (the presenter
|
||||
/// must not know about audio, and the audio thread cannot see the glass).
|
||||
///
|
||||
/// Published RAW. The HUD shaves the OS present floor off its shown display / end-to-end
|
||||
/// numbers (`StatsOverlay.osFloorMs` — metrics report what Punktfunk controls), but sound has
|
||||
/// to reach the ear when the light reaches the eye, and a floor-shaved reference would place
|
||||
/// audio a whole latch period early on every device. Presentation policy, not physics.
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
@@ -48,11 +58,13 @@ impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
video_e2e,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
@@ -105,7 +117,14 @@ pub(super) fn install_render_callback(
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
// No confirmed present ⇒ no `display` stage AND no reference for the audio plane's A/V
|
||||
// sync, which then stays inert and leaves the ring exactly as it was. The release
|
||||
// instant is NOT substituted: releases target a future vsync, so it runs a whole latch
|
||||
// period (8-21 ms measured) ahead of glass — well outside the loop's deadband, i.e. it
|
||||
// would place audio early on every frame while looking like it was working.
|
||||
log::info!(
|
||||
"decode: no render callback on this API level (<33) — no display stage, no A/V sync"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
@@ -145,8 +164,10 @@ pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||
/// unwind out of an `extern "C"` fn would abort the process.
|
||||
/// (single-clock local) — and publishes that end-to-end figure for the audio plane to align
|
||||
/// against, which is the only place in the client that knows when a frame truly reached glass.
|
||||
/// Panic-free by construction (poison-proof lock, saturating math) — an unwind out of an
|
||||
/// `extern "C"` fn would abort the process.
|
||||
unsafe extern "C" fn on_frame_rendered(
|
||||
_codec: *mut ndk_sys::AMediaCodec,
|
||||
userdata: *mut c_void,
|
||||
@@ -186,13 +207,28 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
// The glass-to-glass figure, computed ABOVE the HUD gate: the audio plane steers its ring by it
|
||||
// (see `video_e2e`), and a sync loop that only worked while the overlay was up would be off on
|
||||
// the exact devices that report latency — on a Deck-class report the overlay is precisely what
|
||||
// the field cannot reach. The cost is one relaxed load and some integer arithmetic per confirmed
|
||||
// present (≤ the panel rate); the stats LOCK stays behind the gate, which is what that
|
||||
// early-return was really protecting.
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||
// Same (0, 10 s) clamp as every other e2e sample — a vendor's first render callbacks can carry
|
||||
// a garbage `system_nano`, and here that would step the audio ring rather than just a p95.
|
||||
let e2e_valid = e2e_ns > 0 && e2e_ns < 10_000_000_000;
|
||||
if e2e_valid {
|
||||
t.video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
|
||||
}
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the stats lock
|
||||
}
|
||||
t.stats.note_displayed(
|
||||
e2e_valid.then_some((e2e_ns / 1000) as u64),
|
||||
display_us,
|
||||
latch_us,
|
||||
);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
|
||||
@@ -185,9 +185,12 @@ pub(super) fn run_sync(
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
// The `video_e2e` cell is the audio plane's alignment reference (see `DisplayTracker`): this
|
||||
// legacy loop feeds it too, so A/V sync works with "Low-latency mode" off as well.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
@@ -33,8 +33,21 @@ pub(super) fn now_monotonic_ns() -> i64 {
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
// Explicit widening: `timespec`'s fields are 32-bit on armv7 (`time_t`/`c_long`) and 64-bit on
|
||||
// arm64, so these casts are REQUIRED on one shipping ABI and redundant on the other.
|
||||
//
|
||||
// `:kit:cargoNdkClippy` lints both widths, so it sees the redundant half and flags it; taking
|
||||
// its advice would break the 32-bit build, which is the ABI for the many 32-bit Google TV /
|
||||
// Android TV boxes this client targets. `i64::from`/`.into()` do not escape it either — they
|
||||
// just trade `unnecessary_cast` for `useless_conversion` on the 64-bit side. So the cast stays
|
||||
// and the lint is answered here rather than in whichever build breaks first.
|
||||
#[allow(
|
||||
clippy::unnecessary_cast,
|
||||
reason = "required on 32-bit ABIs; redundant only on 64-bit"
|
||||
)]
|
||||
{
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||
|
||||
@@ -26,6 +26,15 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// What one capture open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// receiver the encode worker drains and the sender that returns emptied buffers for reuse. Note
|
||||
/// the pair is the mirror image of [`crate::audio::OpenedPlayback`]'s: here the callback produces
|
||||
/// and the worker consumes.
|
||||
///
|
||||
/// Named rather than written inline for the same reason as that one — `clippy::type_complexity`,
|
||||
/// now that the Android target is actually linted (`:kit:cargoNdkClippy`).
|
||||
type OpenedCapture = ndk::audio::Result<(AudioStream, Receiver<Vec<f32>>, SyncSender<Vec<f32>>)>;
|
||||
|
||||
const CHANNELS: usize = 1;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
|
||||
@@ -84,13 +93,7 @@ impl MicCapture {
|
||||
|
||||
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||
let try_open = |sharing: AudioSharingMode,
|
||||
voice: bool|
|
||||
-> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
Receiver<Vec<f32>>,
|
||||
SyncSender<Vec<f32>>,
|
||||
)> {
|
||||
let try_open = |sharing: AudioSharingMode, voice: bool| -> OpenedCapture {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list, mirroring the playback path: the realtime capture callback must
|
||||
// not touch the allocator (Android's Scudo has unbounded malloc/free tail latency — an
|
||||
|
||||
@@ -408,8 +408,8 @@ pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||||
frame.fill(0);
|
||||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||||
// unambiguously FELT rather than merely audible.
|
||||
for c in 2..channels {
|
||||
frame[c] = sample;
|
||||
for slot in frame.iter_mut().take(channels).skip(2) {
|
||||
*slot = sample;
|
||||
}
|
||||
}
|
||||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||||
|
||||
@@ -361,6 +361,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that
|
||||
/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can
|
||||
/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane.
|
||||
///
|
||||
/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one
|
||||
/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests.
|
||||
/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting
|
||||
/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing
|
||||
/// into a host that drops every one.
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
declared_pref: jint,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
|
||||
// values read behind `&self`.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let declared =
|
||||
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
|
||||
u8::from(punktfunk_core::config::pad_motion_reaches(
|
||||
declared,
|
||||
h.client.requested_gamepad,
|
||||
h.client.resolved_gamepad,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
|
||||
@@ -177,12 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -203,7 +203,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||||
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
|
||||
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
|
||||
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
|
||||
/// decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -227,7 +230,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 33] = [
|
||||
let buf: [f64; 35] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -281,6 +284,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.feed_p50_ms,
|
||||
snap.codec_p50_ms,
|
||||
snap.skipped_overflow as f64,
|
||||
// The audio plane's own latency (`design/audio-latency-overhaul.md`): how much decoded
|
||||
// audio is queued ahead of the speaker, and where the A/V sync loop measures that
|
||||
// PUTS it relative to the picture (+ = audio behind). Both, because a deep ring on a
|
||||
// jittery link is correct behaviour and only the offset tells that apart from audio
|
||||
// simply held late. Live gauges written by the audio thread — before this the whole
|
||||
// plane published nothing any surface could render, so a "the audio delay is way too
|
||||
// high" report had no instrument behind it at all.
|
||||
h.client.audio_buffer_ms() as f64,
|
||||
h.client.audio_av_offset_ms() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
|
||||
The host is self-signed at a user-supplied address, which default ATS can never accept
|
||||
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
|
||||
Tailscale's 100.64/10 CGNAT range), so the management API talks over MgmtTransport
|
||||
(Network.framework), which is outside the URL loading system and pins the host by
|
||||
SHA-256 fingerprint instead. That leaves cover-art CDN fetches as the app's only
|
||||
URLSession traffic, and they keep the full ATS policy — which is the whole reason not to
|
||||
reach for NSAllowsArbitraryLoads here. See MgmtTransport.swift. -->
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
|
||||
@@ -384,7 +384,13 @@ struct ContentView: View {
|
||||
.frame(minWidth: 940, minHeight: 620)
|
||||
}
|
||||
#else
|
||||
.fullScreenCover(item: $libraryTarget) { host in
|
||||
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
|
||||
// of GamepadHomeView's in-place layers (the console shell — no bottom-up cover), so the
|
||||
// proxy hides the target from the cover while that mode owns it; every writer (Y on a
|
||||
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
||||
// controller arriving or leaving mid-browse hands the open library to whichever
|
||||
// presentation the new mode owns.
|
||||
.fullScreenCover(item: touchLibraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
@@ -401,6 +407,14 @@ struct ContentView: View {
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||
/// the library in place (see the cover's comment).
|
||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : libraryTarget },
|
||||
set: { libraryTarget = $0 })
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
@@ -558,7 +572,8 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -574,7 +589,8 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
@@ -764,6 +780,15 @@ struct ContentView: View {
|
||||
// other in the seconds where they overlap.
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
// A forwarded pad has a gyro this session's virtual controller cannot
|
||||
// carry. Shown briefly at every stats tier and with the overlay off: the
|
||||
// failure is otherwise completely silent — the gyro just does nothing —
|
||||
// and the fix is a setting, so the hint has to name it. Every platform,
|
||||
// including tvOS, where a DualSense is an ordinary way to play.
|
||||
if captureEnabled, model.motionUnreachableKind != nil {
|
||||
MotionUnreachableBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
|
||||
@@ -83,6 +83,11 @@ struct ConnectOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay's text/glyph colour: the palette's ink in the console takeover — over a pale
|
||||
/// aurora, literal white was the one console surface that stayed white-on-white — and white
|
||||
/// in the touch modal, whose branch is deliberately forced dark over a black scrim.
|
||||
private var overlayFG: Color { gamepadUI ? ink.fg : .white }
|
||||
|
||||
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||
// The takeover carries larger type than the compact modal.
|
||||
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||
@@ -90,21 +95,24 @@ struct ConnectOverlay: View {
|
||||
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||
switch phase {
|
||||
case .connecting(let name):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
ProgressView().controlSize(.large).tint(overlayFG)
|
||||
Text("Connecting to \(name)")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Establishing a secure connection…")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.font(.geist(bodySize, relativeTo: .caption))
|
||||
.foregroundStyle(overlayFG.opacity(0.6))
|
||||
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||
case .waking(let w) where w.timedOut:
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||
.font(.system(size: gamepadUI ? 40 : 34))
|
||||
.foregroundStyle(overlayFG.opacity(0.9))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.font(.geist(bodySize, relativeTo: .caption))
|
||||
.foregroundStyle(overlayFG.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
@@ -112,12 +120,13 @@ struct ConnectOverlay: View {
|
||||
}
|
||||
.padding(.top, 6)
|
||||
case .waking(let w):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
ProgressView().controlSize(.large).tint(overlayFG)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6))
|
||||
.monospacedDigit()
|
||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered).padding(.top, 6)
|
||||
|
||||
@@ -14,7 +14,15 @@ import SwiftUI
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
let onAdd: (StoredHost) -> Void
|
||||
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
|
||||
/// falls back to the environment dismiss. Declared AFTER `onAdd` so the existing trailing-
|
||||
/// closure call sites keep binding to it, not to this.
|
||||
var close: (() -> Void)?
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadSettingsView's twin).
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@@ -36,8 +44,8 @@ struct GamepadAddHostView: View {
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() },
|
||||
isActive: editing == nil
|
||||
onBack: { performClose() },
|
||||
isActive: controllerActive && editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -45,7 +53,8 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Add Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
@@ -54,14 +63,14 @@ struct GamepadAddHostView: View {
|
||||
+ "for everything else.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -73,7 +82,10 @@ struct GamepadAddHostView: View {
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||
.background { GamepadFormBackground() }
|
||||
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -81,6 +93,18 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
|
||||
// tray — the remote and the pad both drive it natively. Same `editing` state as the
|
||||
@@ -141,22 +165,10 @@ struct GamepadAddHostView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Cancel")
|
||||
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||
/// the environment dismiss under a macOS sheet / tvOS cover.
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
@@ -237,7 +249,7 @@ struct GamepadAddHostView: View {
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
dismiss()
|
||||
performClose()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let card: (Item) -> Card
|
||||
/// Whether the cards are worth showing off yet — the entrance holds until this is true. The
|
||||
/// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything
|
||||
/// whose cards are ready the moment they mount leaves it alone.
|
||||
var contentReady: Bool = true
|
||||
/// Builds one card. The `CardEntrance` handed along is the card's share of the strip's
|
||||
/// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own
|
||||
/// `.scrollTransition` — see `CardEntrance` for why that placement is load-bearing.
|
||||
@ViewBuilder let card: (Item, CardEntrance) -> Card
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
@@ -83,6 +90,26 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
/// The strip's entrance, as ONE timeline: 0 = every card still away, 1 = every card landed
|
||||
/// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per
|
||||
/// mount — a strip that re-played its entrance every time a screen popped off the top of it
|
||||
/// would be noise, and the shell's push/pop carries that motion already. So it plays when a
|
||||
/// screen is entered: the launcher when the gamepad UI comes up, the coverflow each time the
|
||||
/// library opens (its layer mounts fresh).
|
||||
///
|
||||
/// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers,
|
||||
/// because those modifiers wrap the caller's card — INCLUDING its `.scrollTransition` — and a
|
||||
/// delayed spring flipping while the scroll view was still settling captured the transition's
|
||||
/// own per-frame phase updates, stranding the centred card in a half-receded state until the
|
||||
/// next scroll re-drove it. Nothing here wraps the card in an animation at all.
|
||||
@State private var entranceProgress: Double = 0
|
||||
/// Which card the entrance fans out from — the cursor as it stood when the strip was armed,
|
||||
/// so a restored selection assembles around where the eye already is instead of sweeping in
|
||||
/// from the left.
|
||||
@State private var entranceAnchor = 0
|
||||
/// The entrance has been scheduled; it plays exactly once per mount.
|
||||
@State private var entranceArmed = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||
@@ -94,24 +121,27 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
// Enumerated for the entrance stagger only — identity stays `item.id`,
|
||||
// which is what `.scrollTargetLayout()` and `scrollPosition` key on.
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
#if os(tvOS)
|
||||
// A focusable Button per card: the focus engine does the navigating
|
||||
// (remote swipes and pad dpad alike), select activates. The bare style
|
||||
// below keeps the tile's own look — the `.scrollTransition` center pop
|
||||
// is the focus treatment, since focus and center track each other.
|
||||
Button { activate(item) } label: {
|
||||
card(item)
|
||||
card(item, entrance(idx))
|
||||
.frame(width: itemWidth)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
card(item)
|
||||
card(item, entrance(idx))
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
.id(item.id) // explicit scroll-target identity for scrollPosition
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -165,7 +195,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
armEntrance()
|
||||
}
|
||||
// The cards became worth showing (the library's covers got their art) — play now.
|
||||
.onChange(of: contentReady) { _, _ in armEntrance() }
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
@@ -200,9 +233,55 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
// A strip that mounted empty (its content arrived after) still gets its entrance.
|
||||
armEntrance()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entrance
|
||||
|
||||
/// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing.
|
||||
///
|
||||
/// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the
|
||||
/// same transaction as the view's insertion, where SwiftUI runs with animations disabled — so
|
||||
/// the cards would simply BE there. Note the failure mode is benign either way: progress
|
||||
/// reaching 1 without animating leaves every card at exact identity, never stranded.
|
||||
private func armEntrance() {
|
||||
guard !entranceArmed, contentReady, !items.isEmpty else { return }
|
||||
entranceArmed = true
|
||||
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
|
||||
entranceAnchor = cursor
|
||||
// Not just the next runloop turn (a change made inside `onAppear` lands in the
|
||||
// insertion's transaction, where animations are disabled) but a couple of frames: the
|
||||
// GeometryReader's first pass can report no width at all, so the strip has to lay out
|
||||
// for real and the scroll view has to centre itself on the cursor before this starts.
|
||||
// Cards are invisible until then (progress 0 ⇒ opacity 0), so the wait never shows.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
// Linear on purpose: the master timeline is a clock, and each card eases its OWN
|
||||
// slice of it (see `CardEntrance`) — a spring here would warp every card's curve.
|
||||
withAnimation(
|
||||
reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total)
|
||||
) {
|
||||
entranceProgress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The card's share of the strip's entrance: it swings in on the drum, the anchored card
|
||||
/// landing first and its neighbours fanning outward to either side.
|
||||
private func entrance(_ idx: Int) -> CardEntrance {
|
||||
// Capped so a several-hundred-title library never queues a card behind a visibly long
|
||||
// wait — everything past the cap lands together, well off-screen anyway.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
|
||||
return CardEntrance(
|
||||
progress: entranceProgress,
|
||||
start: delay / CardEntrance.total,
|
||||
// Never zero: the anchor is the card the eye is ON, so it must swing like the rest —
|
||||
// giving it "no rotation" left the one card you actually watch merely sliding up.
|
||||
side: idx < entranceAnchor ? -1 : 1,
|
||||
reduceMotion: reduceMotion)
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
@@ -346,4 +425,87 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a card arrives when its strip does: turned away on the drum, small, low and invisible —
|
||||
/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to
|
||||
/// the left of the anchor hinge on their trailing edge and cards to its right on their leading
|
||||
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
|
||||
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
||||
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
||||
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
|
||||
/// speaks, so the arrival and the scrolling feel like one object.
|
||||
///
|
||||
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
||||
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
||||
/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from
|
||||
/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only
|
||||
/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the
|
||||
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
||||
///
|
||||
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
struct CardEntrance: ViewModifier, Animatable {
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts.
|
||||
static let perCard: Double = 0.6
|
||||
static let maxDelay: Double = 0.42
|
||||
/// The master timeline the carousel animates 0 → 1.
|
||||
static var total: Double { perCard + maxDelay }
|
||||
|
||||
/// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this
|
||||
/// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure
|
||||
/// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch
|
||||
/// the caller's `.scrollTransition` mid-scroll and strand it.
|
||||
var progress: Double
|
||||
/// Where this card's window opens on that timeline, 0…1.
|
||||
let start: Double
|
||||
/// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor),
|
||||
/// +1 hinged on its leading edge (right of it). Never 0 — every card turns, including the
|
||||
/// centred one.
|
||||
let side: Double
|
||||
let reduceMotion: Bool
|
||||
|
||||
var animatableData: Double {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// This card's own 0…1, sliced out of the master clock.
|
||||
let span = Self.perCard / Self.total
|
||||
let raw = min(max((progress - start) / span, 0), 1)
|
||||
// The travel eases out with a whisker of overshoot, so a card settles rather than stops.
|
||||
let travel = Self.easeOutBack(raw)
|
||||
// The fade is FAR quicker than the travel — it finishes in the first third of the window.
|
||||
// Sharing one curve meant the card spent its whole swing at near-zero opacity and only
|
||||
// the last few degrees ever showed, which is why this read as a small slide.
|
||||
let fade = Self.easeOut(min(raw / 0.34, 1))
|
||||
// Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. The
|
||||
// sign matches the coverflow's own recede (right of centre turns negative about its
|
||||
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
||||
// it instead of swinging the opposite way.
|
||||
let away = reduceMotion ? 0 : 1 - travel
|
||||
return content
|
||||
.opacity(reduceMotion ? raw : fade)
|
||||
.scaleEffect(1 - 0.26 * away)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
/// `1 - (1-t)³`, with a small overshoot past 1 before it settles.
|
||||
private static func easeOutBack(_ t: Double) -> Double {
|
||||
let c1 = 1.2, c3 = c1 + 1
|
||||
let u = t - 1
|
||||
return 1 + c3 * u * u * u + c1 * u * u
|
||||
}
|
||||
|
||||
private static func easeOut(_ t: Double) -> Double {
|
||||
let u = 1 - t
|
||||
return 1 - u * u * u
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -23,22 +23,47 @@ func buttonGlyph(
|
||||
|
||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
|
||||
/// at all, so the iOS value hugs the top edge there.
|
||||
/// at all. The other values follow the console shell's rhythm (title top = 18 design units,
|
||||
/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole
|
||||
/// header reads pressed against the bezel, which the tab strip's extra band made obvious.
|
||||
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#if os(macOS)
|
||||
26
|
||||
#elseif os(tvOS)
|
||||
24
|
||||
#else
|
||||
compact ? 4 : 10
|
||||
compact ? 18 : 28
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is
|
||||
/// one) before the content: the console leaves ~14 units of air under its tab pills, and without
|
||||
/// it the first row sits shoulder-to-shoulder with the header.
|
||||
func gamepadTitleBottomPadding(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
16
|
||||
#else
|
||||
compact ? 8 : 12
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Spacing between a header's stacked elements (title over tab strip / subtitle).
|
||||
func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
13
|
||||
#else
|
||||
compact ? 6 : 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||
/// in-hand compact-aware sizes elsewhere.
|
||||
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict
|
||||
/// on the smaller first cut was "way too small" once the title moved off-centre.
|
||||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
compact ? 20 : 30
|
||||
compact ? 24 : 34
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -58,8 +83,7 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
static let detailFont: CGFloat = 19
|
||||
static let closeFont: CGFloat = 20
|
||||
static let closeSide: CGFloat = 48
|
||||
static let bandWidth: CGFloat = 380
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
@@ -72,8 +96,8 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
static let detailFont: CGFloat = 13
|
||||
static let closeFont: CGFloat = 14
|
||||
static let closeSide: CGFloat = 34
|
||||
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
||||
static let bandWidth: CGFloat = 240
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -147,8 +171,21 @@ struct GamepadHintBar: View {
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Quiet the field for a form screen (see the type comment).
|
||||
var calm = false
|
||||
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
|
||||
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
|
||||
/// it during a push/pop — the console does the same with its `bg_mix` — and every
|
||||
/// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where
|
||||
/// re-built gradient stops do not.
|
||||
var calmMix: Double
|
||||
|
||||
/// The Bool spelling every non-shell call site uses (see the type comment for `calm`).
|
||||
init(calm: Bool = false) {
|
||||
calmMix = calm ? 1 : 0
|
||||
}
|
||||
|
||||
init(calmMix: Double) {
|
||||
self.calmMix = calmMix
|
||||
}
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@@ -184,21 +221,22 @@ struct GamepadScreenBackground: View {
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
||||
.opacity(calm ? 0.6 : 1)
|
||||
if calm {
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
.opacity(1 - 0.4 * calmMix)
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
||||
// and an always-present layer is what lets the mix animate instead of popping.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4 * calmMix)
|
||||
.blendMode(.plusLighter)
|
||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
|
||||
// screen's rows run out toward the edges, where crushing them just eats the list.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
|
||||
colors: [.clear, scrim.opacity(0.42 * strength)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
.opacity(1 - 0.5 * calmMix)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// works on the field itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
// blur), so it stays a gradient, just a light one.
|
||||
@@ -342,20 +380,39 @@ struct GamepadTrayScrim: View {
|
||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
||||
// light under dark ink.
|
||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||
// Sink the material's grey luminance lift toward the palette's shade (black on a
|
||||
// dark field — field ask: the frost read GREY over the aurora). Inside the mask, so
|
||||
// the tint dissolves with the blur.
|
||||
.overlay(ink.shade(0.35))
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a
|
||||
// line. The strong region sits deep (0.65) because the first stretch of the gradient
|
||||
// now runs over the fixed 80 pt outer overhang below.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black.opacity(0.9), location: 0.5),
|
||||
.init(color: .black.opacity(0.92), location: 0.65),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: fromEdge, endPoint: toContent)
|
||||
}
|
||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||
// text always sits on the strong part, rows blur out before they reach it.
|
||||
.padding(edge == .top ? .bottom : .top, -32)
|
||||
.ignoresSafeArea()
|
||||
// text always sits on the strong part, rows blur out before they reach it. The bottom
|
||||
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
|
||||
// the field verdict on the short reach was rows colliding visibly with the legend.
|
||||
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
|
||||
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||
// beat after insertion (outside any geometry group and outside this view's own
|
||||
// transaction), which is exactly the pop the field kept seeing — vertically first,
|
||||
// then, once the vertical runway became padding, on the X axis alone (the landscape
|
||||
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
|
||||
// so the overhang simply draws.
|
||||
.padding(edge == .top ? .top : .bottom, -80)
|
||||
.padding(.horizontal, -80)
|
||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||
// geometry would ride the push's transaction and visibly grow into place. The
|
||||
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ struct GamepadHomeView: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
let connect: (StoredHost, ProfileSelection) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (StoredHost, String) -> Void
|
||||
|
||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||
@@ -93,29 +96,56 @@ struct GamepadHomeView: View {
|
||||
private let compact = false // no size classes on macOS; the window minimum keeps room
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
|
||||
/// the controller — a double-tapped A can't push two screens, and the held button that
|
||||
/// caused the change is long released before the next poller starts (whose own
|
||||
/// `needsSnapshot` seed swallows it if not).
|
||||
@State private var transitioning = false
|
||||
/// Guards the gate's release against an interrupted transition: only the newest hold clears.
|
||||
@State private var transitionEpoch = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
// The in-place shell (see GamepadShell.swift): the launcher is the base layer, the
|
||||
// current sub-screen a transparent layer over it, both over ONE persistent backdrop
|
||||
// that never unmounts — a push slides the screen up out of a fade while the launcher
|
||||
// recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is
|
||||
// constantly nil and this ZStack degenerates to the plain launcher, presented over by
|
||||
// the sheets/covers below exactly as before.
|
||||
ZStack {
|
||||
homeLayer
|
||||
.opacity(covered ? 0 : 1)
|
||||
.scaleEffect(covered ? GamepadShellMotion.underScale : 1)
|
||||
// The covers used to swallow touch; the recessed layer must too.
|
||||
.allowsHitTesting(!covered)
|
||||
#if os(iOS)
|
||||
if let screen = topScreen {
|
||||
screenLayer(screen)
|
||||
// Settle the screen's internal layout before the insertion animates, so
|
||||
// descendants never lerp from a half-resolved first frame. (Not sufficient
|
||||
// for the tray blurs on its own — safe-area expansion resolves outside a
|
||||
// geometry group; GamepadTrayScrim pins its own geometry too.)
|
||||
.geometryGroup()
|
||||
.zIndex(1)
|
||||
.id(screen.id)
|
||||
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleBar
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
|
||||
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
|
||||
// derived id catches every writer. Reduce Motion snaps.
|
||||
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
|
||||
// ONE living field for every layer, still a `.background` (the layout rule in this
|
||||
// file's header). Its calm is CHASED between the launcher's aurora and the form
|
||||
// screens' quiet, never crossfaded per screen — the console's `bg_mix`.
|
||||
.background {
|
||||
GamepadScreenBackground(calmMix: calmTarget)
|
||||
.animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -129,6 +159,17 @@ struct GamepadHomeView: View {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: topScreenID) { _, _ in
|
||||
transitionEpoch += 1
|
||||
let epoch = transitionEpoch
|
||||
transitioning = true
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
||||
if epoch == transitionEpoch { transitioning = false }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
|
||||
// X, and historically tvOS maps a pad's X to this same press — the poll and this command
|
||||
// double-firing just sets the same Bool twice.
|
||||
@@ -136,8 +177,9 @@ struct GamepadHomeView: View {
|
||||
.onPlayPauseCommand { showSettings = true }
|
||||
#endif
|
||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
// gate above). macOS has no fullScreenCover — they are generously sized sheets over the
|
||||
// dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the
|
||||
// shell's layers above ARE the presentation.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store)
|
||||
@@ -148,7 +190,7 @@ struct GamepadHomeView: View {
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
@@ -156,6 +198,110 @@ struct GamepadHomeView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - The shell's layers (see GamepadShell.swift)
|
||||
|
||||
/// The launcher itself — everything the pre-shell body was, minus the backdrop (hoisted to
|
||||
/// the shell) and the presentation modifiers (below).
|
||||
private var homeLayer: some View {
|
||||
GeometryReader { geo in
|
||||
hero(for: geo.size)
|
||||
}
|
||||
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
titleBar
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||
private var topScreen: GamepadScreen? {
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let host = libraryTarget { return .library(host) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View {
|
||||
// The layer owns the controller only once the push settles and nothing rides over the
|
||||
// shell (the connect/wake takeover is an overlay in ContentView, above these layers).
|
||||
let active = !transitioning && waker.waking == nil && model.phase != .connecting
|
||||
Group {
|
||||
switch screen {
|
||||
case .settings:
|
||||
GamepadSettingsView(
|
||||
store: store,
|
||||
close: { if !transitioning { showSettings = false } },
|
||||
controllerActive: active)
|
||||
case .addHost:
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .library(let host):
|
||||
GamepadLibraryScreen(
|
||||
store: store, host: host,
|
||||
onLaunch: { launchTitle(host, $0) },
|
||||
close: { if !transitioning { libraryTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
}
|
||||
.environment(\.gamepadHostedInShell, true)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var covered: Bool {
|
||||
#if os(iOS)
|
||||
topScreen != nil
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var topScreenID: String? {
|
||||
#if os(iOS)
|
||||
topScreen?.id
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The
|
||||
/// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them
|
||||
/// keeps its aurora — exactly what shipped.
|
||||
private var calmTarget: Double {
|
||||
#if os(iOS)
|
||||
topScreen?.isForm == true ? 1 : 0
|
||||
#else
|
||||
0
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
/// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
/// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
/// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
@@ -181,32 +327,27 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
// Leading title (a console heading, not a floating label — field ask), chip trailing.
|
||||
// The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip;
|
||||
// a leading title needs none of it — the flexible frame keeps the two apart, and the
|
||||
// title shrinks a little before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
statusChip
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
/// compact-height phone gives the pixels to the carousel instead.
|
||||
@ViewBuilder private var statusChip: some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +370,9 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
isActive: homeOwnsController
|
||||
) { tile, entrance in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
}
|
||||
.frame(height: cardHeight + 40)
|
||||
}
|
||||
@@ -245,8 +381,12 @@ struct GamepadHomeView: View {
|
||||
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
||||
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
||||
/// brightness/saturation + a depth blur on the recessed neighbors.
|
||||
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
||||
private func hostCard(
|
||||
_ tile: HomeTile, size: CGSize, entrance: CardEntrance
|
||||
) -> some View {
|
||||
GamepadHostTile(tile: tile, size: size)
|
||||
// Beneath the scroll transition, never around it — see CardEntrance.
|
||||
.modifier(entrance)
|
||||
.scrollTransition { content, phase in
|
||||
let d = CGFloat(min(abs(phase.value), 1))
|
||||
let scale = 1 - d * 0.12
|
||||
@@ -402,10 +542,15 @@ private struct GamepadHostTile: View {
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
// Status colours stay palette-independent (a pip must not change meaning
|
||||
// with the wallpaper) — only the glow softens on a pale field, where it
|
||||
// reads as a smudge at full strength.
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.fill(GamepadInk.onlineGreen)
|
||||
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
.shadow(
|
||||
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
|
||||
radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,7 +586,7 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
||||
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
|
||||
@@ -37,6 +37,12 @@ struct GamepadInk: Equatable, Sendable {
|
||||
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
|
||||
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
|
||||
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
|
||||
/// The glass base at `alpha` — what a surface's material is washed with so it carries the
|
||||
/// palette's hue (the console fills its panels with exactly this colour).
|
||||
func glass(_ alpha: Double) -> Color { glass.opacity(alpha) }
|
||||
/// A drop shadow: always black — a white shadow is not a shadow — but softened on a pale
|
||||
/// field, where full-strength black under every card reads as a smear rather than depth.
|
||||
func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) }
|
||||
|
||||
static func of(_ p: GamepadPalette) -> GamepadInk {
|
||||
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
|
||||
@@ -60,6 +66,10 @@ struct GamepadInk: Equatable, Sendable {
|
||||
|
||||
/// The shipped dark look — what a preview or a test composition gets.
|
||||
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
|
||||
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
||||
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
||||
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
|
||||
@@ -111,7 +111,9 @@ struct GamepadKeyboard: View {
|
||||
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(focused ? Color.black : ink.fg)
|
||||
// The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it — a dark
|
||||
// accent palette got black-on-dark with the old literal black.
|
||||
.foregroundStyle(focused ? ink.onAccent : ink.fg)
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// The library as one of the gamepad shell's in-place layers (iOS): console chrome — a pinned
|
||||
// title and a close ✕ styled like the settings screen's — around the shared LibraryView, whose
|
||||
// gamepad branch renders the coverflow. The cover presentation used to get its title and Close
|
||||
// from the wrapping NavigationStack's bar; a shell layer has no bar, so this restores both in
|
||||
// the console's own grammar. Everything data-shaped (the fetch, the loading/error/empty states,
|
||||
// the image session lifecycle) stays LibraryView's.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
|
||||
struct GamepadLibraryScreen: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@ObservedObject var store: HostStore
|
||||
let host: StoredHost
|
||||
let onLaunch: (String) -> Void
|
||||
let close: () -> Void
|
||||
var controllerActive = true
|
||||
|
||||
/// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
LibraryView(
|
||||
store: store, host: host, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
||||
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
|
||||
Text("\(host.displayName) — Library")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
Button("Close") { close() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
// The gamepad UI's screen-shell vocabulary (iOS): which screen sits over the launcher, and the
|
||||
// console push/pop choreography that presents it. On iOS the launcher's sub-screens (settings,
|
||||
// add-host, library) are NOT system covers — they are transparent layers composited in
|
||||
// GamepadHomeView's ZStack over ONE persistent living backdrop, exactly the model
|
||||
// `pf-console-ui`'s shell renders on the desktop clients: a push slides the incoming screen up
|
||||
// out of a fade while the outgoing one recedes; a pop mirrors it; the field underneath never
|
||||
// moves and never leaves. A system `fullScreenCover` — an opaque sheet sliding up from the
|
||||
// bottom edge, mounting its own backdrop — was exactly the wrong grammar for a console.
|
||||
// (macOS keeps its windowed sheets and tvOS its focus-engine covers; this file's motion
|
||||
// constants are iOS-only in practice, but compile everywhere for the shared call sites.)
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
/// The screen the shell currently shows over the launcher. Derived, not stored: the presentation
|
||||
/// triggers (`showSettings`, `showAddHost`, `libraryTarget`) stay authoritative on every
|
||||
/// platform — this enum is just their iOS rendering. Depth is ≤ 1 by construction (the settings
|
||||
/// pin picker is an in-screen layer, and every trigger is only reachable from the launcher), so
|
||||
/// there is no stack to model.
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case library(StoredHost)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
case .library(let host): return "library-\(host.id.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The backdrop's calm target while this screen is up: the form screens quiet the field
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
|
||||
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
|
||||
enum GamepadShellMotion {
|
||||
/// One transition, both layers — the console's `TRANSITION_S`.
|
||||
static let duration: TimeInterval = 0.26
|
||||
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
|
||||
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
|
||||
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
|
||||
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
|
||||
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
|
||||
/// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone.
|
||||
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
|
||||
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
|
||||
static let inScale: CGFloat = 0.985
|
||||
static let underScale: CGFloat = 0.96
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
/// The console push/pop for the top layer. Insertion: up out of a fade, growing from 0.985.
|
||||
/// Removal: down into a fade at full size (the console's pop leaves scale alone). The
|
||||
/// launcher's recede underneath is NOT a transition — it never unmounts — it is the
|
||||
/// `covered` opacity/scale in GamepadHomeView, animated in the same transaction.
|
||||
///
|
||||
/// Known deviation from the console: a pop there re-reveals the launcher from α 0.4; a
|
||||
/// SwiftUI opacity animates from 0. Same duration, same landing — the revealed screen just
|
||||
/// reads a beat later in the fade, not worth an explicitly-driven progress machine.
|
||||
static func gamepadScreen(slide: CGFloat) -> AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .opacity
|
||||
.combined(with: .offset(y: slide))
|
||||
.combined(with: .scale(scale: GamepadShellMotion.inScale)),
|
||||
removal: .opacity.combined(with: .offset(y: slide)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadHostedInShellKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// True for a screen mounted as one of the shell's layers: it must NOT mount its own
|
||||
/// backdrop (the shell's single persistent field is behind everything already — a second
|
||||
/// one would double the mesh cost and break the "field never moves" illusion). The same
|
||||
/// screens presented as macOS sheets / tvOS covers read the default `false` and keep
|
||||
/// mounting their own, exactly as before.
|
||||
var gamepadHostedInShell: Bool {
|
||||
get { self[GamepadHostedInShellKey.self] }
|
||||
set { self[GamepadHostedInShellKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -21,11 +21,16 @@ import GameController
|
||||
struct LibraryCoverflowView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let games: [GameEntry]
|
||||
let imageSession: URLSession?
|
||||
let artLoader: LibraryArtLoader?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
var onDismiss: (() -> Void)?
|
||||
/// Whether the carousel owns the controller — the in-place shell gates it (mid-transition,
|
||||
/// and under the connect takeover after A launches a title, where this coverflow used to
|
||||
/// keep polling underneath). Cover/sheet presentations keep the default.
|
||||
var controllerActive = true
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives a tighter poster so everything still fits.
|
||||
@@ -36,6 +41,18 @@ struct LibraryCoverflowView: View {
|
||||
private let compact = false // no size classes on macOS
|
||||
#endif
|
||||
@State private var selection: String?
|
||||
/// How many covers have settled (art loaded, or every candidate exhausted).
|
||||
@State private var artSettled = 0
|
||||
/// The backstop below has fired: play the entrance regardless of what the art is doing.
|
||||
@State private var artWaitOver = false
|
||||
|
||||
/// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and
|
||||
/// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for
|
||||
/// the first few covers — every poster is fetched in parallel, so those land together and
|
||||
/// cover the visible strip. The wait is capped: a slow or artless library still animates.
|
||||
private var contentReady: Bool {
|
||||
artWaitOver || artSettled >= min(4, games.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
@@ -46,10 +63,19 @@ struct LibraryCoverflowView: View {
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Hosted in the shell, the field is the shell's own persistent aurora (the library is
|
||||
// an aurora screen — the calm mix simply stays 0, so nothing even chases).
|
||||
.background {
|
||||
if !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
// The entrance's backstop (see `contentReady`).
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
artWaitOver = true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
@@ -81,9 +107,11 @@ struct LibraryCoverflowView: View {
|
||||
spacing: 34,
|
||||
onActivate: { onLaunch?($0.id) },
|
||||
onBack: { onDismiss?() },
|
||||
shoulderJump: 5
|
||||
) { game in
|
||||
cover(game, width: coverWidth, height: coverHeight)
|
||||
shoulderJump: 5,
|
||||
isActive: controllerActive,
|
||||
contentReady: contentReady
|
||||
) { game, entrance in
|
||||
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
|
||||
}
|
||||
.frame(height: coverHeight + 44)
|
||||
}
|
||||
@@ -92,18 +120,26 @@ struct LibraryCoverflowView: View {
|
||||
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
|
||||
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
|
||||
/// scale/rotation/opacity ramp already makes the centered cover prominent.
|
||||
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
private func cover(
|
||||
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
// `solid`: a frosted chip can't sample a backdrop through this card's own
|
||||
// composited transform, so it would only show up on the centred card.
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
|
||||
// Beneath the scroll transition, never around it — see CardEntrance.
|
||||
.modifier(entrance)
|
||||
.scrollTransition { content, phase in
|
||||
let v = phase.value
|
||||
let d = CGFloat(min(abs(v), 1))
|
||||
|
||||
@@ -12,15 +12,21 @@ struct LibraryView: View {
|
||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||
var onLaunch: ((String) -> Void)? = nil
|
||||
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
|
||||
/// presentation — falls back to the environment dismiss.
|
||||
var onClose: (() -> Void)? = nil
|
||||
/// Whether the gamepad coverflow owns the controller — the shell gates it during a push/pop
|
||||
/// and while the connect takeover is up. Presentations that cover the launcher keep the
|
||||
/// default (their being up IS the launcher's gate).
|
||||
var controllerActive = true
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var games: [GameEntry] = []
|
||||
@State private var loading = false
|
||||
@State private var errorText: String?
|
||||
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@@ -55,9 +61,23 @@ struct LibraryView: View {
|
||||
}
|
||||
.task { await load() }
|
||||
.onDisappear {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
// Hand the loader off before clearing it, so its pooled connections are closed
|
||||
// rather than left open on a screen the user has left.
|
||||
let leaving = artLoader
|
||||
artLoader = nil
|
||||
Task { await leaving?.close() }
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// B closes the library even before the coverflow exists (loading / error / empty):
|
||||
// the coverflow's carousel owns B once games render; until then this zero-size
|
||||
// listener does — without it a controller-only user is trapped on an error screen
|
||||
// (the gamepad screens carry no close chrome).
|
||||
.background {
|
||||
if gamepadUIActive && games.isEmpty {
|
||||
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@@ -71,8 +91,9 @@ struct LibraryView: View {
|
||||
} else {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
onDismiss: { dismiss() })
|
||||
games: games, artLoader: artLoader, onLaunch: onLaunch,
|
||||
onDismiss: { (onClose ?? { dismiss() })() },
|
||||
controllerActive: controllerActive)
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
@@ -105,10 +126,10 @@ struct LibraryView: View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
GameCard(game: game, artLoader: artLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,8 +208,7 @@ struct LibraryView: View {
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
artLoader = try LibraryArtLoader(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
@@ -202,15 +222,39 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same
|
||||
/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows
|
||||
/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up.
|
||||
private struct LibraryBackCatcher: View {
|
||||
let active: Bool
|
||||
let onBack: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.onAppear {
|
||||
input.onBack = onBack
|
||||
if active { input.start() }
|
||||
}
|
||||
.onChange(of: active) { _, nowActive in
|
||||
if nowActive { input.start() } else { input.stop() }
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let imageSession: URLSession?
|
||||
let artLoader: LibraryArtLoader?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
@@ -17,16 +17,29 @@ struct StoreBadge: View {
|
||||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||||
/// without reading the title.
|
||||
var isLauncher: Bool = false
|
||||
/// Fill the chip with a flat wash instead of a frosted material.
|
||||
///
|
||||
/// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them
|
||||
/// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an
|
||||
/// offscreen composite — so the frost stayed blank on every card and only appeared on the one
|
||||
/// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat
|
||||
/// wash has no backdrop to sample: it is simply always there. (Deliberately black, not
|
||||
/// palette ink: the chip sits on cover art, whose colours the palette has no business
|
||||
/// fighting.)
|
||||
var solid: Bool = false
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if isLauncher { return AnyShapeStyle(Color.brand) }
|
||||
return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.background(fill, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
@@ -47,8 +60,8 @@ private extension Image {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
@@ -57,7 +70,11 @@ private extension Image {
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
let loader: LibraryArtLoader?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
var onLoaded: (() -> Void)?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
|
||||
@@ -67,26 +84,38 @@ struct PosterImage: View {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity)
|
||||
} else if index < candidates.count {
|
||||
ZStack { placeholder; ProgressView() }
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
placeholder
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
// Art crosses over its placeholder instead of replacing it between two frames. Cover
|
||||
// fetches land one by one, so without this a freshly opened library is a run of cards
|
||||
// visibly snapping from grey to artwork after the strip has already settled.
|
||||
.animation(.easeOut(duration: 0.3), value: image != nil)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.task(id: index) { await loadCurrent() }
|
||||
}
|
||||
|
||||
private func loadCurrent() async {
|
||||
guard index < candidates.count else { return }
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
// Past the end: the placeholder IS the final look, so this poster has settled.
|
||||
guard index < candidates.count else {
|
||||
onLoaded?()
|
||||
return
|
||||
}
|
||||
guard let loader, let data = try? await loader.data(for: candidates[index]),
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
return
|
||||
}
|
||||
image = loaded
|
||||
onLoaded?()
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
|
||||
@@ -243,7 +243,7 @@ private struct ShotGamepadHome: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ private struct ShotConnect: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
|
||||
@@ -132,6 +132,17 @@ final class SessionModel: ObservableObject {
|
||||
/// and under stage-1.
|
||||
@Published var osFloorP50Ms = 0.0
|
||||
@Published var osFloorValid = false
|
||||
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
|
||||
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
|
||||
/// (positive = audio behind). `audioValid` is false until playback runs.
|
||||
///
|
||||
/// Both numbers, never just the depth — a deep ring on a jittery link is the adaptive floor
|
||||
/// doing its job, and only the offset separates that from audio simply being held late. They
|
||||
/// existed nowhere a surface could render them until now, which is why a field report of "the
|
||||
/// audio delay seems way too high" was triaged all the way to a conclusion without them.
|
||||
@Published var audioBufferMs = 0
|
||||
@Published var audioAvOffsetMs = 0
|
||||
@Published var audioValid = false
|
||||
|
||||
/// The floor-shaved values every HUD tier displays (raw − floor, never below 0). Identical
|
||||
/// to the raw values whenever no floor is measured.
|
||||
@@ -153,6 +164,20 @@ final class SessionModel: ObservableObject {
|
||||
/// background's privacy mute never clears the user's choice. Local and instant: it gates
|
||||
/// capture on this device, nothing is sent to the host.
|
||||
@Published private(set) var micMuted = false
|
||||
/// The kind a controller declared when it turned out this session cannot carry its motion —
|
||||
/// set once per such pad, cleared after `motionHintSeconds`. Nil the rest of the time.
|
||||
///
|
||||
/// It exists because the failure is otherwise entirely silent: the gyro simply does nothing,
|
||||
/// with no way for the player to tell a dead sensor from a session that resolved a backend
|
||||
/// without a motion plane. The fix is a settings change, so the hint has to name it.
|
||||
@Published private(set) var motionUnreachableKind: PunktfunkConnection.GamepadType?
|
||||
/// Drops `motionUnreachableKind` again — held so a second pad's hint replaces the first
|
||||
/// cleanly, and so ending the session cancels a pending clear rather than letting it fire
|
||||
/// into a torn-down model.
|
||||
private var motionHintTimer: Task<Void, Never>?
|
||||
/// How long the motion hint stays up — the start-of-stream shortcut banner's 6 s, since the
|
||||
/// two share the bottom-centre stack and a player reads them the same way.
|
||||
private static let motionHintSeconds: UInt64 = 6
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -524,6 +549,21 @@ final class SessionModel: ObservableObject {
|
||||
applyMicMute()
|
||||
}
|
||||
|
||||
/// A forwarded controller has a gyro this session cannot carry (see
|
||||
/// `GamepadCapture.onMotionUnreachable`). Show it briefly, then let it go.
|
||||
///
|
||||
/// Last pad wins, and its timer restarts: two such pads are the same one fact to a player, and
|
||||
/// a second hint appearing under a still-visible first would only read as a stutter.
|
||||
private func noteMotionUnreachable(_ kind: PunktfunkConnection.GamepadType) {
|
||||
motionUnreachableKind = kind
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.motionUnreachableKind = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
@@ -573,6 +613,11 @@ final class SessionModel: ObservableObject {
|
||||
// The mic mute is per-session and never persisted: the next stream starts live (if the
|
||||
// mic is enabled), rather than silently carrying a mute nobody remembers making.
|
||||
micMuted = false
|
||||
// Cancel before clearing: a pending clear firing into a torn-down session would be
|
||||
// harmless but pointless, and leaving the hint set would carry it into the next stream.
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = nil
|
||||
motionUnreachableKind = nil
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -628,6 +673,7 @@ final class SessionModel: ObservableObject {
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
audioValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
@@ -702,7 +748,14 @@ final class SessionModel: ObservableObject {
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled,
|
||||
echoCancel: settings.echoCancel)
|
||||
echoCancel: settings.echoCancel,
|
||||
// The A/V sync reference: `endToEnd` is capture→on-glass, the one figure that says
|
||||
// where the picture actually IS, and the audio ring steers its depth to land with it.
|
||||
// The same meter object the presenter writes per presented frame, so audio reads the
|
||||
// video plane's own measurement rather than a second estimate of it — and under the
|
||||
// stage-1 fallback presenter, which stamps nothing, it stays empty and the loop
|
||||
// correctly declines to correct.
|
||||
videoLatency: endToEnd)
|
||||
self.audio = audio
|
||||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||
@@ -722,6 +775,9 @@ final class SessionModel: ObservableObject {
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
// A pad with a gyro that this session cannot carry — say so once, briefly, and name the
|
||||
// setting that fixes it. Already main-actor (GamepadCapture fires it there).
|
||||
capture.onMotionUnreachable = { [weak self] kind in self?.noteMotionUnreachable(kind) }
|
||||
capture.start()
|
||||
gamepadCapture = capture
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
@@ -860,6 +916,15 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.clientQueueValid = false
|
||||
}
|
||||
// The audio plane is a LEVEL, not a window: the ring's depth and the sync loop's
|
||||
// smoothed offset are both current values, so they are read rather than drained.
|
||||
if let a = self.audio?.stats {
|
||||
self.audioBufferMs = a.bufferMS
|
||||
self.audioAvOffsetMs = a.avOffsetMS
|
||||
self.audioValid = true
|
||||
} else {
|
||||
self.audioValid = false
|
||||
}
|
||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||
@@ -875,7 +940,12 @@ final class SessionModel: ObservableObject {
|
||||
// the whole line (a cascade error that also mis-blames the float args).
|
||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
|
||||
// Appended LAST, so every existing parser of this line is unaffected.
|
||||
// In the log as well as on the HUD because the overlay is only up when
|
||||
// someone thought to turn it on, and the reports that need these
|
||||
// numbers arrive after the fact.
|
||||
+ "audio_buffer=%lld audio_av_offset=%lld",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -887,7 +957,9 @@ final class SessionModel: ObservableObject {
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1,
|
||||
self.audioValid ? self.audioBufferMs : -1,
|
||||
self.audioValid ? self.audioAvOffsetMs : 0)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,28 @@ struct StreamHUDView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
// The AUDIO plane's own latency (detailed tier). Deliberately OUTSIDE the video branch
|
||||
// above: it is not a term of that equation — audio is steered to MEET the video total,
|
||||
// never summed into it — and the depth is exactly as worth seeing under the stage-1
|
||||
// fallback presenter, which measures no end-to-end at all.
|
||||
//
|
||||
// `buffer` is how much decoded audio is queued ahead of the speaker; `a/v` is where
|
||||
// that puts it relative to the picture (+ = audio behind). Both, not just the depth: a
|
||||
// deep ring on a jittery link is the adaptive floor doing its job, and only the offset
|
||||
// distinguishes that from a ring holding audio late. Neither number was renderable
|
||||
// anywhere before — they lived in a periodic log line — which is how a report of "the
|
||||
// audio delay seems way too high" got triaged to a conclusion with no instrument.
|
||||
if verbosity == .detailed && model.audioValid && model.audioBufferMs > 0 {
|
||||
// String(format:) for the signed offset: `%+d` has no specifier-interpolation
|
||||
// equivalent, and Swift's Int is 64-bit (%lld, never the 32-bit %d).
|
||||
Text(model.audioAvOffsetMs == 0
|
||||
? "audio buffer \(model.audioBufferMs) ms"
|
||||
: String(
|
||||
format: "audio buffer %lld ms · a/v %+lld ms",
|
||||
model.audioBufferMs, model.audioAvOffsetMs))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if model.lostFrames > 0 {
|
||||
// Unrecoverable network drops this window; hidden while the link is clean.
|
||||
// String(format:) rather than specifier interpolation: the literal % would
|
||||
@@ -224,9 +246,18 @@ struct StreamHUDView: View {
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||||
///
|
||||
/// On iOS it also has to CLEAR THE CORNER. A rounded corner of radius `r` pulls the card's
|
||||
/// edge inward by `r − √(r² − (r−y)²)` at a distance `y` below the top, so the first and last
|
||||
/// lines of a padded stack sit inside the arc unless the padding keeps pace with the radius.
|
||||
/// At `0.45 · r` that intrusion stays well inside the padding across the whole range this
|
||||
/// card can wear (≈4.6 pt of arc against 12.6 pt of padding at the 28 pt cap), so no line
|
||||
/// ever runs into the curve.
|
||||
private var cardPadding: CGFloat {
|
||||
#if os(tvOS)
|
||||
return 16
|
||||
#elseif os(iOS)
|
||||
return max(10, cardCornerRadius * 0.45)
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
@@ -246,13 +277,20 @@ struct StreamHUDView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card's corner radius. On iOS it's concentric with the physical display corner —
|
||||
/// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the
|
||||
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
|
||||
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
|
||||
/// The card's corner radius. On iOS it aims to be concentric with the physical display
|
||||
/// corner — `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform
|
||||
/// right around the corner instead of a small-radius card cutting into the very rounded
|
||||
/// glass — but that aim is BOUNDED by what a card this small can actually carry.
|
||||
///
|
||||
/// Unbounded, a modern phone (~62 pt of display radius) asked for a 48 pt corner on a card
|
||||
/// whose lines sit 10 pt from the edge: the arc reaches ~19 pt inward at the first line, so
|
||||
/// the top and bottom lines rendered INSIDE the curve. Concentricity is only a virtue while
|
||||
/// the radius is small next to the card; past that it is just a blob eating its own text.
|
||||
/// 28 pt is the most this card's stack can wear (with `cardPadding` scaling alongside), and
|
||||
/// devices whose display radius asks for less than that still get a truly concentric corner.
|
||||
private var cardCornerRadius: CGFloat {
|
||||
#if os(iOS)
|
||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||
return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset))
|
||||
#elseif os(tvOS)
|
||||
return 16 // scales with the roomier padding
|
||||
#else
|
||||
@@ -267,6 +305,39 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "This pad's gyro can't reach the game" — shown briefly when a forwarded controller with motion
|
||||
/// meets a session whose virtual controller has no motion plane (an X-Box class pad has no gyro in
|
||||
/// its HID contract, so every sample would be decoded and dropped).
|
||||
///
|
||||
/// Not a control, unlike `MicMutedBadge`: the fix is the Controller type setting, which is not
|
||||
/// reachable mid-stream on every platform, and changing it applies from the next session anyway.
|
||||
/// So this states the fact and names the setting, in the HUD's glass language, and gets out of the
|
||||
/// way — the alternative is what shipped before, which was a gyro that silently did nothing with
|
||||
/// no way to tell that from a broken sensor.
|
||||
///
|
||||
/// Every platform: a DualSense on an Apple TV is an ordinary way to play, and it is exactly the
|
||||
/// pad this can happen to.
|
||||
struct MotionUnreachableBadge: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "gyroscope")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Motion won't reach this session — set Controller type to DualSense")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(
|
||||
"This controller's motion will not reach the game. "
|
||||
+ "Set Controller type to DualSense to enable it.")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The gamepad settings' "select" value as a REAL band: the options sit side by side on a drum
|
||||
// segment curving about a vertical axis — the current one faces you flat, and a step rotates the
|
||||
// next one in with perspective. The old presentation animated a single Text keyed by its value
|
||||
// (an old-out/new-in crossfade that merely implied motion), which fell apart under fast repeated
|
||||
// steps: each press restarted the fade. Here the drum's position is one continuous value driven
|
||||
// by a spring, and SwiftUI's spring retargeting preserves velocity — rapid presses accumulate
|
||||
// into one accelerating travel instead of five restarted crossfades.
|
||||
//
|
||||
// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first
|
||||
// option waiting to the right of the last one, which left/right can't reach (adjust clamps) —
|
||||
// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped
|
||||
// sides with every step. So positions are fixed: option i sits i steps from the start, the ends
|
||||
// are the ends, and A's wrap from the last option travels BACK across the list to the first.
|
||||
// Options other than the facing one exist only while the drum is actually moving — at rest a row
|
||||
// shows exactly its value (a resting neighbour under a long label rendered as overlapping,
|
||||
// unreadable text).
|
||||
//
|
||||
// The band is purely presentational: stepping semantics (left/right clamps with a boundary thud,
|
||||
// A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row
|
||||
// closures. Font and ink come from the environment — the row applies the same value font/colour
|
||||
// it always did, and the drum's own opacity ramp multiplies on top.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadOptionBand: View {
|
||||
let options: [String]
|
||||
/// The committed selection — the caller's clamp/wrap already applied.
|
||||
let selection: Int
|
||||
let focused: Bool
|
||||
/// The band's footprint, FIXED by the row: a step must never reflow the row (the old
|
||||
/// free-width value shifted the chevrons with every label), and the drum needs its stage
|
||||
/// even when the facing label is short.
|
||||
let width: CGFloat
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Where the drum rests, in option steps — always chasing `Double(selection)`; only the
|
||||
/// spring's interpolation ever puts it between integers.
|
||||
@State private var drumPosition: Double
|
||||
|
||||
init(options: [String], selection: Int, focused: Bool, width: CGFloat) {
|
||||
self.options = options
|
||||
self.selection = selection
|
||||
self.focused = focused
|
||||
self.width = width
|
||||
_drumPosition = State(initialValue: Double(selection))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if reduceMotion {
|
||||
// No drum, no travel: today's quiet crossfade, minus even the 14 pt slip.
|
||||
ZStack {
|
||||
Text(current)
|
||||
.lineLimit(1)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
}
|
||||
.animation(.smooth(duration: 0.2), value: selection)
|
||||
} else {
|
||||
Drum(
|
||||
options: options,
|
||||
rotation: drumPosition,
|
||||
target: drumPosition,
|
||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||
radius: width * 0.72)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black, location: 0.12),
|
||||
.init(color: .black, location: 0.88),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||
// without a travel.
|
||||
.onChange(of: options.count) { _, _ in snap() }
|
||||
// One element to VoiceOver — the neighbour texts are rendering, not content.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(current)
|
||||
}
|
||||
|
||||
private var current: String {
|
||||
options.indices.contains(selection) ? options[selection] : ""
|
||||
}
|
||||
|
||||
/// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs
|
||||
/// the drum; anything else (an external write from the touch settings, a re-derived options
|
||||
/// list) re-seats it — a travel to a value the user didn't step to would read as the UI
|
||||
/// acting on its own.
|
||||
private func step(from old: Int, to new: Int) {
|
||||
let wrapped = options.count > 1 && old == options.count - 1 && new == 0
|
||||
guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() }
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
|
||||
drumPosition = Double(new)
|
||||
}
|
||||
}
|
||||
|
||||
private func snap() {
|
||||
var tx = Transaction()
|
||||
tx.disablesAnimations = true
|
||||
withTransaction(tx) { drumPosition = Double(selection) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED
|
||||
/// rotation every frame of the spring — each option's offset/scale/opacity follows the real arc,
|
||||
/// and options along the travel genuinely enter and leave mid-flight. (A plain `.animation` on
|
||||
/// independent modifiers can't do that: each modifier would lerp its own endpoints and the
|
||||
/// in-between options would never appear.)
|
||||
private struct Drum: View, Animatable {
|
||||
let options: [String]
|
||||
/// The interpolated drum position, in option steps.
|
||||
var rotation: Double
|
||||
/// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The
|
||||
/// distance between them is "how mid-flight are we" — the neighbours exist exactly as long
|
||||
/// as the drum is moving, fading continuously as it lands, so a resting row is one flat
|
||||
/// Text and a long label never sits under a resting neighbour.
|
||||
let target: Double
|
||||
/// Drum radius in points (from the band width — see the caller).
|
||||
let radius: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { rotation }
|
||||
set { rotation = newValue }
|
||||
}
|
||||
|
||||
/// Angular pitch between adjacent options on the drum.
|
||||
private static let stepAngle = 34.0 * .pi / 180.0
|
||||
|
||||
var body: some View {
|
||||
let flight = min(1, abs(rotation - target) * 3)
|
||||
let content = ZStack {
|
||||
ForEach(0..<options.count, id: \.self) { i in
|
||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||
// ends are the ends (nothing waits beyond the last option).
|
||||
let d = Double(i) - rotation
|
||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||
option(i, distance: d, gate: flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
||||
content.drawingGroup()
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||
let angle = d * Self.stepAngle
|
||||
let depth = cos(angle)
|
||||
// The facing option never gates: a resting row still shows its value.
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
||||
Text(options[i])
|
||||
.lineLimit(1)
|
||||
.scaleEffect(0.70 + 0.30 * depth)
|
||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
||||
.offset(x: radius * sin(angle))
|
||||
.opacity(alpha)
|
||||
.zIndex(depth)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -47,10 +47,18 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
|
||||
/// falls back to the environment dismiss. See `performClose`.
|
||||
var close: (() -> Void)?
|
||||
/// Whether this screen owns the controller. The shell holds it false during a push/pop (the
|
||||
/// console's input drop) and while the connect takeover is up; a system presentation never
|
||||
/// needs the gate and keeps the default.
|
||||
var controllerActive = true
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -85,6 +93,7 @@ struct GamepadSettingsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) private var gyroFromDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
@@ -126,7 +135,8 @@ struct GamepadSettingsView: View {
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { back() },
|
||||
onShoulder: { step(tabBy: $0) }
|
||||
onShoulder: { step(tabBy: $0) },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -134,18 +144,20 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like a console section heading — centred read as a floating label,
|
||||
// and a gamepad UI needs no close chrome next to it (B is the exit).
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
@@ -167,8 +179,12 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||
// applies here too, so this screen previews the row you're stepping.
|
||||
.background { GamepadFormBackground() }
|
||||
// applies here too, so this screen previews the row you're stepping. Hosted in the
|
||||
// shell, the field is the SHELL's (one persistent backdrop, calm-chased) — mounting a
|
||||
// second would double the mesh and snap where the shell crossfades.
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
@@ -177,6 +193,18 @@ struct GamepadSettingsView: View {
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Close") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
@@ -229,10 +257,12 @@ struct GamepadSettingsView: View {
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did.
|
||||
// in and out — the highlight travels the way the press did. A Liquid Glass
|
||||
// surface (accent-tinted through consoleGlass), so the strip wears the same
|
||||
// material language as the rows it sits above.
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(ink.accent(0.85))
|
||||
Color.clear
|
||||
.consoleGlass(Capsule(), tint: ink.accent(0.85))
|
||||
.matchedGeometryEffect(id: "tab", in: tabHighlight)
|
||||
}
|
||||
}
|
||||
@@ -274,22 +304,10 @@ struct GamepadSettingsView: View {
|
||||
focusID = landing
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Close settings")
|
||||
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||
/// the environment dismiss under a macOS sheet / tvOS cover.
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
@@ -337,7 +355,7 @@ struct GamepadSettingsView: View {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
performClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,24 +381,31 @@ struct GamepadSettingsView: View {
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
// Deliberately not `.push`: that travels the whole container width, loud
|
||||
// and visibly outside the row. The ZStack is the stable home the
|
||||
// removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
if let labels = row.optionLabels, let idx = row.selectedIndex {
|
||||
// A choice row's value is a REAL band — the options ride a rotating
|
||||
// drum, so fast repeated steps spin it instead of restarting a fade.
|
||||
GamepadOptionBand(
|
||||
options: labels, selection: idx, focused: focused, width: bandWidth)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
insertion: .offset(x: slide).combined(with: .opacity),
|
||||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||
} else {
|
||||
// The flat rows (profile pin counts, placeholders) keep the quiet slip:
|
||||
// keyed by the value so a change slides the new string in following the
|
||||
// user's motion, crossfading over ~14 pt. The ZStack is the stable home
|
||||
// the removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
insertion: .offset(x: slide).combined(with: .opacity),
|
||||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
@@ -410,6 +435,17 @@ struct GamepadSettingsView: View {
|
||||
rows.first { $0.id == focusID }?.detail ?? " "
|
||||
}
|
||||
|
||||
/// The option band's fixed stage. A portrait phone is the one place the full 240 pt starves
|
||||
/// the row's label (everywhere else the 620 pt row cap leaves room to spare), so it alone
|
||||
/// narrows the stage.
|
||||
private var bandWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
||||
#else
|
||||
GamepadFormMetrics.bandWidth
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Row model
|
||||
|
||||
private struct Row: Identifiable {
|
||||
@@ -422,6 +458,11 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// A choice row's full option list (labels only — the tags stay inside the closures)
|
||||
/// and where its drum currently rests. nil ⇒ the value renders as plain text (toggles,
|
||||
/// actions, profiles — a two-position switch is not a drum; see GamepadOptionBand).
|
||||
var optionLabels: [String]?
|
||||
var selectedIndex: Int?
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
@@ -649,6 +690,22 @@ struct GamepadSettingsView: View {
|
||||
value: $rumbleOnDevice),
|
||||
at: at + 1)
|
||||
}
|
||||
// The phone-gyro mirror sits beside the rumble mirror: same clip-on-pad audience,
|
||||
// opposite data direction. Hidden where the device has no motion hardware; engages
|
||||
// in-session only while player 1's controller reports no rotation rate of its own.
|
||||
if DeviceGyro.isAvailable,
|
||||
let anchor = list.firstIndex(where: { $0.id == "deviceRumble" })
|
||||
?? list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceGyro", tab: .controller,
|
||||
icon: "gyroscope",
|
||||
label: "Gyro from this device",
|
||||
detail: "When the controller has no gyro, send this device's motion "
|
||||
+ "sensors as player 1's — for clip-on pads without one of their own.",
|
||||
value: $gyroFromDevice),
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
return list + profileRows
|
||||
}
|
||||
@@ -710,6 +767,8 @@ struct GamepadSettingsView: View {
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
optionLabels: ["Off", "Pinned"],
|
||||
selectedIndex: pinned ? 1 : 0,
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
@@ -776,6 +835,10 @@ struct GamepadSettingsView: View {
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
// The band mounts only once the value is a known option — the "—" of an unknown
|
||||
// current renders flat, and the first step's snap-to-first seats the drum.
|
||||
optionLabels: index != nil ? options.map(\.label) : nil,
|
||||
selectedIndex: index,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
@@ -803,6 +866,10 @@ struct GamepadSettingsView: View {
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
// Toggles ride the band too (field ask): Off sits left of On, matching the
|
||||
// directional semantics below, so a right-step slides On in from the right.
|
||||
optionLabels: ["Off", "On"],
|
||||
selectedIndex: value.wrappedValue ? 1 : 0,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
|
||||
@@ -712,6 +712,15 @@ extension SettingsView {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: hidden where the
|
||||
// device has no motion hardware, engages only while the player-1 controller
|
||||
// reports no rotation rate of its own.
|
||||
if !inProfileScope, DeviceGyro.isAvailable {
|
||||
described("When the controller has no gyro of its own, sends this device's "
|
||||
+ "motion sensors as player 1's — for clip-on pads without one.") {
|
||||
Toggle("Gyro from this device", isOn: $gyroFromDevice)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
|
||||
@@ -91,6 +91,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||
@AppStorage(DefaultsKey.gyroFromDevice) var gyroFromDevice = false
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
|
||||
@@ -70,12 +70,17 @@ extension View {
|
||||
|
||||
// MARK: - Console glass (gamepad host tiles + settings rows)
|
||||
|
||||
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces — the host-carousel tiles and
|
||||
/// Liquid Glass tuned for the gamepad UI's "console" surfaces — the host-carousel tiles and
|
||||
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
||||
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
||||
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
|
||||
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark — these surfaces
|
||||
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
|
||||
/// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes
|
||||
/// it flex on press.
|
||||
///
|
||||
/// Every tier is WASHED with the palette's `ink.glass` — the same surface colour the console
|
||||
/// fills its panels with — so switching the background palette recolours the surfaces, not just
|
||||
/// the text on them. The wash alphas are tune-on-device values with one fixed direction: the
|
||||
/// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62
|
||||
/// pair), because a thin white wash over a colourful field reads as haze, not as a surface.
|
||||
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var tint: Color?
|
||||
@@ -86,16 +91,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
||||
/// The palette wash over the material tiers (the material itself supplies the blur body).
|
||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
|
||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
@@ -104,7 +112,14 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -112,8 +127,13 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
#if !os(tvOS)
|
||||
@available(iOS 26, macOS 26, *)
|
||||
private var glass: Glass {
|
||||
var g: Glass = .regular
|
||||
if let tint { g = g.tint(tint) }
|
||||
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
||||
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
||||
// every palette) over the palette base. If device QA finds the mixed focus wash too
|
||||
// weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit.
|
||||
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
||||
var g: Glass = .regular.tint(
|
||||
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
||||
if interactive { g = g.interactive() }
|
||||
return g
|
||||
}
|
||||
@@ -121,9 +141,51 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||
/// (forced dark) pre-26. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
||||
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Console floating glass (the gamepad screens' close buttons)
|
||||
|
||||
/// `glassBackground` for a floating control INSIDE the gamepad UI (the close ✕): same shape
|
||||
/// contract, but washed with the palette's ink and frosted to the palette's scheme — plain
|
||||
/// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink
|
||||
/// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the
|
||||
/// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI,
|
||||
/// where the palette means nothing.
|
||||
private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var interactive = false
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
content
|
||||
.glassEffect(
|
||||
(interactive ? Glass.regular.interactive() : .regular)
|
||||
.tint(ink.glass(ink.isLight ? 0.60 : 0.45)),
|
||||
in: shape)
|
||||
.environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story
|
||||
/// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip.
|
||||
func consoleGlassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlassBackground(shape: shape, interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,23 @@ import os
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
///
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
|
||||
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
|
||||
/// long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches arrivals
|
||||
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
|
||||
/// constants here in step with `JitterTuning.COREAUDIO`.
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: a NEAR-MISS — a read served with
|
||||
/// less than one frame left over — grows it a step BEFORE anything was audible, repeated genuine
|
||||
/// underruns grow it too (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`,
|
||||
/// and a long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches
|
||||
/// arrivals deepens until it stops crackling, while a clean LAN keeps the tight base latency.
|
||||
/// Growth only raises a promise; the one thing that re-banks real depth is a re-prime, so an
|
||||
/// underrun while the ring is HOLLOW (depth average far below the target) re-primes at once,
|
||||
/// spending the click it already cost on the whole refill. Every shrink is armed as a PROBE:
|
||||
/// answered by an underrun or near-miss within its window, it is undone on the spot, and a
|
||||
/// failed sync-driven shrink is not retried for a growing backoff. Keep the constants here in
|
||||
/// step with `JitterTuning.COREAUDIO`.
|
||||
///
|
||||
/// **A/V sync.** On top of all that the depth can be STEERED, by `setSyncTarget` from the drain
|
||||
/// thread's `AvSync` — because a ring that is the right depth for the link is not thereby the
|
||||
/// right depth for the picture. Continuity still outranks sync: the request is clamped between
|
||||
/// the underrun-driven floor above and the hard cap, so the loop can never buy alignment with a
|
||||
/// dropout. `nil` (the default) is exactly the pre-sync behaviour.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
@@ -48,6 +60,33 @@ final class AudioRing: @unchecked Sendable {
|
||||
private static let growWindowMS = 5_000
|
||||
private static let growStepMS = 10
|
||||
private static let shrinkQuietMS = 30_000
|
||||
/// The same quiet span, while the A/V sync loop is actively asking to run shallower. A grown
|
||||
/// target normally relaxes only after a long spell because, absent other evidence, the only
|
||||
/// thing that can justify giving up hard-won slack is time; a sync request IS that evidence —
|
||||
/// a measurement saying the extra depth is costing alignment right now — so a smaller target
|
||||
/// gets tested sooner. Mirrors `SHRINK_QUIET_SYNC_MS`.
|
||||
private static let shrinkQuietSyncMS = 5_000
|
||||
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
|
||||
/// samples, but with less than one protocol frame left in hand — the same evidence as an
|
||||
/// underrun, except nobody heard it yet, so the target grows BEFORE the click instead of
|
||||
/// after the third one. Mirrors `NEAR_MISS_MARGIN_MS`.
|
||||
private static let nearMissMarginMS = frameMS
|
||||
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside
|
||||
/// this window means the shrink was wrong, and the previous target is restored at once.
|
||||
/// Mirrors `SHRINK_PROBE_MS`.
|
||||
private static let shrinkProbeMS = 5_000
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink — without it the
|
||||
/// loop pays an audible starvation event every `shrinkQuietSyncMS` on any link whose jitter
|
||||
/// genuinely needs the depth, forever. Doubles per consecutive failure, capped; a probe that
|
||||
/// survives its window resets it. Mirror `SYNC_BACKOFF_MS` / `SYNC_BACKOFF_MAX_MS`.
|
||||
private static let syncBackoffMS = 60_000
|
||||
private static let syncBackoffMaxMS = 480_000
|
||||
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: growth only ever
|
||||
/// raises the promise, and the one thing that re-banks real depth is a re-prime — so an
|
||||
/// underrun in a hollow ring re-primes AT ONCE, spending the click it already cost on the
|
||||
/// whole refill instead of riding the knife edge one click per bunching period. Mirrors
|
||||
/// `DEPRIME_DEBT_MS`.
|
||||
private static let deprimeDebtMS = growStepMS
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
@@ -70,6 +109,32 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
/// The depth the A/V sync loop would like, in interleaved samples (`AvSync.desiredDepth`).
|
||||
/// `nil` — the default, and what an un-wired session keeps — reproduces the pre-sync
|
||||
/// behaviour exactly, so this ring could adopt sync without the other three diverging.
|
||||
private var syncTarget: Int?
|
||||
/// This read was served with less than `nearMissMarginMS` left over (set in `read`,
|
||||
/// consumed by `noteRead`).
|
||||
private var nearMiss = false
|
||||
/// A near-miss already grew the target this window — one step per window, so a bunching
|
||||
/// episode (a RUN of consecutive near-misses while the ring refills) buys one measured
|
||||
/// step, not a sprint to the ceiling.
|
||||
private var nearMissGrown = false
|
||||
/// The depth average runs a `deprimeDebtMS` debt against the target (set in `read`): an
|
||||
/// underrun should re-prime at once instead of waiting out the hysteresis.
|
||||
private var hollow = false
|
||||
/// Interleaved samples left in the current shrink-probe window (0 = no probe outstanding).
|
||||
private var probeRun = 0
|
||||
/// The live target before the probed shrink, restored if the probe fails.
|
||||
private var probePrevTarget = 0
|
||||
/// Interleaved samples before the sync loop may drive another shrink (0 = allowed now).
|
||||
private var syncBackoffRun = 0
|
||||
/// Length of the NEXT backoff, in ms — doubles per consecutive failed probe, capped.
|
||||
private var syncBackoffLenMS = AudioRing.syncBackoffMS
|
||||
/// The sync loop's smoothed offset in ms, STORED not computed: the ring owns the depth but has
|
||||
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
|
||||
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
|
||||
private var avOffsetMS = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
@@ -85,9 +150,71 @@ final class AudioRing: @unchecked Sendable {
|
||||
|
||||
/// Effective target depth in interleaved samples: the (adaptively grown) live target, lifted
|
||||
/// so it can always serve one device quantum plus a packet (a large-buffer device cannot
|
||||
/// sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(targetLive, renderQuantum + Self.frameMS * perMS)
|
||||
/// sustain a target below its own quantum) — then, if the A/V sync loop has asked for a depth,
|
||||
/// its request CLAMPED into that band. Mirrors `JitterPolicy::effective_target`.
|
||||
///
|
||||
/// The clamp order is the whole safety argument for steering playback depth off a network
|
||||
/// measurement at all: sync may pull the ring shallower to catch the picture up, or push it
|
||||
/// deeper when audio runs early, but never below what underrun pressure has proven this link
|
||||
/// needs, and never past the hard cap that bounds added latency. A link whose jitter genuinely
|
||||
/// demands more buffer than the picture is away keeps its buffer and the residual is REPORTED
|
||||
/// (`Stats.avOffsetMS`) rather than taken out of the listener's stream.
|
||||
///
|
||||
/// The ceiling is raised to the floor rather than used as-is: a device whose callback quantum
|
||||
/// alone exceeds `hardCapMS` makes `floor > cap`, and a plain `min(max(s, floor), cap)` would
|
||||
/// then return the CAP — i.e. quietly below the continuity floor, inverting the very ordering
|
||||
/// this exists to guarantee, on exactly the awkward hardware it exists to survive. (Rust's
|
||||
/// `Ord::clamp` announces the same condition by panicking; Swift would just get it wrong.)
|
||||
private var target: Int { target(lift: renderQuantum) }
|
||||
|
||||
/// The effective target with an explicit quantum lift. The property above uses the high-water
|
||||
/// `renderQuantum` (priming must survive the biggest callback seen); the hollow check in
|
||||
/// `read` passes the CURRENT callback instead, mirroring the Rust side's `want` — a one-off
|
||||
/// oversized read would otherwise inflate the debt threshold forever and turn the very next
|
||||
/// late packet into a full re-prime.
|
||||
private func target(lift quantum: Int) -> Int {
|
||||
let floor = max(targetLive, quantum + Self.frameMS * perMS)
|
||||
guard let want = syncTarget else { return floor }
|
||||
let cap = max(Self.hardCapMS * perMS, floor)
|
||||
return min(max(want, floor), cap)
|
||||
}
|
||||
|
||||
/// The sync loop is asking to run shallower than the adaptive target has grown to — the
|
||||
/// evidence `noteRead` relaxes a grown target on. Compared against the LIVE target, not the
|
||||
/// effective one: it is the underrun-driven growth that a sync request is evidence against,
|
||||
/// not the device-quantum lift, which no amount of measurement can argue with.
|
||||
private var syncWantsLess: Bool {
|
||||
guard let want = syncTarget else { return false }
|
||||
return want < targetLive
|
||||
}
|
||||
|
||||
/// Hand the ring the depth the A/V sync loop wants (`AvSync.desiredDepth`), in interleaved
|
||||
/// samples, or `nil` to run unsynchronised. Called from the drain thread.
|
||||
///
|
||||
/// This is a REQUEST, not a command — see `target` for what happens to it. `nil` is the
|
||||
/// default and reproduces the pre-sync behaviour exactly.
|
||||
func setSyncTarget(_ samples: Int?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
syncTarget = samples
|
||||
}
|
||||
|
||||
/// Store the sync loop's smoothed A/V offset for reporting (positive = audio behind the
|
||||
/// picture). The ring cannot compute this — it has no timestamps — but it is where the two
|
||||
/// numbers a listener's complaint needs, depth and offset, can be read under one lock.
|
||||
func noteAvOffset(_ ms: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
avOffsetMS = ms
|
||||
}
|
||||
|
||||
/// Buffered depth in interleaved samples — what the sync loop measures against (`bufferedMS`
|
||||
/// is the same quantity rounded for humans). Everything queued here must play before the frame
|
||||
/// the drain thread is about to write, which is exactly what delays it.
|
||||
var bufferedSamples: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return writeIdx - readIdx
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -135,12 +262,24 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
// late packet would re-prime a ring that is actually full.
|
||||
depthAvg = Double(available)
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Hollow: the depth AVERAGE runs a debt against the target — the promise has been raised
|
||||
// but the depth was never re-banked (see `deprimeDebtMS`). Judged on the average, not
|
||||
// this instant: a single late packet empties the ring for a callback without making it
|
||||
// hollow, and must keep the consecutive-empties hysteresis. Lifted by THIS callback's
|
||||
// size, not the high-water quantum — see `target(lift:)`.
|
||||
hollow = depthAvg + Double(Self.deprimeDebtMS * perMS) < Double(target(lift: count))
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
@@ -164,6 +303,9 @@ final class AudioRing: @unchecked Sendable {
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
}
|
||||
// Near-miss: served in full, but with less than one frame left over — the next callback
|
||||
// starves unless a packet lands within one frame time.
|
||||
nearMiss = n == count && writeIdx - readIdx < Self.nearMissMarginMS * perMS
|
||||
noteRead(ranShort: n < count, count: count)
|
||||
}
|
||||
|
||||
@@ -178,27 +320,84 @@ final class AudioRing: @unchecked Sendable {
|
||||
if windowRun >= Self.growWindowMS * perMS {
|
||||
windowRun = 0
|
||||
underrunsInWindow = 0
|
||||
nearMissGrown = false
|
||||
}
|
||||
syncBackoffRun = max(0, syncBackoffRun - count)
|
||||
var restored = false
|
||||
if probeRun > 0 {
|
||||
probeRun = max(0, probeRun - count)
|
||||
if ranShort || nearMiss {
|
||||
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
|
||||
// Take the depth straight back — re-learning it three audible underruns at a
|
||||
// time is what made the sync-vs-growth tug-of-war audible — and keep the sync
|
||||
// loop from probing again for a while, doubling per consecutive failure. The
|
||||
// residual A/V offset is reported instead; continuity outranks sync. The
|
||||
// restore CONSUMES this event as growth evidence: it answered a depth the ring
|
||||
// is no longer at, so growing past the proven target on top would overshoot.
|
||||
probeRun = 0
|
||||
targetLive = max(targetLive, probePrevTarget)
|
||||
syncBackoffRun = syncBackoffLenMS * perMS
|
||||
syncBackoffLenMS = min(syncBackoffLenMS * 2, Self.syncBackoffMaxMS)
|
||||
restored = true
|
||||
} else if probeRun == 0 {
|
||||
// Survived the whole window: the shallower depth is genuinely safe here, so the
|
||||
// next probe starts from a clean slate.
|
||||
syncBackoffLenMS = Self.syncBackoffMS
|
||||
}
|
||||
}
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter {
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
}
|
||||
underrunsInWindow += 1
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
}
|
||||
if underrunsInWindow >= Self.growUnderruns {
|
||||
underrunsInWindow = 0
|
||||
windowRun = 0
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else if nearMiss {
|
||||
// Came within one frame of an underrun — the same evidence as one, heard by no one.
|
||||
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
|
||||
// the third audible underrun means the user heard two. One step per window (a
|
||||
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
|
||||
// measured step, not a sprint to the ceiling); if it worsens into real underruns
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
quietRun += count
|
||||
if quietRun >= Self.shrinkQuietMS * perMS {
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
// and without this branch a ring that ratcheted to the ceiling during a transient would
|
||||
// hold audio a ceiling's worth late for minutes after the cause had gone. Every shrink
|
||||
// is armed as a PROBE — answered by an underrun or near-miss it is undone at once (see
|
||||
// above), and a failed sync-driven guess is not retried for a backoff.
|
||||
let syncShrink = syncWantsLess && syncBackoffRun == 0
|
||||
let quietNeeded = syncShrink ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
|
||||
if quietRun >= quietNeeded * perMS {
|
||||
quietRun = 0
|
||||
let prev = targetLive
|
||||
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
|
||||
if targetLive < prev {
|
||||
probeRun = Self.shrinkProbeMS * perMS
|
||||
probePrevTarget = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +438,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
/// The A/V sync loop's smoothed offset (ms): **positive = audio playing BEHIND the
|
||||
/// picture**, negative = ahead of it. `0` before the loop has evidence, or with sync off.
|
||||
///
|
||||
/// Reported next to the depth, never instead of it: a deep ring on a jittery link is
|
||||
/// CORRECT behaviour, and only the offset separates that from a ring holding audio late.
|
||||
let avOffsetMS: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
@@ -248,7 +453,154 @@ final class AudioRing: @unchecked Sendable {
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
sheds: shedCount,
|
||||
avOffsetMS: avOffsetMS)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A/V sync
|
||||
|
||||
/// The A/V synchronisation controller: turns "when will this audio actually play" and "when did
|
||||
/// the picture it belongs with reach the glass" into a ring depth `AudioRing` should aim for.
|
||||
/// The Swift mirror of `punktfunk_core::audio::AvSync` — keep the two in step.
|
||||
///
|
||||
/// **The defect it exists to fix.** The host stamps `pts_ns` on every audio datagram and the
|
||||
/// client decoded it into `AudioPCM` — and then never read it. Video's `pts_ns`, by contrast, is
|
||||
/// used end to end (`LatencyMeter` computes a true glass-to-glass `displayed + clockOffset − pts`
|
||||
/// per presented frame). So audio free-ran at whatever depth its jitter ring happened to settle
|
||||
/// at, video was presented on a wholly independent path, and nothing ever compared them: the A/V
|
||||
/// offset was an accident of buffer depths. It moved whenever the ring ratcheted under underrun
|
||||
/// pressure, and — the way this surfaced in the field — it got WORSE every time video got faster,
|
||||
/// because a quicker decoder lowers the video leg while leaving the audio leg exactly where it was.
|
||||
///
|
||||
/// **Video is the master.** In a game streamer the video leg is the input-feel budget and must
|
||||
/// never be inflated to satisfy the audio clock; audio tolerates small, crossfaded, rate-limited
|
||||
/// corrections that are inaudible, and `AudioRing.shedOneFrame` already applies them. So audio
|
||||
/// moves.
|
||||
///
|
||||
/// **Continuity outranks sync.** This type only ever PROPOSES a depth. `AudioRing` clamps the
|
||||
/// proposal to its own underrun-driven floor (see `AudioRing.target`), so a link whose jitter
|
||||
/// genuinely needs more buffer than the picture is away keeps its buffer and the residual is
|
||||
/// reported instead of being taken out of the listener's stream.
|
||||
///
|
||||
/// Not a class and not locked: it is owned outright by the drain thread that observes packets.
|
||||
struct AvSync {
|
||||
/// Smoothing time constant for the measured offset, in ms of consumed audio. Long enough that
|
||||
/// network jitter and a single late datagram do not move it; short enough to track real drift.
|
||||
private static let ewmaTauMS = 2_000
|
||||
/// Offsets inside this band are left alone. Correcting a few ms costs a (crossfaded, but real)
|
||||
/// discontinuity and buys nothing a listener can perceive — detectability for A/V misalignment
|
||||
/// sits an order of magnitude above it. The deadband is what keeps the loop from hunting
|
||||
/// forever around zero, which would be audible in a way the misalignment it chased was not.
|
||||
private static let deadbandMS = 10
|
||||
/// Observations folded before the first correction is offered. The offset is derived from a
|
||||
/// clock skew estimate and a video figure that both need a moment to settle after connect;
|
||||
/// acting on the first sample would chase the handshake, not the stream.
|
||||
private static let minObservations = 100
|
||||
/// An offset larger than this is not believed. A wall-clock step, a paused host, or a stale
|
||||
/// video figure can all produce an enormous apparent misalignment, and steering the ring by it
|
||||
/// would empty or overfill it outright. Beyond this the loop reports and waits rather than acts.
|
||||
private static let saneLimitMS = 1_000
|
||||
/// The protocol's frame, in ms — the EWMA is weighted by it so the time constant means the
|
||||
/// same thing however often the caller observes.
|
||||
private static let frameMS = 5
|
||||
|
||||
/// Interleaved samples per millisecond at the negotiated layout (48 × channels).
|
||||
private let perMS: Int
|
||||
/// EWMA of the measured offset in ns. Positive = audio is scheduled to play LATE relative to
|
||||
/// the picture it belongs with.
|
||||
private var offsetAvgNs: Double = 0
|
||||
private var observations = 0
|
||||
/// Set once an observation lands outside `saneLimitMS`, for reporting.
|
||||
private(set) var implausible = false
|
||||
|
||||
/// `channels` is the negotiated interleaved channel count (2/6/8).
|
||||
init(channels: Int) {
|
||||
perMS = 48 * max(channels, 1)
|
||||
}
|
||||
|
||||
/// One measurement handed to `observe`. Every field is in the units its source already
|
||||
/// produces, so no caller has to do clock arithmetic to use it correctly.
|
||||
struct Observation {
|
||||
/// The host capture timestamp carried by the audio frame being queued (host clock).
|
||||
let ptsNs: UInt64
|
||||
/// Local `CLOCK_REALTIME` now — the same basis `LatencyMeter` stamps video in.
|
||||
let nowLocalNs: Int64
|
||||
/// Host clock minus client clock, from the skew handshake (`clockOffsetNs`).
|
||||
///
|
||||
/// It very nearly CANCELS: the video figure this is differenced against was computed with
|
||||
/// the same offset and the same sign, so as long as both terms use one value the skew
|
||||
/// drops out of the result entirely. That is what makes the connect-time offset good
|
||||
/// enough here even though the absolute legs would prefer a re-synced one.
|
||||
let clockOffsetNs: Int64
|
||||
/// How much audio is already queued AHEAD of this frame, in interleaved samples —
|
||||
/// everything that must play before it does.
|
||||
let bufferedAhead: Int
|
||||
/// The video plane's current end-to-end figure in ns: `displayed + clockOffset − pts`, as
|
||||
/// `LatencyMeter` already computes it per presented frame. `nil` while nothing has reached
|
||||
/// the glass recently — no reference, no correction.
|
||||
let videoE2eNs: Int64?
|
||||
}
|
||||
|
||||
/// Fold one measurement. Returns the smoothed offset in ns once there is enough evidence to
|
||||
/// believe it (positive = audio late), or `nil` while still settling.
|
||||
///
|
||||
/// Rejecting the implausible rather than clamping it is deliberate: a wall-clock step or a
|
||||
/// stale video figure produces a huge apparent offset, and a clamped-but-wrong value would be
|
||||
/// acted on as though it were a small real one.
|
||||
@discardableResult
|
||||
mutating func observe(_ o: Observation) -> Int64? {
|
||||
// No frame on the glass yet ⇒ no reference to align against, so nothing to say.
|
||||
guard let videoE2eNs = o.videoE2eNs else { return nil }
|
||||
// When this frame's samples will actually reach the speaker, expressed in the host's
|
||||
// capture clock — the same clock, and the same shape, as the video figure it is compared
|
||||
// against.
|
||||
let bufferedNs = Int64(o.bufferedAhead / max(perMS, 1)) * 1_000_000
|
||||
// Overflow-reporting arithmetic, NOT the wrapping `&+`/`&-` the meters use. Every term is
|
||||
// a nanosecond count on the same epoch (~1.8e18), so the DIFFERENCE is tiny while the
|
||||
// operands sit within a factor of five of `Int64.max` — and a garbage `pts_ns` would wrap
|
||||
// a nonsense value round into a small, plausible-looking offset. This loop's entire
|
||||
// defence is that it can tell nonsense from a real misalignment, so an overflow takes the
|
||||
// same exit the sanity limit does rather than being silently believed.
|
||||
let (playAtLocal, o1) = o.nowLocalNs.addingReportingOverflow(bufferedNs)
|
||||
let (playAtHost, o2) = playAtLocal.addingReportingOverflow(o.clockOffsetNs)
|
||||
let (audioE2eNs, o3) = playAtHost.subtractingReportingOverflow(Int64(bitPattern: o.ptsNs))
|
||||
let (offsetNs, o4) = audioE2eNs.subtractingReportingOverflow(videoE2eNs)
|
||||
guard !o1, !o2, !o3, !o4, abs(offsetNs) <= Int64(Self.saneLimitMS) * 1_000_000 else {
|
||||
implausible = true
|
||||
return nil
|
||||
}
|
||||
implausible = false
|
||||
|
||||
let alpha = min(1.0, Double(Self.frameMS) / Double(Self.ewmaTauMS))
|
||||
if observations == 0 {
|
||||
offsetAvgNs = Double(offsetNs)
|
||||
} else {
|
||||
offsetAvgNs += (Double(offsetNs) - offsetAvgNs) * alpha
|
||||
}
|
||||
observations += 1
|
||||
return settled ? Int64(offsetAvgNs) : nil
|
||||
}
|
||||
|
||||
/// Enough evidence folded to act on.
|
||||
var settled: Bool { observations >= Self.minObservations }
|
||||
|
||||
/// The smoothed offset in ms (positive = audio late), for the HUD. Reported as soon as it is
|
||||
/// measured, including while still settling — a number the operator can watch converge is more
|
||||
/// useful than a blank that hides whether the loop is working at all.
|
||||
var offsetMS: Int { Int(offsetAvgNs / 1_000_000) }
|
||||
|
||||
/// The ring depth that would place audio with the picture, given where the ring is now.
|
||||
/// `nil` while unsettled or inside the deadband — the caller then leaves the ring alone.
|
||||
///
|
||||
/// Audio late (offset > 0) means there is too much queued: aim shallower. Audio early means
|
||||
/// aim deeper.
|
||||
func desiredDepth(currentDepth: Int) -> Int? {
|
||||
guard settled else { return nil }
|
||||
let offsetMs = offsetAvgNs / 1_000_000
|
||||
guard abs(offsetMs) >= Double(Self.deadbandMS) else { return nil }
|
||||
let delta = Int(offsetMs * Double(perMS))
|
||||
return max(0, currentDepth - delta)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ public final class SessionAudio {
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
private var ring: AudioRing?
|
||||
/// The video plane's end-to-end meter (capture→on-glass), if the owner wired one — the
|
||||
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
|
||||
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
|
||||
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
|
||||
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
|
||||
/// like `ring`; the meter itself is internally locked and read from the drain thread.
|
||||
private var videoLatency: LatencyMeter?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
@@ -91,9 +98,16 @@ public final class SessionAudio {
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
///
|
||||
/// `videoLatency` is the session's END-TO-END latency meter (capture→on-glass). Pass it to arm
|
||||
/// A/V sync: it is the only thing that tells the audio plane where the picture actually is, and
|
||||
/// without it the ring keeps today's free-running behaviour. Omit it for a playback-only or
|
||||
/// stage-1 session, where no such figure is measured.
|
||||
public func start(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
|
||||
videoLatency: LatencyMeter? = nil
|
||||
) {
|
||||
self.videoLatency = videoLatency
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
@@ -305,6 +319,31 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats
|
||||
|
||||
/// The playback plane's two latency numbers, for the stats overlay.
|
||||
///
|
||||
/// Both, never just the depth: a deep ring on a jittery link is CORRECT behaviour — the
|
||||
/// adaptive floor put it there because the link kept starving — and only the offset separates
|
||||
/// that from a ring that is simply holding audio late. Before this pair existed the plane
|
||||
/// published nothing any surface could render (depth and target lived in a periodic log line),
|
||||
/// and a field investigation into "the audio delay seems way too high" ran all the way to its
|
||||
/// conclusion without either number.
|
||||
public struct Stats: Sendable {
|
||||
/// Decoded audio queued ahead of the speaker (ms).
|
||||
public let bufferMS: Int
|
||||
/// The A/V sync loop's smoothed offset (ms): positive = audio playing BEHIND the picture.
|
||||
/// `0` before the loop has evidence, with sync unwired, or genuinely aligned.
|
||||
public let avOffsetMS: Int
|
||||
}
|
||||
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
|
||||
/// the ring's own numbers are taken under its lock, so they describe one instant).
|
||||
public var stats: Stats? {
|
||||
guard let s = ring?.stats else { return nil }
|
||||
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
@@ -401,9 +440,25 @@ public final class SessionAudio {
|
||||
}
|
||||
drainStarted = true
|
||||
stateLock.unlock()
|
||||
// A/V sync. This thread is the only place that holds all three ingredients at once: the
|
||||
// packet's host capture `ptsNs`, the ring depth, and the video plane's end-to-end figure.
|
||||
// `ptsNs` was decoded into `AudioPCM` and then dropped on the floor right here for the
|
||||
// plane's entire existence, which is why audio ran at whatever depth its jitter ring
|
||||
// happened to settle at and nothing ever placed it against the picture.
|
||||
//
|
||||
// The escape hatch mirrors the Rust clients': a field regression in a loop that steers
|
||||
// PLAYBACK should be bisectable without a rebuild. macOS honours it from the environment;
|
||||
// elsewhere it simply never trips, which is the same as today's behaviour.
|
||||
let syncEnabled = !["1", "true"].contains(
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_NO_AV_SYNC"] ?? "")
|
||||
// nil disarms the loop entirely — no reference, no correction (see `videoLatency`).
|
||||
let videoLatency = syncEnabled ? self.videoLatency : nil
|
||||
if !syncEnabled { log.info("A/V sync disabled by PUNKTFUNK_NO_AV_SYNC") }
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
var av = AvSync(channels: channels)
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -417,6 +472,25 @@ public final class SessionAudio {
|
||||
return false // session closed
|
||||
}
|
||||
guard let pcm, pcm.frameCount > 0 else { return true }
|
||||
// Place this frame against the picture it belongs with BEFORE queueing it: the
|
||||
// depth read here is everything that must still play first, which is exactly what
|
||||
// delays it. Skipped wholesale when no meter was wired, so an un-armed session
|
||||
// does not even read the ring.
|
||||
if let videoLatency {
|
||||
let depth = ring.bufferedSamples
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
// Half a second of tolerance on the reference: long enough to ride out a
|
||||
// stalled or hitching present path, short enough that a backgrounded session
|
||||
// (video decode dropped, audio still playing) stops steering almost at once.
|
||||
av.observe(AvSync.Observation(
|
||||
ptsNs: pcm.ptsNs, nowLocalNs: nowNs,
|
||||
clockOffsetNs: connection.clockOffsetNs, bufferedAhead: depth,
|
||||
videoE2eNs: videoLatency.latestSample(asOfNs: nowNs, maxAgeMs: 500)))
|
||||
ring.setSyncTarget(av.desiredDepth(currentDepth: depth))
|
||||
ring.noteAvOffset(av.offsetMS)
|
||||
}
|
||||
pcm.samples.withUnsafeBufferPointer { p in
|
||||
if let base = p.baseAddress {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
@@ -430,7 +504,7 @@ public final class SessionAudio {
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
@@ -483,19 +557,52 @@ public final class SessionAudio {
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable (logged) — keep the session audible on the plain playback
|
||||
// engine rather than playing through an idle voice processor.
|
||||
|
||||
// The capture side must be PULLED, and only the render graph pulls anything. An input
|
||||
// node carrying nothing but a tap is not part of that graph, so on the combined engine
|
||||
// nobody drove it: the IO unit came up (the recording indicator lit for a beat, then went
|
||||
// out as the input went idle) and NOT ONE BUFFER ever reached the tap — no error, no
|
||||
// failed start, just a session that quietly sent no microphone at all. Routing the input
|
||||
// through a silent sink puts it in the graph, which is what Apple's own voice-processing
|
||||
// sample does. The split path never needed it: a capture-only engine has the input node
|
||||
// AS its graph, so it is pulled by definition — which is why this only broke when the
|
||||
// combined topology became the default.
|
||||
//
|
||||
// `outputVolume = 0` on the sink: the mic has to reach the graph, never the speaker. At
|
||||
// any audible volume this is a microphone wired straight to the earpiece.
|
||||
let micSink = AVAudioMixerNode()
|
||||
engine.attach(micSink)
|
||||
micSink.outputVolume = 0
|
||||
engine.connect(engine.inputNode, to: micSink, format: nil)
|
||||
engine.connect(micSink, to: engine.mainMixerNode, format: nil)
|
||||
|
||||
// BEFORE the tap reads a format. Enabling voice processing swaps the engine's IO unit
|
||||
// for the VPIO one and renegotiates its formats, and until the engine is prepared the
|
||||
// input node can still report the pre-swap state — 0 Hz / 0 channels included, which
|
||||
// `installMicTap` (correctly) refuses as "no usable input device". Preparing first means
|
||||
// the chain is built against what the voice processor will actually emit.
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable on the VOICE-PROCESSED engine (logged). The mic outranks the
|
||||
// echo cancellation, so fall back to the split path — its own engine, no voice
|
||||
// processor, the topology that shipped before AEC existed — rather than dropping the
|
||||
// uplink for the rest of the session. (The sibling failure above, where the voice
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
@@ -533,8 +640,16 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
|
||||
// Prepared before the tap reads a format, for the same reason the combined path does it:
|
||||
// a node that hasn't been through `prepare()` can still report the pre-negotiation
|
||||
// format (0 Hz / 0 channels on a device that is perfectly fine), which reads downstream
|
||||
// as "no microphone".
|
||||
engine.prepare()
|
||||
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
|
||||
log.error("mic uplink unavailable — this session sends no microphone audio")
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// On-disk cache for library cover art.
|
||||
//
|
||||
// Posters are the bulk of what the library screen transfers and they essentially never change, so
|
||||
// re-fetching them on every visit is pure waste — the Windows client has cached them on disk for
|
||||
// this reason and Apple did not. It matters more now that host art rides `MgmtTransport`: a cache
|
||||
// hit costs no connection at all.
|
||||
//
|
||||
// Lives in the CACHES directory on purpose: every byte here is re-derivable from the host, so the
|
||||
// system is welcome to evict it under storage pressure. Entries are keyed by the SHA-256 of the
|
||||
// absolute URL, which covers both host-proxy paths and store CDN URLs without either colliding.
|
||||
//
|
||||
// Deliberately free of any Network.framework / PunktfunkCore dependency, so it can be unit-tested
|
||||
// against a temporary directory.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// A size- and age-bounded blob cache. An actor so disk work stays off whichever thread the
|
||||
/// SwiftUI poster view happens to be on, and so pruning can never race a write.
|
||||
actor ArtCache {
|
||||
private let directory: URL
|
||||
private let maxBytes: Int
|
||||
private let maxAge: TimeInterval
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
/// `directory` is created on demand. Defaults: 128 MB — a 200-title library of 600×900
|
||||
/// capsules lands far under that — and 30 days, which only matters for art a host later
|
||||
/// replaces.
|
||||
init(directory: URL, maxBytes: Int = 128 * 1024 * 1024, maxAge: TimeInterval = 30 * 24 * 3600) {
|
||||
self.directory = directory
|
||||
self.maxBytes = maxBytes
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
/// The app's standard location, or nil if the caches directory is unavailable (in which case
|
||||
/// callers simply run without a cache rather than failing).
|
||||
static func standard() -> ArtCache? {
|
||||
guard let caches = FileManager.default.urls(
|
||||
for: .cachesDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
return ArtCache(directory: caches.appendingPathComponent("PunktfunkArt", isDirectory: true))
|
||||
}
|
||||
|
||||
func data(for url: URL) -> Data? {
|
||||
let file = path(for: url)
|
||||
guard let data = try? Data(contentsOf: file) else { return nil }
|
||||
// Age out stale art rather than serving it forever.
|
||||
if let modified = modificationDate(of: file), Date().timeIntervalSince(modified) > maxAge {
|
||||
try? fileManager.removeItem(at: file)
|
||||
return nil
|
||||
}
|
||||
// Touch, so eviction can order by last USE rather than last write.
|
||||
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
|
||||
return data
|
||||
}
|
||||
|
||||
func store(_ data: Data, for url: URL) {
|
||||
// An empty body is not art, and a `data:` URL is already inline — caching either is a
|
||||
// pure loss.
|
||||
guard !data.isEmpty, url.scheme?.lowercased() != "data" else { return }
|
||||
do {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: path(for: url), options: .atomic)
|
||||
} catch {
|
||||
return // a cache that can't write is a slow cache, not a broken app
|
||||
}
|
||||
prune()
|
||||
}
|
||||
|
||||
/// Drop the oldest entries until the directory fits the budget. Also removes anything past
|
||||
/// `maxAge` so a cache that is under budget still doesn't hoard stale art forever.
|
||||
func prune() {
|
||||
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)
|
||||
else { return }
|
||||
|
||||
var files: [(url: URL, date: Date, size: Int)] = []
|
||||
var total = 0
|
||||
let now = Date()
|
||||
for entry in entries {
|
||||
let values = try? entry.resourceValues(forKeys: Set(keys))
|
||||
let date = values?.contentModificationDate ?? .distantPast
|
||||
let size = values?.fileSize ?? 0
|
||||
if now.timeIntervalSince(date) > maxAge {
|
||||
try? fileManager.removeItem(at: entry)
|
||||
continue
|
||||
}
|
||||
files.append((entry, date, size))
|
||||
total += size
|
||||
}
|
||||
guard total > maxBytes else { return }
|
||||
|
||||
// Oldest first — `data(for:)` touches on read, so this is least-recently-USED.
|
||||
for file in files.sorted(by: { $0.date < $1.date }) {
|
||||
guard total > maxBytes else { break }
|
||||
try? fileManager.removeItem(at: file.url)
|
||||
total -= file.size
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the cache — for a "clear cached data" affordance, and for tests.
|
||||
func clear() {
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
|
||||
private func path(for url: URL) -> URL {
|
||||
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
||||
let name = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
private func modificationDate(of file: URL) -> Date? {
|
||||
(try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,6 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls")
|
||||
|
||||
enum ClientTLS {
|
||||
enum TLSError: LocalizedError {
|
||||
@@ -134,62 +131,8 @@ enum ClientTLS {
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
|
||||
/// already trusts) and presents the client identity for the mTLS client-cert challenge — but ONLY
|
||||
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
|
||||
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
|
||||
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
|
||||
/// evaluation and no client cert — exactly what `URLSession.shared` would have done. Without the
|
||||
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
|
||||
/// the host's) and the client identity would leak to servers that didn't ask for it.
|
||||
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
|
||||
private let identity: SecIdentity
|
||||
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
|
||||
private let host: String
|
||||
private let port: Int
|
||||
|
||||
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
|
||||
self.identity = identity
|
||||
self.pinnedHostFingerprint = pinnedHostFingerprint
|
||||
self.host = host
|
||||
self.port = Int(port)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let space = challenge.protectionSpace
|
||||
guard space.host == host, space.port == port else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
switch space.authenticationMethod {
|
||||
case NSURLAuthenticationMethodServerTrust:
|
||||
// Pin the host cert by fingerprint — the host is self-signed (the client trusts it the
|
||||
// same way the QUIC session does). No pin yet (TOFU) → accept the presented leaf.
|
||||
guard let trust = space.serverTrust,
|
||||
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
|
||||
else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
let der = SecCertificateCopyData(leaf) as Data
|
||||
let fp = Data(SHA256.hash(data: der))
|
||||
if let pinned = pinnedHostFingerprint, pinned != fp {
|
||||
tlsLog.warning("library: host cert fingerprint mismatch — refusing")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
|
||||
case NSURLAuthenticationMethodClientCertificate:
|
||||
completionHandler(.useCredential,
|
||||
URLCredential(identity: identity, certificates: nil, persistence: .forSession))
|
||||
|
||||
default:
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The URLSession pinning delegate that used to live here is gone: the management API now speaks
|
||||
// over `MgmtTransport` (Network.framework), which states the same trust rule in a
|
||||
// `sec_protocol_options_set_verify_block` and — unlike the URL loading system — is not subject to
|
||||
// App Transport Security. That is what lets ATS stay ON for the cover-art CDN fetches, which are
|
||||
// the only URLSession traffic left in the app. See MgmtTransport.swift for the full rationale.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Minimal HTTP/1.1 response parsing for `MgmtTransport`.
|
||||
//
|
||||
// We speak HTTP ourselves because the management API has to be reached OUTSIDE the URL loading
|
||||
// system: App Transport Security applies to URLSession and cannot be relaxed for the arbitrary,
|
||||
// user-supplied addresses a punktfunk host lives at (see MgmtTransport for the full story). What
|
||||
// we need is GETs against one host, so this covers exactly that and nothing more — no redirects,
|
||||
// no request bodies, no content negotiation.
|
||||
//
|
||||
// It does need to find where a response ENDS without waiting for the peer to hang up, because the
|
||||
// connection is reused across a grid's worth of poster fetches (`messageLength`). Both framings
|
||||
// hyper emits are handled: `Content-Length`, and `Transfer-Encoding: chunked` for the art proxy.
|
||||
//
|
||||
// Deliberately free of any Network.framework / PunktfunkCore dependency: pure bytes-in,
|
||||
// value-out, so it can be unit-tested (and typechecked) on its own.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A parsed HTTP/1.1 response. `headers` keys are lowercased, so lookups are case-insensitive the
|
||||
/// way the grammar requires.
|
||||
struct HTTPResponse: Sendable {
|
||||
let status: Int
|
||||
let headers: [String: String]
|
||||
let body: Data
|
||||
|
||||
func header(_ name: String) -> String? { headers[name.lowercased()] }
|
||||
|
||||
/// Did the peer ask to close? HTTP/1.1 keeps the connection open unless it says otherwise.
|
||||
var wantsClose: Bool {
|
||||
header("connection")?.lowercased().contains("close") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPParseError: Error, Sendable {
|
||||
/// The header block never terminated, or the body is shorter than `Content-Length` promised —
|
||||
/// i.e. the peer hung up mid-response. Never surface a truncated body as success: a clipped
|
||||
/// JSON array would read as "this host has no games" rather than as the failure it is.
|
||||
case truncated
|
||||
case malformedStatusLine
|
||||
case malformedHeader
|
||||
case malformedChunk
|
||||
}
|
||||
|
||||
enum HTTPResponseParser {
|
||||
/// Byte length of the first complete response in `raw`, or nil when more bytes are needed.
|
||||
///
|
||||
/// Also nil when the response carries no framing header at all, since then the body runs
|
||||
/// until the peer closes and has no knowable length — a connection that answers that way
|
||||
/// cannot be reused.
|
||||
static func messageLength(in raw: Data) throws -> Int? {
|
||||
let b = [UInt8](raw)
|
||||
guard let head = try parseHead(b) else { return nil }
|
||||
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||
return try chunkedEnd(b, from: head.bodyStart)
|
||||
}
|
||||
if let field = head.headers["content-length"] {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
let end = head.bodyStart + length
|
||||
return b.count >= end ? end : nil
|
||||
}
|
||||
return nil // framed by connection close
|
||||
}
|
||||
|
||||
/// Parse one complete response. `raw` must hold exactly one message (use `messageLength` to
|
||||
/// slice it) or, for a close-framed response, everything read up to EOF.
|
||||
static func parse(_ raw: Data) throws -> HTTPResponse {
|
||||
let b = [UInt8](raw)
|
||||
guard let head = try parseHead(b) else { throw HTTPParseError.truncated }
|
||||
let rest = Data(b[head.bodyStart...])
|
||||
let body: Data
|
||||
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||
body = try decodeChunked(rest)
|
||||
} else if let field = head.headers["content-length"] {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
guard rest.count >= length else { throw HTTPParseError.truncated }
|
||||
body = rest.prefix(length)
|
||||
} else {
|
||||
body = rest // framed by connection close: what we read is what there is
|
||||
}
|
||||
return HTTPResponse(status: head.status, headers: head.headers, body: body)
|
||||
}
|
||||
|
||||
private struct Head {
|
||||
let status: Int
|
||||
let headers: [String: String]
|
||||
/// Offset of the first body byte (just past the CRLFCRLF).
|
||||
let bodyStart: Int
|
||||
}
|
||||
|
||||
/// Status line + header block, or nil if the block hasn't fully arrived.
|
||||
private static func parseHead(_ b: [UInt8]) throws -> Head? {
|
||||
guard let headEnd = findHeaderEnd(b) else { return nil }
|
||||
let text = String(decoding: b[0..<headEnd], as: UTF8.self)
|
||||
var lines = text.components(separatedBy: "\r\n")
|
||||
guard !lines.isEmpty else { throw HTTPParseError.malformedStatusLine }
|
||||
|
||||
// "HTTP/1.1 200 OK" — the reason phrase is optional and ignored.
|
||||
let statusLine = lines.removeFirst().split(separator: " ", maxSplits: 2,
|
||||
omittingEmptySubsequences: false)
|
||||
guard statusLine.count >= 2, statusLine[0].hasPrefix("HTTP/"),
|
||||
let status = Int(statusLine[1])
|
||||
else { throw HTTPParseError.malformedStatusLine }
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
for line in lines where !line.isEmpty {
|
||||
// A leading space/tab marks an obsolete folded continuation line. Nothing we talk to
|
||||
// emits them, and silently mis-parsing one as a field is worse than refusing it.
|
||||
guard !line.hasPrefix(" "), !line.hasPrefix("\t"),
|
||||
let colon = line.firstIndex(of: ":")
|
||||
else { throw HTTPParseError.malformedHeader }
|
||||
let name = String(line[line.startIndex..<colon]).lowercased()
|
||||
let value = String(line[line.index(after: colon)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
// Repeated fields join with ", " per RFC 9110; none of ours repeat, but dropping one
|
||||
// silently would be a lie.
|
||||
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
||||
}
|
||||
return Head(status: status, headers: headers, bodyStart: headEnd + 4)
|
||||
}
|
||||
|
||||
/// Index just past the CRLFCRLF that ends the header block.
|
||||
private static func findHeaderEnd(_ b: [UInt8]) -> Int? {
|
||||
guard b.count >= 4 else { return nil }
|
||||
for i in 0...(b.count - 4) where b[i] == 0x0D && b[i + 1] == 0x0A
|
||||
&& b[i + 2] == 0x0D && b[i + 3] == 0x0A {
|
||||
return i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Offset just past a complete chunked body (terminal chunk plus any trailers), or nil if it
|
||||
/// hasn't all arrived.
|
||||
private static func chunkedEnd(_ b: [UInt8], from start: Int) throws -> Int? {
|
||||
var i = start
|
||||
while true {
|
||||
guard let lineEnd = findCRLF(b, from: i) else { return nil }
|
||||
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
||||
i = lineEnd + 2
|
||||
if size == 0 {
|
||||
// Terminal chunk. Trailers (if any) run to the next empty line.
|
||||
var j = i
|
||||
while true {
|
||||
guard let end = findCRLF(b, from: j) else { return nil }
|
||||
if end == j { return j + 2 }
|
||||
j = end + 2
|
||||
}
|
||||
}
|
||||
guard i + size + 2 <= b.count else { return nil }
|
||||
i += size + 2 // payload plus its trailing CRLF
|
||||
}
|
||||
}
|
||||
|
||||
/// `Transfer-Encoding: chunked` decoding. hyper streams the art proxy this way, so this is a
|
||||
/// live path, not defensive dead code.
|
||||
static func decodeChunked(_ data: Data) throws -> Data {
|
||||
let b = [UInt8](data)
|
||||
var i = 0
|
||||
var out = Data()
|
||||
while true {
|
||||
guard let lineEnd = findCRLF(b, from: i) else { throw HTTPParseError.malformedChunk }
|
||||
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
||||
i = lineEnd + 2
|
||||
if size == 0 { return out } // terminal chunk; trailers are ignored
|
||||
guard i + size <= b.count else { throw HTTPParseError.malformedChunk }
|
||||
out.append(contentsOf: b[i..<(i + size)])
|
||||
i += size
|
||||
guard i + 1 < b.count, b[i] == 0x0D, b[i + 1] == 0x0A else {
|
||||
throw HTTPParseError.malformedChunk
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
/// "1a" or "1a;ext=value" → 26. Nil if it isn't a hex size.
|
||||
private static func chunkSize(_ b: [UInt8], _ from: Int, _ to: Int) -> Int? {
|
||||
let field = String(decoding: b[from..<to], as: UTF8.self)
|
||||
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let size = Int(field, radix: 16), size >= 0 else { return nil }
|
||||
return size
|
||||
}
|
||||
|
||||
private static func findCRLF(_ b: [UInt8], from: Int) -> Int? {
|
||||
guard from >= 0, b.count >= 2 else { return nil }
|
||||
var i = from
|
||||
while i + 1 < b.count {
|
||||
if b[i] == 0x0D && b[i + 1] == 0x0A { return i }
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ public extension Array where Element == GameEntry {
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
public enum LibraryError: LocalizedError {
|
||||
case unauthorized
|
||||
/// The host's certificate didn't hash to the fingerprint pinned at pairing — an impostor, or
|
||||
/// a host reinstalled/re-keyed since. Distinct from `unreachable` because the remedy is
|
||||
/// completely different: re-pair, don't go hunting the network.
|
||||
case pinMismatch
|
||||
case http(Int)
|
||||
case unreachable(String)
|
||||
|
||||
@@ -91,12 +95,22 @@ public enum LibraryError: LocalizedError {
|
||||
case .unauthorized:
|
||||
return "The host didn't recognize this device. Pair with the host first — it "
|
||||
+ "authorizes paired clients by their certificate (no token needed)."
|
||||
case .pinMismatch:
|
||||
return "The host's certificate doesn't match the one this device paired with. "
|
||||
+ "If the host was reinstalled, forget it here and pair again."
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||
+ "so check the host is updated and reachable (a host pinned to "
|
||||
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
|
||||
// The library rides a DIFFERENT port than the stream (the management API, 47990 by
|
||||
// default; the stream is QUIC on 9777), so it can fail while streaming to the same
|
||||
// host works perfectly — say that first, because the opposite assumption has sent
|
||||
// more than one person hunting the wrong layer. Opening that URL in a browser is the
|
||||
// fastest way to tell "port unreachable" apart from anything client-side.
|
||||
return "Couldn't reach the host's management API: \(why). The library uses a "
|
||||
+ "different port than the stream (47990 by default), so streaming can work "
|
||||
+ "while this doesn't. Check that port is reachable from this device, and that "
|
||||
+ "the host isn't pinned to `--mgmt-bind 127.0.0.1`, which serves it to the "
|
||||
+ "host itself only."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,47 +129,66 @@ public enum LibraryClient {
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) async throws -> [GameEntry] {
|
||||
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
|
||||
throw LibraryError.unreachable("invalid host address")
|
||||
}
|
||||
let identity: SecIdentity
|
||||
do {
|
||||
identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let req = URLRequest(url: url, timeoutInterval: 10)
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LibraryError.unreachable("not an HTTP response")
|
||||
}
|
||||
switch http.statusCode {
|
||||
guard let base = URL(string: "\(baseURL(address: address, port: port))/api/v1/library")
|
||||
else { throw LibraryError.unreachable("invalid host address") }
|
||||
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let response = try await send(
|
||||
path: "/api/v1/library", address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint)
|
||||
switch response.status {
|
||||
case 200:
|
||||
var games = try JSONDecoder().decode([GameEntry].self, from: data)
|
||||
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
|
||||
// see the host's `library::steam_art`) so they work the same regardless of which
|
||||
var games = try JSONDecoder().decode([GameEntry].self, from: response.body)
|
||||
// Steam art comes back as host-relative proxy paths (`/api/v1/library/art/...`, see
|
||||
// the host's `library::steam_art`) so they work the same regardless of which
|
||||
// interface/port the client reached the host on. Resolve them against THIS host now,
|
||||
// so every other consumer just sees ordinary absolute URLs.
|
||||
let base = url
|
||||
for i in games.indices {
|
||||
games[i].art = games[i].art.resolved(against: base)
|
||||
}
|
||||
return games
|
||||
case 401:
|
||||
// 403 joins 401 here: both are the host declining this certificate, and the remedy the
|
||||
// user needs is the same one.
|
||||
case 401, 403:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
throw LibraryError.http(http.statusCode)
|
||||
throw LibraryError.http(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// `https://addr:port`, IPv6 literals bracketed — the mirror of the Rust client's `base_url`.
|
||||
static func baseURL(address: String, port: UInt16) -> String {
|
||||
let bare = address.hasPrefix("[") && address.hasSuffix("]")
|
||||
? String(address.dropFirst().dropLast()) : address
|
||||
return bare.contains(":") ? "https://[\(bare)]:\(port)" : "https://\(bare):\(port)"
|
||||
}
|
||||
|
||||
/// Build the paired identity, restating any keychain failure in the UI's vocabulary.
|
||||
static func clientIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
|
||||
do {
|
||||
return try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET against the host, with transport failures mapped onto `LibraryError`.
|
||||
static func send(
|
||||
path: String, address: String, port: UInt16,
|
||||
identity: SecIdentity, hostFingerprint: Data?
|
||||
) async throws -> HTTPResponse {
|
||||
do {
|
||||
return try await MgmtTransport.get(
|
||||
host: address, port: port, path: path,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
} catch MgmtTransportError.pinMismatch {
|
||||
throw LibraryError.pinMismatch
|
||||
} catch MgmtTransportError.timedOut {
|
||||
throw LibraryError.unreachable("timed out")
|
||||
} catch let error as MgmtTransportError {
|
||||
throw LibraryError.unreachable(String(describing: error))
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,23 +212,77 @@ extension Artwork {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images — the same
|
||||
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
|
||||
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
|
||||
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
|
||||
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
|
||||
/// trust + no client cert for any other origin (an external CDN URL).
|
||||
public enum LibraryImageLoader {
|
||||
public static func session(
|
||||
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
|
||||
///
|
||||
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
|
||||
/// (`/api/v1/library/art/...`, resolved to absolute URLs against this host) and public store CDN
|
||||
/// URLs carried verbatim on custom/GOG/Heroic entries. Host URLs go over [`MgmtTransport`] with
|
||||
/// the paired identity and the pinned fingerprint — outside the URL loading system, so App
|
||||
/// Transport Security can stay ON app-wide. Every other origin keeps ordinary `URLSession` with
|
||||
/// full system trust evaluation and no client certificate, which is exactly what it should get.
|
||||
///
|
||||
/// Posters are cached on disk (`ArtCache`), so a second visit to a library costs no network at
|
||||
/// all — and the connections behind a first visit are pooled and kept alive rather than paying a
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
private let address: String
|
||||
private let port: UInt16
|
||||
private let identity: SecIdentity
|
||||
private let hostFingerprint: Data?
|
||||
/// Third-party origins only. No delegate: these are ordinary public HTTPS URLs and get the
|
||||
/// system's normal certificate validation.
|
||||
private let cdn = URLSession(configuration: .default)
|
||||
/// nil when the caches directory is unavailable — then we simply always fetch.
|
||||
private let cache = ArtCache.standard()
|
||||
|
||||
public init(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) throws -> URLSession {
|
||||
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
||||
) throws {
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.identity = try LibraryClient.clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
self.hostFingerprint = hostFingerprint
|
||||
}
|
||||
|
||||
public func data(for url: URL) async throws -> Data {
|
||||
if let cache, let cached = await cache.data(for: url) { return cached }
|
||||
let fetched = try await fetch(url)
|
||||
if let cache { await cache.store(fetched, for: url) }
|
||||
return fetched
|
||||
}
|
||||
|
||||
/// Release this host's pooled connections — call when the library screen goes away, so we
|
||||
/// don't sit on open TLS sockets the user is finished with.
|
||||
public func close() async {
|
||||
await MgmtConnectionPool.shared.closeAll(
|
||||
matching: "\(MgmtTransport.unbracketed(address)):\(port):")
|
||||
}
|
||||
|
||||
private func fetch(_ url: URL) async throws -> Data {
|
||||
guard isHostOrigin(url) else { return try await cdn.data(from: url).0 }
|
||||
var path = url.path.isEmpty ? "/" : url.path
|
||||
if let query = url.query { path += "?\(query)" }
|
||||
let response = try await LibraryClient.send(
|
||||
path: path, address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint)
|
||||
guard response.status == 200 else { throw LibraryError.http(response.status) }
|
||||
return response.body
|
||||
}
|
||||
|
||||
/// Does this URL point at the host's own art proxy? Compared on host + port rather than a
|
||||
/// string prefix, so a differently-spelled but equivalent URL still takes the pinned path.
|
||||
private func isHostOrigin(_ url: URL) -> Bool {
|
||||
guard let host = url.host else { return false }
|
||||
let bare = address.hasPrefix("[") && address.hasSuffix("]")
|
||||
? String(address.dropFirst().dropLast()) : address
|
||||
let scheme = url.scheme?.lowercased()
|
||||
return host.caseInsensitiveCompare(bare) == .orderedSame
|
||||
&& (url.port ?? (scheme == "http" ? 80 : 443)) == Int(port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// HTTPS transport for the host's management REST API, built on Network.framework rather than
|
||||
// URLSession.
|
||||
//
|
||||
// WHY NOT URLSession. App Transport Security governs the URL loading system, and its default
|
||||
// policy exempts only "local" destinations — `.local` names, unqualified names, and RFC1918 /
|
||||
// link-local IP literals. Everything else must present a certificate that passes system trust
|
||||
// evaluation. A punktfunk host is self-signed by construction (there is no CA that could vouch
|
||||
// for a box on someone's LAN), so the library worked at 192.168.x and died at the TLS layer on
|
||||
// every other address: a Tailscale peer (100.64/10 is CGNAT, NOT RFC1918), a WireGuard peer, or a
|
||||
// public IP. No ATS key can express "any address the user typed" — the exception keys are
|
||||
// domain-scoped — so the only ways out were disabling ATS app-wide (which also drops the TLS
|
||||
// floor and the cleartext block on third-party cover-art fetches, the one surface we did NOT want
|
||||
// to open) or leaving the URL loading system for this one origin. This is that second option.
|
||||
//
|
||||
// Network.framework is not subject to ATS, and `sec_protocol_options_set_verify_block` lets us
|
||||
// state the trust rule we actually mean: the leaf certificate must hash to the fingerprint the
|
||||
// user pinned during PIN pairing. That is a stronger check than CA trust here, not a weaker one,
|
||||
// and it is the same rule the QUIC stream plane has always applied via punktfunk-core — which is
|
||||
// precisely why streaming kept working over Tailscale while the library did not.
|
||||
//
|
||||
// Connections are POOLED and kept alive: a library screen fetches one JSON payload and then a
|
||||
// poster per title, and giving each its own TLS handshake was pure latency. `MgmtConnectionPool`
|
||||
// keeps a small number of connections per host, hands them out one request at a time, and makes
|
||||
// callers wait rather than opening an unbounded number.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
enum MgmtTransportError: Error, Sendable {
|
||||
/// The host's certificate did not hash to the pinned fingerprint — an impostor, or a host
|
||||
/// that was reinstalled/re-keyed since pairing.
|
||||
case pinMismatch
|
||||
case connection(String)
|
||||
case timedOut
|
||||
case tooLarge
|
||||
case invalidPort(UInt16)
|
||||
}
|
||||
|
||||
enum MgmtTransport {
|
||||
/// Largest response we will buffer. The host's art proxy serves Steam hero images that run to
|
||||
/// a few MB; anything past this is not a poster and not a library payload.
|
||||
static let maxResponseBytes = 16 * 1024 * 1024
|
||||
|
||||
/// `GET https://host:port/path`, authenticated by mTLS (`identity`) and pinned by
|
||||
/// `pinnedHostFingerprint` (nil = trust-on-first-use, matching the QUIC connect's semantics).
|
||||
///
|
||||
/// Runs over a pooled keep-alive connection. A connection the host has since dropped is
|
||||
/// indistinguishable from a live one until we write to it, so a REUSED connection that fails
|
||||
/// is retried once on a fresh one; a fresh connection that fails is a real error.
|
||||
static func get(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
path: String,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
throw MgmtTransportError.invalidPort(port)
|
||||
}
|
||||
let pin = pinnedHostFingerprint
|
||||
let key = "\(unbracketed(host)):\(port):\(pin.map(hex) ?? "tofu")"
|
||||
var lastError: Error = MgmtTransportError.connection("no attempt made")
|
||||
|
||||
for attempt in 0..<2 {
|
||||
let connection = await MgmtConnectionPool.shared.acquire(key: key) {
|
||||
MgmtConnection(host: unbracketed(host), port: nwPort, identity: identity, pin: pin)
|
||||
}
|
||||
let wasReused = connection.hasServedRequest
|
||||
do {
|
||||
let response = try await connection.perform(path: path, timeout: timeout)
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
return response
|
||||
} catch {
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
lastError = error
|
||||
// Only a reused connection earns a second try, and only once: retrying a fresh
|
||||
// connection would just double every genuine failure's latency.
|
||||
if !wasReused || attempt == 1 { throw error }
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
static func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Saved hosts store bare addresses, but a user who pasted a bracketed IPv6 literal shouldn't
|
||||
/// get an unresolvable endpoint out of it.
|
||||
static func unbracketed(_ host: String) -> String {
|
||||
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
|
||||
return String(host.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
/// A pool of keep-alive connections, at most `maxPerHost` per host. Callers past that wait for one
|
||||
/// to come back rather than opening more — a library grid can ask for dozens of posters at once,
|
||||
/// and answering that with dozens of TLS handshakes is what this exists to prevent.
|
||||
actor MgmtConnectionPool {
|
||||
static let shared = MgmtConnectionPool()
|
||||
|
||||
private var available: [String: [MgmtConnection]] = [:]
|
||||
/// Connections created and not yet closed, per host — the cap this pool enforces.
|
||||
private var live: [String: Int] = [:]
|
||||
private var waiters: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private let maxPerHost = 4
|
||||
|
||||
func acquire(key: String, make: () -> MgmtConnection) async -> MgmtConnection {
|
||||
while true {
|
||||
if var idle = available[key], let connection = idle.popLast() {
|
||||
available[key] = idle
|
||||
if connection.isHealthy { return connection }
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
continue
|
||||
}
|
||||
if (live[key] ?? 0) < maxPerHost {
|
||||
live[key] = (live[key] ?? 0) + 1
|
||||
return make()
|
||||
}
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
waiters[key, default: []].append(continuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Always call this, on success AND on failure: a connection that is never returned leaks a
|
||||
/// slot, and enough leaked slots would hang every later request on the waiter queue.
|
||||
func release(_ connection: MgmtConnection, key: String) {
|
||||
if connection.isHealthy, (available[key]?.count ?? 0) < maxPerHost {
|
||||
available[key, default: []].append(connection)
|
||||
} else {
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
}
|
||||
if var queue = waiters[key], !queue.isEmpty {
|
||||
let next = queue.removeFirst()
|
||||
waiters[key] = queue
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every idle connection for a host — used when a library screen goes away, so we don't
|
||||
/// sit on sockets the user is done with.
|
||||
func closeAll(matching prefix: String) {
|
||||
for (key, connections) in available where key.hasPrefix(prefix) {
|
||||
connections.forEach { $0.close() }
|
||||
live[key] = max(0, (live[key] ?? 0) - connections.count)
|
||||
available[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One TLS connection to a host, serving requests one at a time. The pool guarantees a single
|
||||
/// caller at a time, so there is no request queueing here.
|
||||
///
|
||||
/// Everything mutable is touched only on `queue`, which also runs the connection's callbacks, the
|
||||
/// verify block and the timeout — so the state below needs no locking and two callbacks can never
|
||||
/// race to resume the same continuation.
|
||||
final class MgmtConnection: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.mgmt-connection")
|
||||
private let connection: NWConnection
|
||||
private let host: String
|
||||
private let port: UInt16
|
||||
|
||||
private enum Phase { case idle, connecting, ready, dead }
|
||||
private var phase: Phase = .idle
|
||||
private var pending: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var pendingRequest: Data?
|
||||
/// Bytes read past the end of the last response. Non-empty only if a host pipelines ahead of
|
||||
/// us, which none do — but dropping them would silently corrupt the next read.
|
||||
private var buffer = Data()
|
||||
private var operation = 0
|
||||
private var pinRejected = false
|
||||
private var servedRequest = false
|
||||
|
||||
/// False once the connection has failed; the pool discards these instead of handing them out.
|
||||
private(set) var isHealthy = true
|
||||
/// Has this connection completed at least one request? Drives the retry-once rule in
|
||||
/// `MgmtTransport.get` — only a connection the host may have dropped since is worth retrying.
|
||||
var hasServedRequest: Bool { servedRequest }
|
||||
|
||||
init(host: String, port: NWEndpoint.Port, identity: SecIdentity, pin: Data?) {
|
||||
self.host = host
|
||||
self.port = port.rawValue
|
||||
let options = NWProtocolTLS.Options()
|
||||
let sec = options.securityProtocolOptions
|
||||
sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12)
|
||||
// Our half of the mTLS handshake: the same paired identity the host authorizes the
|
||||
// read-only library routes by (mgmt/auth.rs `cert_may_access`).
|
||||
if let secIdentity = sec_identity_create(identity) {
|
||||
sec_protocol_options_set_local_identity(sec, secIdentity)
|
||||
}
|
||||
let rejected = RejectionFlag()
|
||||
// Replaces system trust evaluation wholesale, which is the point: the host is self-signed
|
||||
// and carries no SAN, so there is nothing for the system policy to succeed at. Pinning the
|
||||
// leaf's SHA-256 is the real check.
|
||||
sec_protocol_options_set_verify_block(sec, { _, trust, complete in
|
||||
let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||
guard let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
|
||||
let leaf = chain.first
|
||||
else {
|
||||
rejected.value = true
|
||||
complete(false)
|
||||
return
|
||||
}
|
||||
guard let pin else {
|
||||
complete(true) // trust-on-first-use: no pin recorded for this host yet
|
||||
return
|
||||
}
|
||||
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(leaf) as Data))
|
||||
let matches = fingerprint == pin
|
||||
if !matches { rejected.value = true }
|
||||
complete(matches)
|
||||
}, queue)
|
||||
self.connection = NWConnection(
|
||||
to: .hostPort(host: NWEndpoint.Host(host), port: port),
|
||||
using: NWParameters(tls: options, tcp: NWProtocolTCP.Options()))
|
||||
self.rejection = rejected
|
||||
self.connection.stateUpdateHandler = { [weak self] state in
|
||||
self?.handle(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set from the verify block, read when mapping the resulting handshake failure. Its own
|
||||
/// object because the block is built before `self` exists.
|
||||
private let rejection: RejectionFlag
|
||||
private final class RejectionFlag: @unchecked Sendable { var value = false }
|
||||
|
||||
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
guard self.phase != .dead else {
|
||||
continuation.resume(throwing: MgmtTransportError.connection("connection closed"))
|
||||
return
|
||||
}
|
||||
self.operation += 1
|
||||
let op = self.operation
|
||||
self.pending = continuation
|
||||
self.pendingRequest = self.requestBytes(path: path)
|
||||
self.buffer.removeAll(keepingCapacity: true)
|
||||
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self, self.operation == op else { return }
|
||||
self.finish(.failure(MgmtTransportError.timedOut))
|
||||
}
|
||||
switch self.phase {
|
||||
case .idle:
|
||||
self.phase = .connecting
|
||||
self.connection.start(queue: self.queue)
|
||||
case .ready:
|
||||
self.send()
|
||||
case .connecting, .dead:
|
||||
break // `.ready` (or a failure) will pick the pending request up
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
queue.async {
|
||||
self.phase = .dead
|
||||
self.isHealthy = false
|
||||
self.connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue-confined internals
|
||||
|
||||
private func handle(_ state: NWConnection.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
phase = .ready
|
||||
if pendingRequest != nil { send() }
|
||||
case .failed(let error):
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(mapped(error)))
|
||||
case .cancelled:
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(MgmtTransportError.connection("cancelled")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let request = pendingRequest else { return }
|
||||
pendingRequest = nil
|
||||
connection.send(content: request, completion: .contentProcessed { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
})
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
|
||||
[weak self] chunk, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let chunk, !chunk.isEmpty { self.buffer.append(chunk) }
|
||||
if self.buffer.count > MgmtTransport.maxResponseBytes {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(MgmtTransportError.tooLarge))
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
do {
|
||||
if let length = try HTTPResponseParser.messageLength(in: self.buffer) {
|
||||
let message = self.buffer.prefix(length)
|
||||
self.buffer = Data(self.buffer.dropFirst(length))
|
||||
let response = try HTTPResponseParser.parse(message)
|
||||
// A response the peer means to be last leaves nothing reusable behind. Nor
|
||||
// does a stream with bytes left over: we never pipeline, so anything trailing
|
||||
// means we are out of sync, and reusing the connection would misread the next
|
||||
// response rather than fail cleanly.
|
||||
if response.wantsClose || !self.buffer.isEmpty { self.isHealthy = false }
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// No framing header: the body ran to EOF, so what we have is the whole thing
|
||||
// and the connection is spent.
|
||||
self.isHealthy = false
|
||||
let response = try HTTPResponseParser.parse(self.buffer)
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard let continuation = pending else { return }
|
||||
pending = nil
|
||||
operation += 1 // invalidate this operation's timeout
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
/// A rejected pin surfaces as a generic handshake failure; the flag is how we recover what
|
||||
/// actually happened, so the UI can say "re-pair" instead of "offline".
|
||||
private func mapped(_ error: NWError) -> MgmtTransportError {
|
||||
rejection.value ? .pinMismatch : .connection(String(describing: error))
|
||||
}
|
||||
|
||||
private func requestBytes(path: String) -> Data {
|
||||
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
|
||||
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
|
||||
let request = """
|
||||
GET \(path) HTTP/1.1\r
|
||||
Host: \(authority)\r
|
||||
User-Agent: punktfunk-apple\r
|
||||
Accept: */*\r
|
||||
\r
|
||||
|
||||
"""
|
||||
return Data(request.utf8)
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,51 @@ public final class PunktfunkConnection {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this backend has a motion plane at all — whether a `sendMotion` sample to a
|
||||
/// host running it can reach the game, or is decoded and dropped. Mirrors the host's
|
||||
/// `GamepadPref::has_motion`; the X-Box classes have no gyro in their HID contract.
|
||||
///
|
||||
/// This answers for ONE backend. To ask it of a particular pad, go through
|
||||
/// `PunktfunkConnection.motionReaches(declared:)` — `resolvedGamepad` is not that pad's
|
||||
/// answer, because the host builds each virtual device from the pad's own
|
||||
/// `gamepadArrival` and falls back to the session default only for a pad that never
|
||||
/// declared one.
|
||||
///
|
||||
/// `.auto` answers `true` on purpose: it means "unknown" — an older host that omitted the
|
||||
/// echo, which may well have resolved a DualSense. Suppressing on unknown would silently
|
||||
/// break a working gyro, which is the worse of the two failures.
|
||||
public var hasMotion: Bool {
|
||||
switch self {
|
||||
case .auto: return true // unknown; assume it can, see above
|
||||
case .xbox360, .xboxOne: return false
|
||||
case .dualSense, .dualShock4, .dualSenseEdge, .switchPro,
|
||||
.steamController, .steamDeck, .steamController2:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad
|
||||
/// announced in its `gamepadArrival`, `asked` is the session default the handshake carried,
|
||||
/// and `resolved` is the host's echo. Mirrors punktfunk-core's `pad_motion_reaches`, which
|
||||
/// carries the full argument; in short:
|
||||
///
|
||||
/// - the host builds each virtual device from that pad's declaration, so the echo is simply
|
||||
/// not this pad's answer when the two differ (under "Automatic" the handshake carries the
|
||||
/// ACTIVE pad's kind, so a couch with an X-Box pad and a DualSense echoes X-Box 360 while
|
||||
/// the host builds the DualSense a working motion plane);
|
||||
/// - the host FOLDS what it cannot build — a Switch Pro on Windows, a UHID backend on a
|
||||
/// host whose `/dev/uhid` is unusable — and nothing here can predict that;
|
||||
/// - but the echo IS one observed sample of that fold, for the kind we asked about, so it
|
||||
/// is authoritative for a pad that declared exactly that.
|
||||
///
|
||||
/// Static and pure so it can be tested without a live session; the connection's
|
||||
/// `motionReaches(declared:)` is the call site that fills in the other two.
|
||||
public static func motionReaches(
|
||||
declared: GamepadType, asked: GamepadType, resolved: GamepadType
|
||||
) -> Bool {
|
||||
declared == asked ? resolved.hasMotion : declared.hasMotion
|
||||
}
|
||||
}
|
||||
|
||||
/// The virtual gamepad backend the host actually resolved (the Welcome's echo of the
|
||||
@@ -318,6 +363,18 @@ public final class PunktfunkConnection {
|
||||
/// DualSense feedback.
|
||||
public private(set) var resolvedGamepad: GamepadType = .auto
|
||||
|
||||
/// The session default this connection's handshake ASKED for, kept beside the host's answer
|
||||
/// above. The pair is what makes the echo usable per pad — see `motionReaches(declared:)`.
|
||||
public private(set) var requestedGamepad: GamepadType = .auto
|
||||
|
||||
/// Whether motion sent for ONE pad can reach the game, given the kind that pad DECLARED in its
|
||||
/// `gamepadArrival` (`GamepadManager.declaredKind(for:)`) — this session's two halves of
|
||||
/// `GamepadType.motionReaches(declared:asked:resolved:)`, which carries the reasoning.
|
||||
public func motionReaches(declared: GamepadType) -> Bool {
|
||||
GamepadType.motionReaches(
|
||||
declared: declared, asked: requestedGamepad, resolved: resolvedGamepad)
|
||||
}
|
||||
|
||||
/// The compositor the host actually resolved for this session's virtual output (the
|
||||
/// Welcome's echo of the requested `compositor`, with `.auto` resolved to a concrete
|
||||
/// backend). `.auto` = an older host that didn't say. Clients use it to decide
|
||||
@@ -572,6 +629,9 @@ public final class PunktfunkConnection {
|
||||
var gp: UInt32 = 0
|
||||
_ = punktfunk_connection_gamepad(handle, &gp)
|
||||
resolvedGamepad = GamepadType(rawValue: gp) ?? .auto
|
||||
// What we asked for, straight off the parameter — the echo above only speaks for a pad
|
||||
// that declared this same kind (see `motionReaches(declared:)`).
|
||||
requestedGamepad = gamepad
|
||||
var comp: UInt32 = 0
|
||||
_ = punktfunk_connection_compositor(handle, &comp)
|
||||
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// The opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): when player 1's forwarded
|
||||
// controller has no rotation sensor of its own, THIS device's IMU speaks for it on the wire's
|
||||
// motion plane — for clip-on and third-party pads that ship without a gyro, where the phone
|
||||
// body is rigidly attached to (or simply is) the thing in the player's hands. The sibling of
|
||||
// `GamepadFeedback`'s rumble-on-device mirror, with the data flowing the other way.
|
||||
//
|
||||
// GamepadCapture owns the engage/stand-down decision (it knows the pad-0 slot and whether its
|
||||
// controller reports a rotation rate); this class only turns CoreMotion on and off and converts
|
||||
// samples. Two invariants it enforces itself:
|
||||
// - one motion writer per pad: samples go out only between `start` and `stop`, and capture
|
||||
// suppresses pad 0's controller-motion forwarding while this runs;
|
||||
// - no stale rotation: `stop` sends a single zero-gyro sample after the last real one, so the
|
||||
// host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
// producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
//
|
||||
// Samples are CMDeviceMotion (sensor-fused: bias-corrected rotation rate, gravity split from
|
||||
// user acceleration) at the ~100 Hz CoreMotion ceiling — below a DualSense's 250 Hz, but the
|
||||
// host's motion plane is event-driven, not cadence-locked, so a slower producer just means
|
||||
// fewer samples. Units and axis semantics match `GamepadCapture.forwardMotion` exactly (the
|
||||
// `GamepadWire` constants; accel = gravity + user acceleration — the same convention, so a
|
||||
// future sign/scale correction lands in one place for both sources). The one thing the phone
|
||||
// adds is a frame remap: CoreMotion reports in the device's portrait frame, while the wire
|
||||
// wants the controller frame the player sees (x right, y up, z out of the screen), so each
|
||||
// sample is rotated by the current interface orientation — a phone clipped landscape must yaw
|
||||
// when the player yaws, not roll.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Device-frame → controller-frame axis remap for one interface orientation. CoreMotion's
|
||||
/// frame is fixed to the portrait device (+x right edge, +y top, +z out of the screen); the
|
||||
/// controller frame keeps +z (the screen always faces the player) and rotates x/y so they
|
||||
/// mean "player's right" and "player's up". Derived, like the wire scale constants — pinned
|
||||
/// by `DeviceGyroRemapTests`, correctable in one place if on-glass says otherwise.
|
||||
/// File-scope rather than nested so the sample thread can use it without actor isolation.
|
||||
enum DeviceGyroRemap {
|
||||
case identity
|
||||
/// Upside-down portrait: both in-plane axes flip.
|
||||
case flipped
|
||||
/// Landscape, device top to the player's LEFT (interface `.landscapeRight`):
|
||||
/// player-right = device-bottom, player-up = device-right.
|
||||
case topLeft
|
||||
/// Landscape, device top to the player's RIGHT (interface `.landscapeLeft`).
|
||||
case topRight
|
||||
|
||||
init(_ orientation: UIInterfaceOrientation) {
|
||||
switch orientation {
|
||||
case .portraitUpsideDown: self = .flipped
|
||||
case .landscapeRight: self = .topLeft
|
||||
case .landscapeLeft: self = .topRight
|
||||
default: self = .identity
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
/// same way under an in-plane rotation) into the controller frame.
|
||||
func apply(x: Float, y: Float, z: Float) -> (x: Float, y: Float, z: Float) {
|
||||
switch self {
|
||||
case .identity: return (x, y, z)
|
||||
case .flipped: return (-x, -y, z)
|
||||
case .topLeft: return (-y, x, z)
|
||||
case .topRight: return (y, -x, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class DeviceGyro {
|
||||
/// Whether this device can source motion at all — gates the settings rows (a device
|
||||
/// without an IMU would make the toggle a silent no-op, the rumble mirror's rule).
|
||||
/// One shared probe: Apple recommends a single `CMMotionManager` per app, and the
|
||||
/// settings UI asking per-render must not allocate one each time.
|
||||
public static let isAvailable: Bool = CMMotionManager().isDeviceMotionAvailable
|
||||
|
||||
/// Everything the sample thread touches, behind one lock: the orientation remap (written
|
||||
/// on main when the device rotates), the last converted accel, and whether a real sample
|
||||
/// went out (so `stop` knows it owes the wire a zero). Kept off the actor deliberately —
|
||||
/// `forward` runs on the delivery queue.
|
||||
private final class SampleState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var remap: DeviceGyroRemap = .identity
|
||||
var sentSample = false
|
||||
/// Re-sent with the closing zero-gyro sample so "rotation stopped" doesn't also
|
||||
/// overwrite a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
}
|
||||
|
||||
/// Ship one converted sample (wire pad 0). Must be thread-safe — invoked from the
|
||||
/// delivery queue (`PunktfunkConnection.sendMotion` locks internally).
|
||||
private let send: @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
|
||||
private let motion = CMMotionManager()
|
||||
/// Dedicated serial delivery queue — deliberately NOT main (the controller path's
|
||||
/// main-queue delivery is a known jitter source; the mirror starts clean).
|
||||
private let queue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "punktfunk.device-gyro"
|
||||
q.maxConcurrentOperationCount = 1
|
||||
return q
|
||||
}()
|
||||
|
||||
private let state = SampleState()
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
|
||||
/// Whether the mirror is between `start` and `stop` — read by GamepadCapture to keep the
|
||||
/// controller path off pad 0's motion while this runs.
|
||||
public private(set) var isRunning = false
|
||||
|
||||
public init(
|
||||
send: @escaping @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
self.send = send
|
||||
}
|
||||
|
||||
/// Begin sourcing pad-0 motion from this device. Idempotent.
|
||||
public func start() {
|
||||
guard !isRunning, motion.isDeviceMotionAvailable else { return }
|
||||
isRunning = true
|
||||
updateRemap()
|
||||
// Interface orientation only changes alongside a device-orientation notification, so
|
||||
// this is the one signal needed; re-reading the scene keeps a rotation lock stable.
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.updateRemap() }
|
||||
}
|
||||
// CoreMotion's practical ceiling; requesting faster just clamps.
|
||||
motion.deviceMotionUpdateInterval = 1.0 / 100.0
|
||||
motion.startDeviceMotionUpdates(to: queue) { [state, send] m, _ in
|
||||
guard let m else { return }
|
||||
Self.forward(m, state: state, send: send)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop sourcing and, if anything was sent, park the host pad's rotation at zero. The
|
||||
/// zero rides the same serial queue as the samples, so it is guaranteed last — without
|
||||
/// blocking the caller.
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
isRunning = false
|
||||
motion.stopDeviceMotionUpdates()
|
||||
if let o = orientationObserver {
|
||||
NotificationCenter.default.removeObserver(o)
|
||||
orientationObserver = nil
|
||||
}
|
||||
queue.addOperation { [state, send] in
|
||||
state.lock.lock()
|
||||
let owed = state.sentSample
|
||||
state.sentSample = false
|
||||
let accel = state.lastAccel
|
||||
state.lock.unlock()
|
||||
if owed { send((0, 0, 0), accel) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemap() {
|
||||
let o = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.interfaceOrientation ?? .portrait
|
||||
state.lock.lock()
|
||||
state.remap = DeviceGyroRemap(o)
|
||||
state.lock.unlock()
|
||||
}
|
||||
|
||||
/// Runs on the delivery queue: remap, scale, ship.
|
||||
nonisolated private static func forward(
|
||||
_ m: CMDeviceMotion, state: SampleState,
|
||||
send: (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void
|
||||
) {
|
||||
state.lock.lock()
|
||||
let r = state.remap
|
||||
state.lock.unlock()
|
||||
let rot = r.apply(
|
||||
x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z))
|
||||
// Total acceleration, NEGATED — the same convention as GamepadCapture.forwardMotion, which
|
||||
// this file's header promises to track. Apple reports the gravity VECTOR (pointing down);
|
||||
// an accelerometer measures proper acceleration (pointing up at rest), and the wire carries
|
||||
// the latter. Without the minus a still phone told the host it was accelerating downward at
|
||||
// 1 g.
|
||||
let acc = r.apply(
|
||||
x: -Float(m.gravity.x + m.userAcceleration.x),
|
||||
y: -Float(m.gravity.y + m.userAcceleration.y),
|
||||
z: -Float(m.gravity.z + m.userAcceleration.z))
|
||||
// NO frame conversion here, and that is not an oversight — `GamepadCapture.forwardMotion`
|
||||
// applies `GamepadWire.appleMotionToWire` and this deliberately does not.
|
||||
//
|
||||
// The trap is that two different frames are both called "the controller frame". GCMotion
|
||||
// reports a CONTROLLER in (Right, Forward, Up) — measured on a real DualSense — which is
|
||||
// not the wire's frame, hence the conversion over there. `r` above resolves THIS DEVICE
|
||||
// into the frame the header describes: x right, y up, z out of the screen. For the pose
|
||||
// this mirror exists to serve — a phone clipped upright, screen facing the player — "out of
|
||||
// the screen" points AT the player, so that frame is (Right, Up, Backward), which IS the
|
||||
// wire's frame. Straight through is already correct.
|
||||
//
|
||||
// Applying the controller path's conversion here was tried and was WRONG: a phone at rest
|
||||
// would have reported gravity as −1 g on the roll axis instead of +1 g up, i.e. lying on
|
||||
// its edge. Caught by measuring the Android twin, which does the same thing straight
|
||||
// through and reads +1 g on the up axis end to end. If a future capture path needs a
|
||||
// conversion, decide it from that source's OWN measured frame rather than by analogy.
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
let gyro = (
|
||||
GamepadWire.motionRaw(rot.x, scale: gs),
|
||||
GamepadWire.motionRaw(rot.y, scale: gs),
|
||||
GamepadWire.motionRaw(rot.z, scale: gs)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(acc.x, scale: as_),
|
||||
GamepadWire.motionRaw(acc.y, scale: as_),
|
||||
GamepadWire.motionRaw(acc.z, scale: as_)
|
||||
)
|
||||
state.lock.lock()
|
||||
state.lastAccel = accel
|
||||
state.sentSample = true
|
||||
state.lock.unlock()
|
||||
send(gyro, accel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -66,7 +66,13 @@ public final class GamepadCapture {
|
||||
var buttons: UInt32 = 0
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
/// A motion sample went out on this pad — `flush` then owes the wire a zero-gyro
|
||||
/// sample: the host holds motion as STATE and re-emits it, so a nonzero angular
|
||||
/// velocity left behind reads as endless rotation (the gyro-sweep latch).
|
||||
var motionSent = false
|
||||
/// The last accel sent, re-used by the flush zero so "rotation stopped" doesn't
|
||||
/// also replace a plausible gravity vector with free-fall.
|
||||
var lastAccel: (Int16, Int16, Int16) = (0, 0, 0)
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
@@ -89,9 +95,6 @@ public final class GamepadCapture {
|
||||
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
|
||||
private var slots: [Slot] = []
|
||||
|
||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||
|
||||
/// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`):
|
||||
/// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal
|
||||
/// play can't trip it. Held for `disconnectHold` it ends the session via
|
||||
@@ -128,6 +131,15 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
/// Fired ON MAIN, once per slot at open, when a controller that HAS a gyro was given a host
|
||||
/// backend without a motion plane — its motion is not being sent, because every sample would
|
||||
/// be decoded and dropped. The argument is the kind this pad declared, so the UI can name it.
|
||||
///
|
||||
/// It fires at open rather than on the first sample precisely because nothing is sampled: the
|
||||
/// IMU is never powered in this case (see `openSlot`), which is also what stops the pad
|
||||
/// burning battery streaming gyro nobody reads.
|
||||
public var onMotionUnreachable: ((PunktfunkConnection.GamepadType) -> Void)?
|
||||
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
@@ -153,6 +165,15 @@ public final class GamepadCapture {
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
#if os(iOS)
|
||||
/// Opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): while player 1's forwarded
|
||||
/// controller has no rotation sensor, this device's IMU sources pad 0's motion instead —
|
||||
/// for clip-on pads without a gyro. Session-scoped (the setting is read once here); nil
|
||||
/// when off, unavailable, or forwarding is off (the mirror is wire-only, so with nothing
|
||||
/// to send there is nothing to mirror). Engage/stand-down lives in `updateDeviceGyro`.
|
||||
private let deviceGyro: DeviceGyro?
|
||||
#endif
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
@@ -162,6 +183,17 @@ public final class GamepadCapture {
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
#if os(iOS)
|
||||
if forwarding, DeviceGyro.isAvailable,
|
||||
UserDefaults.standard.bool(forKey: DefaultsKey.gyroFromDevice) {
|
||||
deviceGyro = DeviceGyro { [weak connection] gyro, accel in
|
||||
// Thread-safe (sendMotion locks); pad 0 by the same rule as the rumble mirror.
|
||||
connection?.sendMotion(pad: 0, gyro: gyro, accel: accel)
|
||||
}
|
||||
} else {
|
||||
deviceGyro = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -187,6 +219,9 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated {
|
||||
self?.suspended = true
|
||||
self?.releaseAll()
|
||||
// The mirror pauses with capture (its stop parks the host pad's rotation
|
||||
// at zero — an overlay pull-down must not leave the game spinning).
|
||||
self?.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
@@ -199,11 +234,15 @@ public final class GamepadCapture {
|
||||
for slot in self.slots {
|
||||
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||
}
|
||||
self.updateDeviceGyro()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
#if os(iOS)
|
||||
deviceGyro?.stop()
|
||||
#endif
|
||||
closeAllSlots()
|
||||
forwardedSub = nil
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
@@ -224,6 +263,8 @@ public final class GamepadCapture {
|
||||
}
|
||||
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||
updateEscapeChord()
|
||||
// Pad 0 may have changed hands — re-evaluate whether this device's IMU speaks for it.
|
||||
updateDeviceGyro()
|
||||
}
|
||||
|
||||
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||
@@ -299,10 +340,43 @@ public final class GamepadCapture {
|
||||
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
|
||||
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
|
||||
// it back down), so with nothing to forward we simply never turn it on.
|
||||
if forwarding, let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
//
|
||||
// A host that built this pad a backend WITHOUT a motion plane is the same situation: every
|
||||
// sample would be decoded and dropped, so there is equally nothing to forward. Asked per
|
||||
// pad off what this slot declared, not off the session echo — under "Automatic" a couch
|
||||
// with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1
|
||||
// a DualSense whose gyro works.
|
||||
//
|
||||
// Gated on `hasRotationRate`, not on `motion != nil`. An X-Box controller exposes a
|
||||
// `GCMotion` that reports gravity and NOTHING else — attaching to it streamed a
|
||||
// permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration
|
||||
// that says this pad has one. A game reading it sees a controller being held perfectly
|
||||
// still forever, which is worse than seeing no motion plane at all: there is nothing to
|
||||
// fall back to and nothing to notice.
|
||||
let motionCanReach = connection.motionReaches(declared: slot.pref)
|
||||
if forwarding, let motion = c.motion, motion.hasRotationRate {
|
||||
if motionCanReach {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
// Delivered on the MAIN queue, like every other handler here, and deliberately so
|
||||
// even though ~250 Hz of samples on main is not free.
|
||||
//
|
||||
// GameController's `handlerQueue` is a property of the CONTROLLER, not of an
|
||||
// element, so there is no way to move motion off main without moving buttons,
|
||||
// sticks, the touchpad and the escape chord with it. This whole class is
|
||||
// `@MainActor` — eight `assumeIsolated` sites, the slot table, the gesture timers
|
||||
// — so that is a rewrite of the isolation model, not a queue assignment. It would
|
||||
// also put the tvOS escape chord (the ONLY controller way out of a stream there)
|
||||
// on a background queue, which is a real risk taken for a speculative gain.
|
||||
//
|
||||
// If main-queue contention ever shows up as motion jitter, the measurement to make
|
||||
// first is `motion_cadence`'s per-pad inter-arrival histogram on the host — it
|
||||
// already reports exactly this, and would say whether the delay is here or on the
|
||||
// wire before anyone restructures the class for it.
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
}
|
||||
} else {
|
||||
onMotionUnreachable?(slot.pref)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,36 +635,96 @@ public final class GamepadCapture {
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
guard !suspended else { return }
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
slot.lastMotionNs = now
|
||||
// Total acceleration in g: gravity + user when split, else the raw vector.
|
||||
#if os(iOS)
|
||||
// While the phone-gyro mirror speaks for pad 0, the controller's own motion —
|
||||
// necessarily rotation-less, that's the engage condition — stays off the wire:
|
||||
// two writers on one pad's motion state would fight, and this accel-only stream
|
||||
// would keep stomping the mirror's gyro with zeros.
|
||||
if slot.pad == 0, deviceGyro?.isRunning == true { return }
|
||||
#endif
|
||||
// Every sample goes out. There used to be a 4 ms floor here, and it was a DROP: a sample
|
||||
// arriving 3.9 ms after the last one was discarded outright.
|
||||
//
|
||||
// That is the wrong shape for this signal. Buttons and sticks are absolute state, so a
|
||||
// dropped frame costs nothing — the next one says everything it would have. Angular
|
||||
// velocity is a RATE, and a consumer integrates it into an angle; a dropped sample is
|
||||
// rotation that happened and can never be recovered. GameController's delivery is jittery
|
||||
// around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra
|
||||
// sample, it sheds a steady fraction of every turn — and the error is one-signed, so it
|
||||
// accumulates into aim drifting short rather than into noise.
|
||||
//
|
||||
// Nothing needed the ceiling: GC delivers at the sensor's rate rather than faster, the SDL
|
||||
// client has always forwarded every sample, and the host's own idle watchdog runs on a
|
||||
// 100 ms timeout this cannot outpace. The throttle's `lastMotionNs`/`motionIntervalNs` went
|
||||
// with it rather than being left set-but-unread — nothing else consumed either.
|
||||
// Total acceleration in g: gravity + user when split, else the raw vector — then NEGATED
|
||||
// into the wire's convention.
|
||||
//
|
||||
// Apple reports acceleration as the gravity VECTOR: a device lying flat face-up reads
|
||||
// z = −1, because gravity points down. An accelerometer physically measures proper
|
||||
// acceleration, which at rest is the +1 g normal force pushing UP, and that is what a
|
||||
// DualSense's report — the wire's convention — carries. The two are exact negatives, so
|
||||
// every sample we sent was upside down, on both branches (`m.acceleration` follows the
|
||||
// same Apple convention as the gravity/user split).
|
||||
//
|
||||
// Measured on glass 2026-08-07 (G16): a DualSense flat and face-up, streamed from an
|
||||
// iPhone to a Linux host, arrived at hid-playstation as z = −0.99 g where +1.00 was owed.
|
||||
// Magnitude was 1.006 g, so the SCALE was already right — this is purely direction.
|
||||
// `rotationRate` is a true angular rate and needs no flip; the same session confirmed yaw
|
||||
// came through with the correct sign.
|
||||
let ax: Float
|
||||
let ay: Float
|
||||
let az: Float
|
||||
if m.hasGravityAndUserAcceleration {
|
||||
ax = Float(m.gravity.x + m.userAcceleration.x)
|
||||
ay = Float(m.gravity.y + m.userAcceleration.y)
|
||||
az = Float(m.gravity.z + m.userAcceleration.z)
|
||||
ax = -Float(m.gravity.x + m.userAcceleration.x)
|
||||
ay = -Float(m.gravity.y + m.userAcceleration.y)
|
||||
az = -Float(m.gravity.z + m.userAcceleration.z)
|
||||
} else {
|
||||
ax = Float(m.acceleration.x)
|
||||
ay = Float(m.acceleration.y)
|
||||
az = Float(m.acceleration.z)
|
||||
ax = -Float(m.acceleration.x)
|
||||
ay = -Float(m.acceleration.y)
|
||||
az = -Float(m.acceleration.z)
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs)
|
||||
),
|
||||
accel: (
|
||||
GamepadWire.motionRaw(ax, scale: as_),
|
||||
GamepadWire.motionRaw(ay, scale: as_),
|
||||
GamepadWire.motionRaw(az, scale: as_)
|
||||
))
|
||||
// Into the DualSense report frame. GameController and the pad's own report do not agree
|
||||
// about which slot is which axis — measured, both from the same controller, on 2026-08-07
|
||||
// — so forwarding GC's x/y/z straight through sent yaw where the game reads roll. See
|
||||
// `GamepadWire.appleMotionToWire`. One change of basis, applied to both planes.
|
||||
let g = GamepadWire.appleMotionToWire(
|
||||
(Float(m.rotationRate.x), Float(m.rotationRate.y), Float(m.rotationRate.z)))
|
||||
let a = GamepadWire.appleMotionToWire((ax, ay, az))
|
||||
let gyro = (
|
||||
GamepadWire.motionRaw(g.0, scale: gs),
|
||||
GamepadWire.motionRaw(g.1, scale: gs),
|
||||
GamepadWire.motionRaw(g.2, scale: gs)
|
||||
)
|
||||
let accel = (
|
||||
GamepadWire.motionRaw(a.0, scale: as_),
|
||||
GamepadWire.motionRaw(a.1, scale: as_),
|
||||
GamepadWire.motionRaw(a.2, scale: as_)
|
||||
)
|
||||
// Recorded AFTER the frame conversion, deliberately: `flush` replays `lastAccel` beside a
|
||||
// zero gyro, so it has to be the vector that actually went on the wire. Stashing the
|
||||
// pre-conversion one would park a still pad's gravity in the wrong axis.
|
||||
if wire != nil {
|
||||
slot.motionSent = true
|
||||
slot.lastAccel = accel
|
||||
}
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: gyro, accel: accel)
|
||||
}
|
||||
|
||||
/// Engage or stand down the phone-gyro mirror: it speaks for pad 0 exactly while a
|
||||
/// forwarded controller holds that index but can't rotate for itself — no `GCMotion`,
|
||||
/// or a motion object without a rotation rate (gravity-only pads, e.g. an Xbox pad on
|
||||
/// iOS). Re-evaluated on every reconcile and on suspend/resume; `DeviceGyro.stop`
|
||||
/// parks the host pad's rotation at zero, so standing down never strands a spin.
|
||||
private func updateDeviceGyro() {
|
||||
#if os(iOS)
|
||||
guard let gyro = deviceGyro else { return }
|
||||
let pad0 = slots.first { $0.pad == 0 }
|
||||
let wants = !suspended && pad0 != nil && pad0!.controller.motion?.hasRotationRate != true
|
||||
if wants { gyro.start() } else { gyro.stop() }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||
@@ -634,6 +768,14 @@ public final class GamepadCapture {
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
// Motion is host-side STATE, re-emitted until replaced — a nonzero angular velocity
|
||||
// left behind reads as endless rotation (the gyro-sweep latch: Control Center
|
||||
// pull-down froze the last sample for as long as the overlay stayed up). Rest means
|
||||
// zero rotation; the last accel is kept so gravity doesn't become free-fall.
|
||||
if slot.motionSent {
|
||||
slot.motionSent = false
|
||||
wire?.sendMotion(pad: UInt8(slot.pad), gyro: (0, 0, 0), accel: slot.lastAccel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||
|
||||
@@ -41,6 +41,10 @@ public final class GamepadManager: ObservableObject {
|
||||
public let kind: PunktfunkConnection.GamepadType
|
||||
public let hasLight: Bool
|
||||
public let hasHaptics: Bool
|
||||
/// This controller has a GYROSCOPE — not merely a `GCMotion`. The distinction is the whole
|
||||
/// point: an X-Box pad exposes a `GCMotion` that reports gravity and nothing else, so
|
||||
/// `motion != nil` is true for a controller with no angular rate to give. Read
|
||||
/// `hasRotationRate`, which is GameController's own answer to the question we mean.
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||
@@ -265,7 +269,10 @@ public final class GamepadManager: ObservableObject {
|
||||
kind: kind,
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// `hasRotationRate`, not `motion != nil` — see the property. The settings row shows a
|
||||
// gyroscope badge off this, and promising a gyro an X-Box pad does not have is the
|
||||
// same lie as streaming its non-existent rotation to the host.
|
||||
hasMotion: c.motion?.hasRotationRate ?? false,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||
// Edge included); the DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||
|
||||
@@ -73,6 +73,30 @@ public enum GamepadWire {
|
||||
public static func motionRaw(_ value: Float, scale: Float) -> Int16 {
|
||||
Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max)))
|
||||
}
|
||||
|
||||
/// GameController's motion frame → the DualSense report frame the wire is defined in.
|
||||
///
|
||||
/// The wire is a unit passthrough: the host writes these three components, in order, into the
|
||||
/// virtual DualSense's report bytes 16../22.. — the same slots a real pad fills. So the frame
|
||||
/// the wire is defined in is the pad's OWN report frame, and a client that forwards its
|
||||
/// platform's axes unconverted is simply speaking a different language.
|
||||
///
|
||||
/// Both frames were measured on 2026-08-07 from ONE physical DualSense on one desk — the pad
|
||||
/// read twice, over raw HID and through GameController:
|
||||
///
|
||||
/// DualSense report frame: (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll
|
||||
/// GameController frame: (Right, Forward, Up)
|
||||
///
|
||||
/// Matching them up: Right is already slot 0; Up is GC's z, so it moves to slot 1; and slot 2
|
||||
/// wants Backward, which is GC's y negated. Hence `(x, z, -y)`.
|
||||
///
|
||||
/// Applied to gyro AND acceleration, because it is a change of basis and both are expressed in
|
||||
/// that basis. The negation `forwardMotion` already does for acceleration is a separate matter
|
||||
/// — that one converts Apple's gravity-VECTOR convention into the proper acceleration a real
|
||||
/// pad reports, and it composes with this rather than replacing it.
|
||||
public static func appleMotionToWire(_ v: (Float, Float, Float)) -> (Float, Float, Float) {
|
||||
(v.0, v.2, -v.1)
|
||||
}
|
||||
}
|
||||
|
||||
extension Float {
|
||||
|
||||
@@ -47,7 +47,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
bytes 1.12.0 — MIT — https://github.com/tokio-rs/bytes
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cfg-if 1.0.4 — MIT OR Apache-2.0 — https://github.com/rust-lang/cfg-if
|
||||
cfg_aliases 0.2.1 — MIT — https://github.com/katharostech/cfg_aliases
|
||||
@@ -85,7 +85,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastbloom 0.14.1 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
fnv 1.0.7 — Apache-2.0 / MIT — https://github.com/servo/rust-fnv
|
||||
foldhash 0.2.0 — Zlib — https://github.com/orlp/foldhash
|
||||
@@ -908,7 +908,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.9, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.10, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -1953,7 +1953,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var samplesUs: [Int64] = []
|
||||
private var skewCorrected = false
|
||||
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)` —
|
||||
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
|
||||
private var latestNs: Int64 = 0
|
||||
private var latestAtNs: Int64 = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -49,10 +53,42 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||||
lock.lock()
|
||||
samplesUs.append(latNs / 1000)
|
||||
latestNs = latNs
|
||||
latestAtNs = atNs
|
||||
if offsetNs != 0 { skewCorrected = true }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// The most recent single sample in ns, or `nil` if none has landed or the last one ended more
|
||||
/// than `maxAgeMs` before `nowNs` (both `CLOCK_REALTIME`). Unlike `drain`, this reports a level
|
||||
/// rather than a window, and reading it consumes nothing.
|
||||
///
|
||||
/// **What it is for.** Read off the END-TO-END meter, this is the video plane's live
|
||||
/// glass-to-glass figure — `displayed + clockOffset − pts`, exactly the shape `AvSync` compares
|
||||
/// audio against — and it is the reference the A/V sync loop needs. It is published from
|
||||
/// `record`, so BOTH present paths (arrival and deadline) feed it without either knowing that
|
||||
/// audio exists.
|
||||
///
|
||||
/// **Why staleness is not optional.** The number is a level, so absent an age check it would
|
||||
/// simply keep its last value forever. This client has a state where that matters: the
|
||||
/// backgrounded keep-alive keeps audio playing and DROPS video decode entirely, so the loop
|
||||
/// would go on steering the ring against a reference minutes old and frozen. Expiring it
|
||||
/// returns `nil`, which is the same "no reference yet" case as session start — the loop holds
|
||||
/// its last correction and stops chasing. `nowNs` is caller-supplied rather than read fresh so
|
||||
/// the audio side compares against exactly the instant it timestamped its own frame at.
|
||||
///
|
||||
/// Only the PAST is bounded. A present stamp can legitimately sit a hair ahead of the reader's
|
||||
/// clock (the deadline presenter stamps at the link's target present time), and discarding the
|
||||
/// only reference we have over a fraction of a refresh would make it flap in and out; a stamp
|
||||
/// wildly in the future instead yields a huge offset, which `AvSync` refuses on its own terms.
|
||||
public func latestSample(asOfNs nowNs: Int64, maxAgeMs: Int) -> Int64? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard latestNs > 0 else { return nil }
|
||||
guard (nowNs &- latestAtNs) <= Int64(maxAgeMs) * 1_000_000 else { return nil }
|
||||
return latestNs
|
||||
}
|
||||
|
||||
public struct Stats: Sendable {
|
||||
public let p50Ms: Double
|
||||
public let p95Ms: Double
|
||||
|
||||
@@ -193,6 +193,14 @@ public enum DefaultsKey {
|
||||
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||
/// has a haptic actuator (no iPad/Mac/TV).
|
||||
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||
/// Use this device's own gyroscope as player 1's motion when the forwarded controller has
|
||||
/// none of its own — for clip-on and third-party pads without an IMU, where the device body
|
||||
/// moves with the player's hands. The rumble mirror's sibling, data flowing the other way.
|
||||
/// Off by default (opt-in); read once per session by `GamepadCapture`, whose `DeviceGyro`
|
||||
/// mirror engages only while pad 0's controller reports no rotation rate (a real gyro pad
|
||||
/// always wins). The toggle is shown only where the device has motion hardware
|
||||
/// (`DeviceGyro.isAvailable`).
|
||||
public static let gyroFromDevice = "punktfunk.gyroFromDevice"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
|
||||
@@ -53,11 +53,22 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
/// The mirror case: a host clock running SLOW is a genuine deficit — no depth is ever deep
|
||||
/// enough forever — so the ring must spend it on RARE, clean re-banks (a hollow ring
|
||||
/// re-primes on its first click and refills the whole target) rather than riding the knife
|
||||
/// edge in permanent sub-frame chatter, which is what "silence-free" used to hide: every
|
||||
/// callback a fraction of a frame short, none of them fully silent, all of them audible.
|
||||
/// −200 ppm is an exaggeration of real DAC skew (tens of ppm); even so, two minutes may
|
||||
/// cost at most a couple of refills' worth of silent callbacks.
|
||||
func testNegativeDriftBanksRarelyInsteadOfChattering() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
XCTAssertLessThanOrEqual(
|
||||
silent, 24,
|
||||
"a draining ring re-banks a few times; a silent-callback stream means it is thrashing")
|
||||
XCTAssertGreaterThan(
|
||||
silent, 0,
|
||||
"a persistent deficit cannot be ridden out silence-free — if this is zero the ring "
|
||||
+ "is back to sub-frame chatter, which is audible without ever being silent")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
@@ -79,11 +90,14 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
// Drain it dry at the device's own quantum — an oversized read would count as ITS OWN
|
||||
// huge callback and legitimately read as hollow — then starve one callback and feed a
|
||||
// normal quantum again. The ring is freshly primed, so its depth average is nowhere near
|
||||
// hollow, and one short read must ride on the hysteresis.
|
||||
while ring.bufferedMS > 0 {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
@@ -92,14 +106,17 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
|
||||
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
|
||||
/// it back — and the floor never dips below the base.
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
/// a long — genuinely quiet — spell gives it back, never below the base. The quiet refill
|
||||
/// runs DEEP: a knife-edge refill (exactly what each read takes) leaves the ring within a
|
||||
/// frame of empty every callback, which now correctly reads as pressure, not quiet.
|
||||
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
@@ -108,20 +125,27 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
|
||||
|
||||
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
|
||||
// each full read in between keeps the de-prime hysteresis from tripping.
|
||||
// Prime, then drain: the 5th read is still served in full but leaves nothing over — a
|
||||
// near-miss, and the floor grows BEFORE any click.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() } // drains to zero
|
||||
for _ in 0..<5 { read() }
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "a near-miss must grow the floor pre-click")
|
||||
XCTAssertEqual(ring.stats.underruns, 0, "nothing was audible yet")
|
||||
|
||||
// Then alternate starve/refill: each dry read is a genuine underrun, each full read in
|
||||
// between keeps the de-prime hysteresis from tripping. (The refills land as further
|
||||
// near-misses, but growth is one step per window — the cluster is what grows it again.)
|
||||
read() // short — underrun 1
|
||||
write(ms: 5); read() // full — hysteresis reset
|
||||
read() // short — underrun 2
|
||||
write(ms: 5); read() // full
|
||||
read() // short — underrun 3 → the floor grows one step
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.targetMS, 40, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.underruns, 3)
|
||||
|
||||
// A long clean run (30 s of consumed audio) relaxes the growth back to the base…
|
||||
for _ in 0..<(30_000 / 5 + 10) {
|
||||
// A long clean run at a healthy depth relaxes the growth back to the base…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(90_000 / 5 + 10) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
@@ -198,5 +222,431 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
silentTail, 0,
|
||||
"after adapting, the last 3 s must play through the bunching without a dropout")
|
||||
}
|
||||
|
||||
// MARK: - A/V sync (audio latency overhaul, W6)
|
||||
//
|
||||
// The second half of the same story. Depth alone is not correctness: a ring can be exactly as
|
||||
// deep as its link needs and still put audio in the wrong place, because nothing ever compared
|
||||
// it to the picture. `AvSync` measures that comparison and asks the ring to move; the ring is
|
||||
// free to refuse. These pin both halves — that the loop DOES act (the previous pass in this
|
||||
// area shipped a correction that was structurally unreachable and had a green test), and that
|
||||
// it can never act far enough to starve the callback.
|
||||
|
||||
/// Build an observation whose measured offset is exactly `offsetMS` (positive = audio late).
|
||||
/// Mirrors the Rust `obs` helper: pin now/skew/pts so the only free term is the buffered depth,
|
||||
/// then choose the video figure so the difference lands where we want it.
|
||||
private func obs(offsetMS: Int, depth: Int) -> AvSync.Observation {
|
||||
let bufferedMS = depth / perMS
|
||||
let audioE2eMS = bufferedMS + 40 // 40 ms of transport, arbitrary but fixed
|
||||
let videoE2eMS = audioE2eMS - offsetMS
|
||||
return AvSync.Observation(
|
||||
ptsNs: 1_000_000_000,
|
||||
nowLocalNs: 1_000_000_000 + 40 * 1_000_000,
|
||||
clockOffsetNs: 0,
|
||||
bufferedAhead: depth,
|
||||
videoE2eNs: Int64(max(0, videoE2eMS)) * 1_000_000)
|
||||
}
|
||||
|
||||
/// Fold `n` identical observations in.
|
||||
private func settle(_ sync: inout AvSync, offsetMS: Int, depth: Int, count: Int = 100) {
|
||||
for _ in 0..<count { sync.observe(obs(offsetMS: offsetMS, depth: depth)) }
|
||||
}
|
||||
|
||||
func testAvSyncNeedsEvidenceBeforeActing() {
|
||||
var s = AvSync(channels: channels)
|
||||
// One sample is never enough — the skew estimate and the video figure both settle after
|
||||
// connect, and acting on the first would chase the handshake, not the stream.
|
||||
XCTAssertNil(s.observe(obs(offsetMS: 50, depth: 30 * perMS)))
|
||||
XCTAssertFalse(s.settled)
|
||||
XCTAssertNil(s.desiredDepth(currentDepth: 30 * perMS))
|
||||
settle(&s, offsetMS: 50, depth: 30 * perMS, count: 99) // 1 + 99 = 100
|
||||
XCTAssertTrue(s.settled, "should act once the evidence is in")
|
||||
}
|
||||
|
||||
/// No frame on the glass ⇒ no reference ⇒ the loop says nothing, however many observations
|
||||
/// arrive. This is the state every session starts in, and the one the stage-1 fallback
|
||||
/// presenter stays in for its whole life.
|
||||
func testAvSyncWithoutAVideoReferenceNeverActs() {
|
||||
var s = AvSync(channels: channels)
|
||||
for _ in 0..<500 {
|
||||
s.observe(AvSync.Observation(
|
||||
ptsNs: 1_000_000_000, nowLocalNs: 1_040_000_000, clockOffsetNs: 0,
|
||||
bufferedAhead: 30 * perMS, videoE2eNs: nil))
|
||||
}
|
||||
XCTAssertFalse(s.settled)
|
||||
XCTAssertNil(s.desiredDepth(currentDepth: 30 * perMS))
|
||||
}
|
||||
|
||||
func testAvSyncAimsShallowerWhenAudioIsLate() {
|
||||
let depth = 60 * perMS
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: 40, depth: depth, count: 400)
|
||||
guard let want = s.desiredDepth(currentDepth: depth) else {
|
||||
return XCTFail("a 40 ms offset is actionable")
|
||||
}
|
||||
XCTAssertLessThan(want, depth, "audio late must aim shallower")
|
||||
// The correction is the offset, not a guess at it.
|
||||
let shedMS = (depth - want) / perMS
|
||||
XCTAssertTrue((35...45).contains(shedMS), "should aim to shed ~40 ms, got \(shedMS)")
|
||||
XCTAssertEqual(s.offsetMS, 40, "and report it, sign and all")
|
||||
}
|
||||
|
||||
func testAvSyncAimsDeeperWhenAudioIsEarly() {
|
||||
let depth = 20 * perMS
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: -30, depth: depth, count: 400)
|
||||
guard let want = s.desiredDepth(currentDepth: depth) else {
|
||||
return XCTFail("a 30 ms offset is actionable")
|
||||
}
|
||||
XCTAssertGreaterThan(want, depth, "audio early must aim deeper")
|
||||
XCTAssertEqual(s.offsetMS, -30)
|
||||
}
|
||||
|
||||
func testAvSyncDeadbandsWhatNoOneCanHear() {
|
||||
let depth = 30 * perMS
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: 8, depth: depth, count: 400) // inside the 10 ms deadband
|
||||
XCTAssertNil(
|
||||
s.desiredDepth(currentDepth: depth),
|
||||
"an offset inside the deadband must not provoke a (real, if crossfaded) discontinuity")
|
||||
XCTAssertEqual(s.offsetMS, 8, "…but it is still REPORTED — the HUD shows the residual")
|
||||
}
|
||||
|
||||
/// A wall-clock step or a stale video figure produces an enormous apparent misalignment.
|
||||
/// Clamping it would act on a wrong number as though it were a small real one, so it is
|
||||
/// refused outright and the running average is left untouched.
|
||||
func testAvSyncRejectsTheImplausibleInsteadOfClampingIt() {
|
||||
let depth = 30 * perMS
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: 30, depth: depth, count: 400)
|
||||
let before = s.offsetMS
|
||||
// Built directly rather than through `obs`: that helper floors the video figure at zero,
|
||||
// which would cap the offset at a merely LARGE value and let this pass without ever
|
||||
// exercising the rejection.
|
||||
let wild = AvSync.Observation(
|
||||
ptsNs: 0, nowLocalNs: 5_000_000_000, clockOffsetNs: 0,
|
||||
bufferedAhead: depth, videoE2eNs: 40_000_000)
|
||||
XCTAssertNil(s.observe(wild))
|
||||
XCTAssertTrue(s.implausible, "a ~5 s offset must be refused, not folded")
|
||||
XCTAssertEqual(before, s.offsetMS, "an implausible sample must be discarded, not folded in")
|
||||
}
|
||||
|
||||
/// The same refusal for arithmetic that cannot even be CARRIED OUT, which is why the terms are
|
||||
/// combined with overflow-reporting operators rather than the wrapping `&-` the latency meters
|
||||
/// use.
|
||||
///
|
||||
/// This input is not arbitrary. `ptsNs = 1 << 63` reads as `Int64.min` in two's complement, so
|
||||
/// the audio leg overflows and the difference lands on EXACTLY `Int64.min` — and `abs()` of
|
||||
/// `Int64.min` has no representable result, so in Swift it traps. Check the overflow flags
|
||||
/// after the sanity limit instead of before and this observation does not mis-measure the
|
||||
/// stream, it aborts the process, from the audio drain thread. The guard's short-circuit
|
||||
/// ordering is what makes the sanity check itself safe to run.
|
||||
func testAvSyncRefusesAnOffsetItCannotEvenCompute() {
|
||||
var s = AvSync(channels: channels)
|
||||
let wild = AvSync.Observation(
|
||||
ptsNs: 1 << 63, nowLocalNs: 40_000_000, clockOffsetNs: 0,
|
||||
bufferedAhead: 0, videoE2eNs: 40_000_000)
|
||||
XCTAssertNil(s.observe(wild))
|
||||
XCTAssertTrue(s.implausible)
|
||||
XCTAssertFalse(s.settled, "a refused sample is not evidence")
|
||||
XCTAssertEqual(s.offsetMS, 0, "and nothing of it was folded in")
|
||||
}
|
||||
|
||||
// MARK: - …and what the ring does with the proposal
|
||||
|
||||
/// Drive one read so the ring knows the device quantum (`renderQuantum` seeds the floor).
|
||||
private func primeQuantum(_ ring: AudioRing, quantumMS: Int) {
|
||||
var scratch = [Float](repeating: 0, count: quantumMS * perMS)
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
}
|
||||
|
||||
/// The loop is NOT inert: a settled proposal inside the ring's legal band actually moves the
|
||||
/// effective target. Without this the whole feature could ship as unreachable code with every
|
||||
/// other test still green — which is exactly how the previous drift correction shipped dead.
|
||||
func testSyncActuallyMovesTheTarget() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
primeQuantum(ring, quantumMS: 5)
|
||||
XCTAssertEqual(ring.stats.targetMS, 20, "base target (JitterTuning.COREAUDIO)")
|
||||
|
||||
// Audio 30 ms EARLY at a 20 ms depth ⇒ aim 50 ms deep: above the floor, under the 90 ms
|
||||
// cap, so the ring has no reason to refuse.
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: -30, depth: 20 * perMS, count: 400)
|
||||
ring.setSyncTarget(s.desiredDepth(currentDepth: 20 * perMS))
|
||||
XCTAssertEqual(ring.stats.targetMS, 50, "the ring must adopt a legal request")
|
||||
|
||||
// And releasing it returns the ring to exactly where it was.
|
||||
ring.setSyncTarget(nil)
|
||||
XCTAssertEqual(ring.stats.targetMS, 20)
|
||||
}
|
||||
|
||||
/// THE safety invariant: sync only ever proposes. Continuity — the underrun-driven floor —
|
||||
/// outranks it, or a lossy link would be "synced" into dropouts. Pinned against a GROWN floor,
|
||||
/// not just the base, because the floor sync is most likely to argue with is the one a bad link
|
||||
/// earned.
|
||||
func testSyncCanNeverStarveTheRing() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor above the base with three clustered genuine underruns (same shape as
|
||||
// testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet).
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
let floor = ring.stats.targetMS
|
||||
XCTAssertGreaterThan(floor, 20, "the test needs a GROWN floor to be meaningful")
|
||||
|
||||
// Ask for an absurdly shallow ring — zero.
|
||||
ring.setSyncTarget(0)
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, floor,
|
||||
"sync pulled the target below the continuity floor — a link that needs the buffer must "
|
||||
+ "keep it, and the residual gets reported instead")
|
||||
// One frame under the floor is still under the floor.
|
||||
ring.setSyncTarget(floor * perMS - perMS)
|
||||
XCTAssertEqual(ring.stats.targetMS, floor)
|
||||
// And it may not blow past the hard cap either — added latency stays bounded.
|
||||
ring.setSyncTarget(Int.max / 2)
|
||||
XCTAssertLessThanOrEqual(ring.stats.targetMS, 90, "sync pushed the target past the hard cap")
|
||||
}
|
||||
|
||||
/// A device whose callback quantum alone exceeds the hard cap puts the continuity floor ABOVE
|
||||
/// the ceiling. The floor must win: clamping naively (`min(max(s, floor), cap)`) would hand
|
||||
/// back the cap — quietly below the floor, inverting the whole ordering — on exactly the
|
||||
/// awkward hardware this code exists to survive.
|
||||
func testAHugeDeviceQuantumDoesNotInvertTheClamp() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels * 2, channels: channels)
|
||||
let quantumMS = 500 // absurd, but not a reason to starve the callback
|
||||
primeQuantum(ring, quantumMS: quantumMS)
|
||||
ring.setSyncTarget(0)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
ring.stats.targetMS, quantumMS,
|
||||
"the target must still be able to serve one callback")
|
||||
}
|
||||
|
||||
/// A ring that ratcheted during a transient must not hold audio late for minutes after the
|
||||
/// cause is gone: with sync asking for less, the relax window is the short one.
|
||||
func testSyncPressureRelaxesAGrownTargetSoonerThanTimeAlone() {
|
||||
let want = 5 * perMS
|
||||
|
||||
func grow(_ ring: AudioRing) {
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
}
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
}
|
||||
/// Quiet (full) reads needed before the grown target relaxes one step. The ring is
|
||||
/// refilled DEEP first: a knife-edge refill (exactly what each read takes) leaves less
|
||||
/// than a frame over every callback, which now correctly reads as pressure — near-misses
|
||||
/// — and pressure never relaxes anything.
|
||||
func quietToRelax(_ ring: AudioRing) -> Int {
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 60 * perMS) }
|
||||
let start = ring.stats.targetMS
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == start, reads < 200_000 {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 5 * perMS) }
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
reads += 1
|
||||
}
|
||||
return reads
|
||||
}
|
||||
|
||||
let slow = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
grow(slow)
|
||||
slow.setSyncTarget(nil)
|
||||
let slowReads = quietToRelax(slow)
|
||||
|
||||
let fast = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
grow(fast)
|
||||
fast.setSyncTarget(perMS) // strictly shallower than the grown target
|
||||
let fastReads = quietToRelax(fast)
|
||||
|
||||
XCTAssertLessThan(
|
||||
fastReads, slowReads,
|
||||
"sync pressure should relax sooner: \(fastReads) vs \(slowReads) quiet reads")
|
||||
}
|
||||
|
||||
/// A shrink answered by an underrun or near-miss inside its probe window is undone AT ONCE,
|
||||
/// and the sync loop is backed off — mirrors the Rust `a_failed_shrink_probe_is_undone_at_once`
|
||||
/// and `a_failed_probe_backs_the_sync_shrink_off`. Before this, the loop re-probed a proven
|
||||
/// depth every five quiet seconds and paid an audible starvation event each time it was wrong,
|
||||
/// forever — the 0.25.0 MacBook field report.
|
||||
func testAFailedShrinkProbeIsUndoneAtOnceAndBacksTheSyncLoopOff() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor (near-miss + a cluster of genuine underruns), as the usual pattern does.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
let grown = ring.stats.targetMS
|
||||
XCTAssertGreaterThan(grown, 20, "the test needs a GROWN floor to probe")
|
||||
|
||||
// Sync asks for less; a deep, genuinely quiet spell later the shrink probes.
|
||||
ring.setSyncTarget(perMS)
|
||||
write(ms: 60)
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == grown, reads < 10_000 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
reads += 1
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, grown - 10, "the sync-driven shrink must have probed")
|
||||
|
||||
// Drain to the knife edge: the last full read leaves nothing over — a near-miss, nobody
|
||||
// heard anything — and the probe must be undone on the spot.
|
||||
while ring.bufferedMS > 5 { read() }
|
||||
read()
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"a failed probe must restore the target on the first near-miss")
|
||||
XCTAssertEqual(ring.stats.underruns, 3, "and nothing audible may have paid for it")
|
||||
|
||||
// Backed off: two accelerated windows of clean, deep audio must NOT shrink again…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(2 * 5_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"the five-second cadence must be suspended after a failure")
|
||||
// …while the slow, pre-sync window eventually still tests one — backoff is not a freeze.
|
||||
for _ in 0..<(2 * 30_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertLessThan(
|
||||
ring.stats.targetMS, grown,
|
||||
"the slow window must still be allowed to test a shrink")
|
||||
}
|
||||
|
||||
/// Growth raises a promise; only a re-prime banks real depth. An underrun while the ring is
|
||||
/// HOLLOW — its depth AVERAGE far below the target — re-primes immediately, spending the click
|
||||
/// it already cost on the whole refill, instead of riding the knife edge and clicking once per
|
||||
/// bunching period indefinitely. The average, not the instant, is what separates a hollow ring
|
||||
/// from one late packet (`testSingleShortReadDoesNotDeprime` pins that side).
|
||||
func testAHollowRingReprimesOnItsFirstClick() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor to 40 the usual way…
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
XCTAssertEqual(ring.stats.targetMS, 40)
|
||||
// …then ride the knife edge for ~2 s of audio, so the depth average genuinely sinks far
|
||||
// below the promised 40 ms.
|
||||
for _ in 0..<400 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
// One dry read — the click. The ring is hollow, so this single click must re-prime.
|
||||
read()
|
||||
// A packet arrives, but the ring stays SILENT: it is re-priming toward the full target
|
||||
// rather than playing the packet and clicking again at the next bunch.
|
||||
write(ms: 10)
|
||||
read()
|
||||
XCTAssertTrue(
|
||||
scratch.allSatisfy { $0 == 0 },
|
||||
"a hollow ring must spend its click on the whole refill, not keep limping")
|
||||
// And once the refill reaches the target, it plays again.
|
||||
write(ms: 40)
|
||||
read()
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "refilled to target — playback resumes")
|
||||
}
|
||||
|
||||
/// The four client rings adopt sync one at a time; an un-wired one must behave exactly as it
|
||||
/// did. `nil` is the default, so this pins the initializer too — and every other test in this
|
||||
/// file runs without a sync target, which is the real guard that nothing moved underneath them.
|
||||
func testNoSyncTargetLeavesTheRingExactlyAsItWas() {
|
||||
let a = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let b = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
b.setSyncTarget(nil)
|
||||
let want = 5 * perMS
|
||||
var sa = [Float](repeating: 0, count: want)
|
||||
var sb = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 30 * perMS)
|
||||
for step in 0..<4_000 {
|
||||
// Uneven delivery so the depth actually moves around and the two rings have something
|
||||
// to disagree about.
|
||||
if step % 7 == 0 {
|
||||
for r in [a, b] {
|
||||
feed.withUnsafeBufferPointer { r.write($0.baseAddress!, count: 30 * perMS) }
|
||||
}
|
||||
}
|
||||
sa.withUnsafeMutableBufferPointer { a.read(into: $0.baseAddress!, count: want) }
|
||||
sb.withUnsafeMutableBufferPointer { b.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertEqual(sa, sb, "step \(step): an explicit nil diverged from the default")
|
||||
XCTAssertEqual(a.stats.targetMS, b.stats.targetMS, "step \(step)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The reporting half of §1.3: the offset must reach the same snapshot the depth does, because
|
||||
/// a depth on its own cannot distinguish "deep because the link needs it" from "deep and
|
||||
/// therefore late". This is the number the HUD and the 1 Hz log line read.
|
||||
func testAvOffsetIsReportedAlongsideTheDepth() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
XCTAssertEqual(ring.stats.avOffsetMS, 0, "no evidence yet reads as zero, not as noise")
|
||||
let feed = [Float](repeating: 0.5, count: 30 * perMS)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 30 * perMS) }
|
||||
|
||||
var s = AvSync(channels: channels)
|
||||
settle(&s, offsetMS: 37, depth: 30 * perMS, count: 400)
|
||||
ring.noteAvOffset(s.offsetMS)
|
||||
let stats = ring.stats
|
||||
XCTAssertEqual(stats.bufferedMS, 30)
|
||||
XCTAssertEqual(stats.avOffsetMS, 37, "positive = audio behind the picture")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is
|
||||
// derived (like the wire scale constants), so these tests are the contract: if on-glass says an
|
||||
// axis is wrong, fix the enum AND these expectations together.
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DeviceGyroRemapTests: XCTestCase {
|
||||
/// A distinct vector per axis so a swapped or flipped component can't cancel out.
|
||||
private let v: (x: Float, y: Float, z: Float) = (1, 2, 3)
|
||||
|
||||
func testPortraitIsIdentity() {
|
||||
let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3])
|
||||
}
|
||||
|
||||
func testUpsideDownFlipsInPlane() {
|
||||
let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's LEFT: player-right = device-bottom (−y), player-up =
|
||||
/// device-right (+x). z (out of the screen) never changes — the screen faces the player.
|
||||
func testTopLeftLandscape() {
|
||||
let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3])
|
||||
}
|
||||
|
||||
/// Device top to the player's RIGHT: player-right = device-top (+y), player-up =
|
||||
/// device-left (−x).
|
||||
func testTopRightLandscape() {
|
||||
let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z)
|
||||
XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3])
|
||||
}
|
||||
|
||||
/// Interface orientation → remap: `.landscapeRight` means the Home edge is on the
|
||||
/// player's right, i.e. the device top points LEFT (and vice versa).
|
||||
func testOrientationMapping() {
|
||||
XCTAssertEqual(DeviceGyroRemap(.portrait), .identity)
|
||||
XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft)
|
||||
XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight)
|
||||
XCTAssertEqual(DeviceGyroRemap(.unknown), .identity)
|
||||
}
|
||||
|
||||
/// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping.
|
||||
func testHandednessPreserved() {
|
||||
for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] {
|
||||
let x = remap.apply(x: 1, y: 0, z: 0)
|
||||
let y = remap.apply(x: 0, y: 1, z: 0)
|
||||
// Cross product of the two mapped in-plane basis vectors.
|
||||
let cross = (
|
||||
x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x
|
||||
)
|
||||
XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,91 @@
|
||||
// The motion frame conversion, pinned against the readings it was derived from.
|
||||
//
|
||||
// On 2026-08-07 one physical DualSense was read twice on one desk — over raw HID (the pad's own
|
||||
// report) and through GameController — so both frames come from the same controller in the same
|
||||
// orientations rather than from two documents:
|
||||
//
|
||||
// DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll
|
||||
// GameController frame: (Right, Forward, Up)
|
||||
//
|
||||
// The numbers below are those measurements. They are the reason the conversion is `(x, z, -y)` and
|
||||
// not one of the five other permutations that also move gravity to slot 1, so they belong in a test
|
||||
// rather than only in a commit message.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadMotionFrameTests: XCTestCase {
|
||||
private func wire(_ v: (Float, Float, Float)) -> (Float, Float, Float) {
|
||||
GamepadWire.appleMotionToWire(v)
|
||||
}
|
||||
|
||||
/// Gravity at rest, face up. MEASURED: GameController read (+0.005, -0.192, +0.992) g while raw
|
||||
/// HID on the same pad read (+0.021, +0.997, +0.160). The conversion has to carry one into the
|
||||
/// other — including the small tilt term, which is what distinguishes this mapping from the one
|
||||
/// that merely gets gravity onto the right slot.
|
||||
func testRestingGravityLandsInTheDualSenseFrame() {
|
||||
let apple: (Float, Float, Float) = (0.005, -0.192, 0.992)
|
||||
let w = wire(apple)
|
||||
XCTAssertEqual(w.0, 0.005, accuracy: 0.001, "right stays on slot 0")
|
||||
XCTAssertEqual(w.1, 0.992, accuracy: 0.001, "up moves to slot 1 — the pad reads +1 g here")
|
||||
XCTAssertEqual(w.2, 0.192, accuracy: 0.001, "slot 2 is Backward, so GC's Forward negates")
|
||||
// The hardware's own reading of the same pose, to the precision two sessions of holding a
|
||||
// controller by hand can agree to.
|
||||
XCTAssertEqual(w.1, 0.997, accuracy: 0.02)
|
||||
XCTAssertEqual(w.2, 0.160, accuracy: 0.05)
|
||||
}
|
||||
|
||||
/// The tilt term's SIGN is the whole point: before this conversion the client sent Apple's y
|
||||
/// straight through, so a pad tilted nose-up reported itself tilted nose-down.
|
||||
func testTheForeAftAxisIsNegatedNotJustMoved() {
|
||||
XCTAssertEqual(wire((0, 1, 0)).2, -1, "GC +y (Forward) is the wire's -Backward")
|
||||
XCTAssertEqual(wire((0, -1, 0)).2, 1)
|
||||
XCTAssertEqual(wire((0, 1, 0)).0, 0, "and it must not leak into the other slots")
|
||||
XCTAssertEqual(wire((0, 1, 0)).1, 0)
|
||||
}
|
||||
|
||||
/// Each rotation, as measured, must reach the slot the wire reads it from: the wire's gyro is
|
||||
/// documented pitch/yaw/roll in slots 0/1/2, and the raw-HID run confirmed the pad agrees.
|
||||
func testEachRotationReachesItsWireSlot() {
|
||||
// Yaw is the reliable direct measurement — a continuous one-way spin, clockwise from above,
|
||||
// read as NEGATIVE on GC's z. It must arrive negative on slot 1, where the pad puts yaw.
|
||||
let yaw = wire((-0.2, 21.7, -122.2))
|
||||
XCTAssertEqual(yaw.1, -122.2, accuracy: 0.01)
|
||||
XCTAssertLessThan(yaw.1, 0, "clockwise-from-above is negative about +Up, both frames agree")
|
||||
|
||||
// Pitch: nose-down about Right stays on slot 0 and keeps its sign.
|
||||
let pitch = wire((-79.4, 0, 0))
|
||||
XCTAssertEqual(pitch.0, -79.4, accuracy: 0.01)
|
||||
|
||||
// Roll: about the fore-aft axis, which moves to slot 2 AND flips.
|
||||
let roll = wire((0, 61.8, 0))
|
||||
XCTAssertEqual(roll.2, -61.8, accuracy: 0.01)
|
||||
}
|
||||
|
||||
/// A change of basis is linear and orthonormal: it may not stretch a vector, and applying it to
|
||||
/// gyro and to acceleration must be the same operation. Both are asserted because the capture
|
||||
/// path calls it twice, on two different quantities.
|
||||
func testConversionIsAnIsometry() {
|
||||
for v in [(1, 2, 3), (-4, 5, -6), (0, 0, 1), (7, 0, 0)] as [(Float, Float, Float)] {
|
||||
let w = wire(v)
|
||||
let before = (v.0 * v.0 + v.1 * v.1 + v.2 * v.2).squareRoot()
|
||||
let after = (w.0 * w.0 + w.1 * w.1 + w.2 * w.2).squareRoot()
|
||||
XCTAssertEqual(before, after, accuracy: 1e-4, "must not change magnitude")
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-handed in, right-handed out. A permutation with the wrong number of sign flips is a
|
||||
/// REFLECTION, which reads as plausible on every single axis and inverts every rotation — the
|
||||
/// exact failure this measurement exists to prevent.
|
||||
func testHandednessIsPreserved() {
|
||||
let x = wire((1, 0, 0))
|
||||
let y = wire((0, 1, 0))
|
||||
// x cross y must equal the image of z, not its negative.
|
||||
let cx = (x.1 * y.2 - x.2 * y.1, x.2 * y.0 - x.0 * y.2, x.0 * y.1 - x.1 * y.0)
|
||||
let z = wire((0, 0, 1))
|
||||
XCTAssertEqual(cx.0, z.0, accuracy: 1e-5)
|
||||
XCTAssertEqual(cx.1, z.1, accuracy: 1e-5)
|
||||
XCTAssertEqual(cx.2, z.2, accuracy: 1e-5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Whether a given pad's motion can reach the game. The Swift half of punktfunk-core's
|
||||
// `pad_motion_reaches` — same rows as `config::tests::motion_reach_is_answered_per_pad_not_per_session`,
|
||||
// because a client that disagrees with the host about this either kills a working gyro or keeps
|
||||
// streaming ~250 Hz of samples nobody reads, and both failures are silent.
|
||||
|
||||
import PunktfunkCore
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadMotionReachTests: XCTestCase {
|
||||
private typealias Pad = PunktfunkConnection.GamepadType
|
||||
|
||||
func testOnlyTheXboxClassesLackAMotionPlane() {
|
||||
for kind: Pad in [.xbox360, .xboxOne] {
|
||||
XCTAssertFalse(kind.hasMotion, "\(kind) should have no motion plane")
|
||||
}
|
||||
for kind: Pad in [
|
||||
.dualSense, .dualShock4, .dualSenseEdge, .switchPro,
|
||||
.steamController, .steamDeck, .steamController2,
|
||||
] {
|
||||
XCTAssertTrue(kind.hasMotion, "\(kind) should carry motion")
|
||||
}
|
||||
// Unknown must not suppress: an older host that omitted the echo may well have resolved a
|
||||
// DualSense, and silently killing its gyro is worse than sending into a void.
|
||||
XCTAssertTrue(Pad.auto.hasMotion)
|
||||
}
|
||||
|
||||
/// The per-pad question, case by case. Each row is a session a player can actually sit down to;
|
||||
/// the comment says which of the three inputs decides it.
|
||||
func testMotionReachIsAnsweredPerPadNotPerSession() {
|
||||
// The case this predicate exists for, and the one a session-level check gets WRONG:
|
||||
// "Automatic" with mixed pads. The handshake carries the active pad's kind (an X-Box pad),
|
||||
// so the echo says X-Box 360 — but pad 1 declared a DualSense and the host built it one,
|
||||
// with a motion plane. Reading the echo here kills a gyro that works.
|
||||
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .xbox360, resolved: .xbox360))
|
||||
// Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion.
|
||||
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .xbox360, resolved: .xbox360))
|
||||
|
||||
// An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared ==
|
||||
// asked, so the echo is this pad's answer and catches a fold nothing local could predict.
|
||||
XCTAssertFalse(
|
||||
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .xbox360))
|
||||
// The same declaration against a Linux host that builds it: unchanged, motion reaches.
|
||||
XCTAssertTrue(
|
||||
Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .switchPro))
|
||||
|
||||
// A DualSense wish on a host with no usable /dev/uhid degrades the same way.
|
||||
XCTAssertFalse(
|
||||
Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .xbox360))
|
||||
|
||||
// Nobody connected at dial time, so the handshake asked `.auto` and the host resolved it
|
||||
// from its own env. A pad that shows up later declares its own kind and is judged on that.
|
||||
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .auto, resolved: .xbox360))
|
||||
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .auto, resolved: .dualSense))
|
||||
|
||||
// An old host that echoes nothing leaves `.auto`, which must not suppress.
|
||||
XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .auto))
|
||||
// Even then the declaration still speaks when it is the thing without a plane.
|
||||
XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .dualSense, resolved: .auto))
|
||||
}
|
||||
}
|
||||
@@ -71,4 +71,65 @@ final class LatencyMeterTests: XCTestCase {
|
||||
m.record(ptsNs: now - 20_000_000_000, offsetNs: 0)
|
||||
XCTAssertNil(m.drain())
|
||||
}
|
||||
|
||||
// MARK: - latestSample: the A/V sync loop's video reference
|
||||
|
||||
/// The end-to-end meter doubles as the reference the audio ring steers against, so its most
|
||||
/// recent sample must be readable as a LEVEL — without consuming it, and independently of the
|
||||
/// 1 Hz percentile window the HUD drains.
|
||||
func testLatestSampleSurvivesDrainAndIsNotAWindow() {
|
||||
let m = LatencyMeter()
|
||||
let atNs: Int64 = 1_000_000_000_000
|
||||
m.record(ptsNs: UInt64(atNs - 12_000_000), atNs: atNs, offsetNs: 0) // 12 ms
|
||||
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 12_000_000)
|
||||
_ = m.drain()
|
||||
XCTAssertEqual(
|
||||
m.latestSample(asOfNs: atNs, maxAgeMs: 500), 12_000_000,
|
||||
"the reference is a level — draining the percentile window must not clear it")
|
||||
// …and it tracks the newest frame.
|
||||
m.record(ptsNs: UInt64(atNs - 20_000_000), atNs: atNs, offsetNs: 0)
|
||||
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 20_000_000)
|
||||
}
|
||||
|
||||
/// No frame yet ⇒ no reference. This is what keeps the sync loop inert at session start and
|
||||
/// under the stage-1 presenter, which stamps no present at all.
|
||||
func testLatestSampleIsNilBeforeAnyFrame() {
|
||||
XCTAssertNil(LatencyMeter().latestSample(asOfNs: 1_000_000_000_000, maxAgeMs: 500))
|
||||
}
|
||||
|
||||
/// THE staleness gate: video can stop while audio keeps playing (the backgrounded keep-alive
|
||||
/// drops decode entirely). A level with no expiry would go on offering a minutes-old figure as
|
||||
/// though it were live, and the ring would be steered against a frozen reference.
|
||||
func testLatestSampleExpires() {
|
||||
let m = LatencyMeter()
|
||||
let atNs: Int64 = 1_000_000_000_000
|
||||
m.record(ptsNs: UInt64(atNs - 12_000_000), atNs: atNs, offsetNs: 0)
|
||||
XCTAssertNotNil(m.latestSample(asOfNs: atNs + 499_000_000, maxAgeMs: 500))
|
||||
XCTAssertNil(
|
||||
m.latestSample(asOfNs: atNs + 501_000_000, maxAgeMs: 500),
|
||||
"a stale reference must read as NO reference, not as a live one")
|
||||
// A stamp marginally ahead of the reader's clock is normal (the deadline presenter stamps
|
||||
// at the link's TARGET present time) and must not drop the only reference we have.
|
||||
XCTAssertNotNil(m.latestSample(asOfNs: atNs - 8_000_000, maxAgeMs: 500))
|
||||
}
|
||||
|
||||
/// A sample the meter refused must not become a reference either — the sync loop would then be
|
||||
/// steered by a value the percentile window itself judged absurd.
|
||||
///
|
||||
/// The ABSURDLY LARGE case is the load-bearing one: a negative interval would also be stopped
|
||||
/// by `latestSample`'s own `> 0` check, so on its own it proves nothing about where the publish
|
||||
/// sits relative to the guard.
|
||||
func testRefusedSampleIsNotPublishedAsAReference() {
|
||||
let m = LatencyMeter()
|
||||
let atNs: Int64 = 1_000_000_000_000
|
||||
m.record(ptsNs: UInt64(atNs - 20_000_000_000), atNs: atNs, offsetNs: 0) // 20 s → refused
|
||||
XCTAssertNil(
|
||||
m.latestSample(asOfNs: atNs, maxAgeMs: 500),
|
||||
"a sample too absurd for the window is too absurd to steer the ring")
|
||||
m.record(ptsNs: UInt64(atNs + 1), atNs: atNs, offsetNs: 0) // negative interval
|
||||
XCTAssertNil(m.latestSample(asOfNs: atNs, maxAgeMs: 500))
|
||||
// …and a good sample after them still lands, so the refusals cost nothing.
|
||||
m.record(ptsNs: UInt64(atNs - 9_000_000), atNs: atNs, offsetNs: 0)
|
||||
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 9_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,234 @@ final class LibraryClientTests: XCTestCase {
|
||||
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
|
||||
XCTAssertNil(resolved.logo)
|
||||
}
|
||||
|
||||
// MARK: - HTTP response parsing (MgmtTransport)
|
||||
|
||||
// The management API is reached over Network.framework rather than URLSession (ATS cannot be
|
||||
// relaxed for the arbitrary addresses a host lives at — see MgmtTransport), so we parse HTTP
|
||||
// ourselves. These cover the framings hyper actually emits, plus the failure modes where
|
||||
// getting it wrong would be silent.
|
||||
|
||||
private func raw(_ text: String) -> Data { Data(text.utf8) }
|
||||
|
||||
func testParsesContentLengthFramedJSON() throws {
|
||||
let body = #"[{"id":"steam:570"}]"#
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
|
||||
+ "Content-Length: \(body.utf8.count)\r\n\r\n\(body)"))
|
||||
XCTAssertEqual(response.status, 200)
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), body)
|
||||
// Field names are case-insensitive per RFC 9110.
|
||||
XCTAssertEqual(response.header("CONTENT-TYPE"), "application/json")
|
||||
}
|
||||
|
||||
func testParsesChunkedBody() throws {
|
||||
// How hyper streams the art proxy.
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "hello world")
|
||||
}
|
||||
|
||||
func testChunkExtensionsAndTrailersAreIgnored() throws {
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "3;foo=bar\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
|
||||
}
|
||||
|
||||
func testUnauthorizedStatusSurvives() throws {
|
||||
// What an unpaired certificate gets from the host — the status is the whole signal.
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertEqual(response.status, 401)
|
||||
XCTAssertTrue(response.body.isEmpty)
|
||||
}
|
||||
|
||||
func testBodyRunsToEOFWithoutFramingHeaders() throws {
|
||||
let response = try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\n\r\nraw-to-eof"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "raw-to-eof")
|
||||
}
|
||||
|
||||
func testTruncatedBodyThrowsRatherThanReturningPartialJSON() {
|
||||
// The one that matters: a body cut short must NOT come back as success. A clipped JSON
|
||||
// array would decode to fewer games — "this host has no games" — instead of an error.
|
||||
XCTAssertThrowsError(
|
||||
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\nshort")))
|
||||
}
|
||||
|
||||
func testOverLongBodyIsClippedToContentLength() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
|
||||
}
|
||||
|
||||
func testMalformedResponsesThrow() {
|
||||
// Header block never terminated (peer hung up), a non-HTTP greeting, and a chunked stream
|
||||
// cut mid-chunk.
|
||||
XCTAssertThrowsError(
|
||||
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n")))
|
||||
XCTAssertThrowsError(try HTTPResponseParser.parse(raw("NOT-HTTP\r\n\r\n")))
|
||||
XCTAssertThrowsError(try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\nabc")))
|
||||
}
|
||||
|
||||
func testMultiWordReasonPhraseParses() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertEqual(response.status, 404)
|
||||
}
|
||||
|
||||
func testBinaryBodySurvivesByteForByte() throws {
|
||||
// Posters are PNG/JPEG: the body must never be round-tripped through a String.
|
||||
let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0x0D, 0x0A]
|
||||
var message = raw("HTTP/1.1 200 OK\r\nContent-Length: \(png.count)\r\n\r\n")
|
||||
message.append(contentsOf: png)
|
||||
let response = try HTTPResponseParser.parse(message)
|
||||
XCTAssertEqual([UInt8](response.body), png)
|
||||
}
|
||||
|
||||
// MARK: - Message framing (keep-alive)
|
||||
|
||||
// Connections are pooled and reused, so a response has to be delimited WITHOUT waiting for
|
||||
// the peer to hang up. Getting this wrong either truncates a response or bleeds one response
|
||||
// into the next, and both would be silent.
|
||||
|
||||
func testMessageLengthDelimitsContentLengthFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))
|
||||
}
|
||||
|
||||
func testMessageLengthDelimitsChunkedFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
// Mid-chunk, and terminal chunk without its closing blank line.
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n")))
|
||||
// Trailers belong to the message and must be consumed with it.
|
||||
let trailered = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "3\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: trailered), trailered.count)
|
||||
}
|
||||
|
||||
func testMessageLengthIsNilWithoutFraming() throws {
|
||||
// No Content-Length and not chunked ⇒ the body runs to EOF and the connection can't be
|
||||
// reused. Partial header blocks are likewise "not yet".
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\n\r\nto-eof")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\nContent-Len")))
|
||||
}
|
||||
|
||||
func testBackToBackResponsesSplitExactly() throws {
|
||||
// The reuse case that matters: two responses arriving in one read.
|
||||
let first = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
let second = raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nbye")
|
||||
let both = first + second
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: both), first.count)
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.prefix(first.count)).body,
|
||||
as: UTF8.self), "hello")
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.dropFirst(first.count)).body,
|
||||
as: UTF8.self), "bye")
|
||||
}
|
||||
|
||||
func testConnectionCloseIsDetected() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertTrue(response.wantsClose)
|
||||
// HTTP/1.1 keeps the connection open unless told otherwise.
|
||||
XCTAssertFalse(try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")).wantsClose)
|
||||
}
|
||||
|
||||
// MARK: - Art cache
|
||||
|
||||
private func temporaryCacheDirectory() -> URL {
|
||||
URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("pf-art-test-\(UUID().uuidString)", isDirectory: true)
|
||||
}
|
||||
|
||||
func testArtCacheRoundTripsBinaryAndSeparatesKeys() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
|
||||
let portrait = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/portrait")!
|
||||
let header = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/header")!
|
||||
|
||||
var hit = await cache.data(for: portrait)
|
||||
XCTAssertNil(hit, "cold cache must miss")
|
||||
await cache.store(png, for: portrait)
|
||||
hit = await cache.data(for: portrait)
|
||||
XCTAssertEqual(hit, png, "posters are binary; the body must survive byte-for-byte")
|
||||
// Sibling art of the same title must not collide.
|
||||
let sibling = await cache.data(for: header)
|
||||
XCTAssertNil(sibling)
|
||||
}
|
||||
|
||||
func testArtCacheRefusesEmptyAndInlineData() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let empty = URL(string: "https://cdn.example.com/empty.jpg")!
|
||||
await cache.store(Data(), for: empty)
|
||||
let emptyHit = await cache.data(for: empty)
|
||||
XCTAssertNil(emptyHit, "an empty body is not art")
|
||||
|
||||
let inline = URL(string: "data:image/png;base64,iVBORw0KGgo=")!
|
||||
await cache.store(Data("x".utf8), for: inline)
|
||||
let inlineHit = await cache.data(for: inline)
|
||||
XCTAssertNil(inlineHit, "a data: URL is already inline — caching it is a pure loss")
|
||||
}
|
||||
|
||||
func testArtCacheAgesEntriesOut() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory, maxAge: 0.4)
|
||||
let url = URL(string: "https://cdn.example.com/stale.jpg")!
|
||||
|
||||
await cache.store(Data("stale".utf8), for: url)
|
||||
let fresh = await cache.data(for: url)
|
||||
XCTAssertNotNil(fresh)
|
||||
try await Task.sleep(nanoseconds: 700_000_000)
|
||||
let expired = await cache.data(for: url)
|
||||
XCTAssertNil(expired)
|
||||
}
|
||||
|
||||
func testArtCacheEvictsLeastRecentlyUsedOverBudget() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
// Budget holds three of these; the fourth store must evict.
|
||||
let cache = ArtCache(directory: directory, maxBytes: 300)
|
||||
let blob = Data(repeating: 0x41, count: 100)
|
||||
var urls: [URL] = []
|
||||
for i in 0..<4 {
|
||||
let url = URL(string: "https://cdn.example.com/blob\(i).jpg")!
|
||||
urls.append(url)
|
||||
await cache.store(blob, for: url)
|
||||
try await Task.sleep(nanoseconds: 60_000_000) // distinct mtimes for LRU ordering
|
||||
}
|
||||
let evicted = await cache.data(for: urls[0])
|
||||
XCTAssertNil(evicted, "the oldest entry should have been evicted")
|
||||
let newest = await cache.data(for: urls[3])
|
||||
XCTAssertEqual(newest, blob)
|
||||
}
|
||||
|
||||
func testBaseURLBracketsIPv6Only() {
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
|
||||
"https://192.168.1.70:47990")
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "100.101.102.103", port: 47990),
|
||||
"https://100.101.102.103:47990")
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "fd7a:115c::1", port: 47990),
|
||||
"https://[fd7a:115c::1]:47990")
|
||||
// An address the user pasted already bracketed must not end up double-bracketed.
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "[fd7a:115c::1]", port: 47990),
|
||||
"https://[fd7a:115c::1]:47990")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates listed
|
||||
below. Each is distributed under its own permissive license; the full license texts
|
||||
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
|
||||
@@ -62,7 +62,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
cairo-sys-rs 0.22.0 — MIT — https://github.com/gtk-rs/gtk-rs-core
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cexpr 0.6.0 — Apache-2.0/MIT — https://github.com/jethrogb/rust-cexpr
|
||||
cfg-expr 0.20.8 — MIT OR Apache-2.0 — https://github.com/EmbarkStudios/cfg-expr
|
||||
@@ -117,7 +117,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
field-offset 0.3.6 — MIT OR Apache-2.0 — https://github.com/Diggsey/rust-field-offset
|
||||
filetime 0.2.29 — MIT/Apache-2.0 — https://github.com/alexcrichton/filetime
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flate2 1.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/flate2-rs
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
@@ -1625,7 +1625,7 @@ DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: assert_matches 1.5.0, async-channel 2.5.0, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.9, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, glob 0.3.3, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, nasm-rs 0.3.2, num-integer 0.1.46, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1, xattr 1.6.1
|
||||
The following license (LICENSE-APACHE) applies to: assert_matches 1.5.0, async-channel 2.5.0, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, glob 0.3.3, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, nasm-rs 0.3.2, num-integer 0.1.46, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1, xattr 1.6.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -2921,7 +2921,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
|
||||
@@ -1575,7 +1575,8 @@ pub fn show_scoped(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad on the host — Automatic matches your controller",
|
||||
"The virtual pad on the host — Automatic matches your controller. An X-Box type has no \
|
||||
gyroscope, so pick a DualSense-class one if you want motion.",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
|
||||
@@ -1156,7 +1156,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
if args.rich_input_test {
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
use punktfunk_core::input::gamepad::AXIS_LS_X;
|
||||
use punktfunk_core::input::gamepad::{AXIS_LS_X, MOTION_ACCEL_LSB_PER_G};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
// A neutral gamepad axis event makes the host create the virtual DualSense pad 0.
|
||||
@@ -1184,12 +1184,14 @@ async fn session(args: Args) -> Result<()> {
|
||||
for i in 0..60u32 {
|
||||
let x = ((i * 65535) / 60) as u16;
|
||||
let _ = conn2.send_datagram(touch(true, x, 32768).encode().into());
|
||||
let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble
|
||||
let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble, ±250 °/s
|
||||
let _ = conn2.send_datagram(
|
||||
RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [g, 0, 0],
|
||||
accel: [0, 0, 16384],
|
||||
// At rest, gravity is 1 g on +Z — in WIRE units, which are 10000 LSB/g
|
||||
// and not the 8192 or 16384 a particular driver happens to use.
|
||||
accel: [0, 0, MOTION_ACCEL_LSB_PER_G as i16],
|
||||
}
|
||||
.encode()
|
||||
.into(),
|
||||
|
||||
@@ -69,8 +69,8 @@ Video, then the platform's own (pf-dxvadec on Windows, pf-vaadec on Linux), then
|
||||
rung (openh264/rav1d). The libavcodec rungs that used to sit under each of them are
|
||||
deleted, along with `pf-ffvk` and the `ffmpeg-next` dependency.
|
||||
|
||||
Two of the native rungs have never decoded a frame on real hardware (native VAAPI at all;
|
||||
native D3D11VA's AV1 leg). They run anyway — with the libavcodec twins gone, the only
|
||||
One of the native rungs has never decoded a frame on real hardware (native VAAPI's H.264 and
|
||||
H.265 legs; its AV1 leg has decoded but has never been parity-checked). It runs anyway — with the libavcodec twins gone, the only
|
||||
thing below them is the CPU, so barring them would cost the session hardware decode
|
||||
outright rather than move it one rung down. What replaces the safety net is the log: every
|
||||
session names the rung it landed on with its evidence state,
|
||||
|
||||
@@ -266,6 +266,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
ActionOutcome::Start(Box::new(params))
|
||||
}
|
||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||
// Also run-loop-side: the clipboard belongs to SDL, which this callback
|
||||
// has no handle on. Unreachable in practice — listed so adding an action
|
||||
// to the enum keeps failing loudly here instead of falling into a
|
||||
// wildcard that silently drops it.
|
||||
OverlayAction::CopyText(_) => ActionOutcome::Handled,
|
||||
OverlayAction::Quit => ActionOutcome::Quit,
|
||||
}
|
||||
});
|
||||
@@ -286,6 +291,21 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A console row key → its index in the known-hosts store. The key is the pinned
|
||||
/// fingerprint when there is one, else `addr:port` (see the row builder), and a pinned
|
||||
/// CARD's key carries the profile id past a NUL — the console strips that before it
|
||||
/// sends a command, so nothing here has to.
|
||||
fn index_for_key(known: &trust::KnownHosts, key: &str) -> Option<usize> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
})
|
||||
}
|
||||
|
||||
fn host_display_name(name: &str, addr: &str) -> String {
|
||||
if name.trim().is_empty() {
|
||||
addr.to_string()
|
||||
@@ -483,6 +503,48 @@ impl ServiceState {
|
||||
}
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now
|
||||
}
|
||||
ConsoleCmd::UpdateHost {
|
||||
key,
|
||||
name,
|
||||
addr,
|
||||
port,
|
||||
} => {
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let Some(h) = index_for_key(&known, &key).and_then(|i| known.hosts.get_mut(i))
|
||||
else {
|
||||
tracing::warn!(%key, "edit for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
// Edited IN PLACE rather than removed and re-added: the fingerprint, the
|
||||
// learned MAC, the pinned cards and the profile binding all hang off this
|
||||
// entry, and re-adding would silently unpair a host the user only renamed.
|
||||
h.name = if name.trim().is_empty() {
|
||||
addr.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
h.addr = addr;
|
||||
h.port = port;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60); // the address moved
|
||||
}
|
||||
ConsoleCmd::ForgetHost { key } => {
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let Some(i) = index_for_key(&known, &key) else {
|
||||
tracing::warn!(%key, "forget for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
let gone = known.hosts.remove(i);
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
tracing::info!(name = %gone.name, addr = %gone.addr, "host forgotten");
|
||||
// It may still be advertising, in which case it comes straight back as a
|
||||
// DISCOVERED row — unsaved and unpaired, which is the honest state.
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::Wake { key, then_connect } => {
|
||||
if let Some(c) = self.wake_cancel.take() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
@@ -534,14 +596,7 @@ impl ServiceState {
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
|
||||
+111
-4
@@ -510,6 +510,65 @@ mod session_main {
|
||||
);
|
||||
}
|
||||
|
||||
/// The driver's own answers about video images, printed with nothing in front of
|
||||
/// them (`--probe-decode`).
|
||||
///
|
||||
/// Passing the five conjuncts above only says Vulkan Video EXISTS on a device; this
|
||||
/// says whether the zero-copy pipeline can be BUILT on it — a different question
|
||||
/// with, on at least one shipping driver, a different answer. Verbatim on purpose:
|
||||
/// the Intel Arc refusal was twice diagnosed from punktfunk's own error text and
|
||||
/// twice the diagnosis was wrong, and what broke it open both times was reading what
|
||||
/// the driver actually said.
|
||||
fn print_video_formats(a: &pf_presenter::vk::AdapterDecode) {
|
||||
use pf_presenter::vk::probe::{describe_create_flags, describe_usage};
|
||||
for p in &a.formats {
|
||||
println!(" {} (wants {:?}):", p.profile, p.wanted);
|
||||
for u in &p.usages {
|
||||
let answer = match &u.formats {
|
||||
Err(e) => format!("query failed: {e:?}"),
|
||||
Ok(entries) if entries.is_empty() => "no formats offered".to_string(),
|
||||
Ok(entries) => entries
|
||||
.iter()
|
||||
.map(|f| {
|
||||
format!(
|
||||
"{:?} usage={} create={} {:?} {:?}",
|
||||
f.format,
|
||||
describe_usage(f.image_usage),
|
||||
describe_create_flags(f.image_create_flags),
|
||||
f.image_type,
|
||||
f.image_tiling,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
};
|
||||
println!(" {:<24} {answer}", u.label);
|
||||
// The second opinion, printed only where it differs from the video
|
||||
// format query. Worded as "also asked" rather than "disagrees" on
|
||||
// purpose: measured on both vendors this call answers "creatable" for
|
||||
// combinations the video query rejects (NVIDIA included, for SAMPLED
|
||||
// alone), so it does not honour the profile list and a difference here
|
||||
// is NOT the driver contradicting itself. Printed anyway because the
|
||||
// question gets re-asked by everyone who reads a refusal.
|
||||
let listed = u
|
||||
.wanted_entry(p.wanted)
|
||||
.is_some_and(|f| f.image_usage.contains(u.usage));
|
||||
if listed != u.image_format_support.is_ok() {
|
||||
let second = match &u.image_format_support {
|
||||
Ok(()) => "creatable".to_string(),
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
println!(
|
||||
" {:<24} (also asked: \
|
||||
vkGetPhysicalDeviceImageFormatProperties2 says {second} — that \
|
||||
call does not honour the profile list; not authority)",
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
@@ -548,12 +607,21 @@ mod session_main {
|
||||
println!("no Vulkan physical devices");
|
||||
}
|
||||
for (i, a) in adapters.iter().enumerate() {
|
||||
// The index IS the PUNKTFUNK_VK_DEVICE value, and entry 0 is what
|
||||
// The bracketed number is the PUNKTFUNK_VK_DEVICE value, and the
|
||||
// FIRST listed entry is what
|
||||
// a default run presents on — the decoder shares that device, so
|
||||
// on a hybrid box this line is usually the answer.
|
||||
let kind = if a.discrete { "discrete" } else { "integrated" };
|
||||
// `a.index`, NOT the loop position. This list is sorted
|
||||
// discrete-first for reading, but PUNKTFUNK_VK_DEVICE indexes the
|
||||
// raw enumeration, which puts the iGPU first on some hybrids —
|
||||
// printing the loop position would name the other GPU on exactly
|
||||
// the machines this flag is for. The `i == 0` marker is still the
|
||||
// loop position, because sorted-first IS what pick_device lands on
|
||||
// when nothing overrides it.
|
||||
println!(
|
||||
"[{i}] {} ({kind}){}",
|
||||
"[{}] {} ({kind}){}",
|
||||
a.index,
|
||||
a.name,
|
||||
if i == 0 { " <- default presenter" } else { "" }
|
||||
);
|
||||
@@ -561,11 +629,29 @@ mod session_main {
|
||||
" vulkan video decode: {}",
|
||||
if a.usable { "YES" } else { "no" }
|
||||
);
|
||||
let codecs: Vec<&str> = [(0x1u32, "H.264"), (0x2, "H.265"), (0x4, "AV1")]
|
||||
// Name every bit, and ACCOUNT for the ones we cannot name. The
|
||||
// 5070 Ti reports 0xF — four bits — while punktfunk decodes three
|
||||
// codecs, so the first version of this line printed three names
|
||||
// beside a four-bit mask and looked complete. VP9 (bit 3) is a
|
||||
// real decode operation this client has no rung for; a codec the
|
||||
// tool cannot name must not silently vanish from a mask it prints,
|
||||
// or the reader is left to trust that the words cover the number.
|
||||
const OPS: [(u32, &str); 4] = [
|
||||
(0x1, "H.264"),
|
||||
(0x2, "H.265"),
|
||||
(0x4, "AV1"),
|
||||
(0x8, "VP9 (no punktfunk rung)"),
|
||||
];
|
||||
let mut codecs: Vec<String> = OPS
|
||||
.iter()
|
||||
.filter(|(bit, _)| a.codec_ops & bit != 0)
|
||||
.map(|(_, n)| *n)
|
||||
.map(|(_, n)| (*n).to_string())
|
||||
.collect();
|
||||
let named: u32 = OPS.iter().map(|(b, _)| b).sum();
|
||||
let unknown = a.codec_ops & !named;
|
||||
if unknown != 0 {
|
||||
codecs.push(format!("unrecognised bits 0x{unknown:X}"));
|
||||
}
|
||||
println!(
|
||||
" driver decode ops: {}",
|
||||
if codecs.is_empty() {
|
||||
@@ -601,6 +687,27 @@ mod session_main {
|
||||
} else {
|
||||
println!(" extensions: {}", a.codec_exts.join(", "));
|
||||
}
|
||||
print_video_formats(a);
|
||||
}
|
||||
if adapters.len() > 1 {
|
||||
// The single most common misreading of this output: seeing a
|
||||
// capable GPU listed and concluding the decoder will use it.
|
||||
// Vulkan Video decodes on the PRESENTER's device, and the decoder
|
||||
// preference does not move the presenter.
|
||||
println!();
|
||||
println!(
|
||||
"Vulkan Video decodes on the presenter's device. PUNKTFUNK_DECODER \
|
||||
picks the rung,"
|
||||
);
|
||||
println!(
|
||||
"not the GPU — move the presenter with PUNKTFUNK_VK_DEVICE=<index \
|
||||
above> or"
|
||||
);
|
||||
println!(
|
||||
"PUNKTFUNK_VK_ADAPTER=<name substring>, which is the safer knob \
|
||||
where two"
|
||||
);
|
||||
println!("adapters share a name.");
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates listed
|
||||
below. Each is distributed under its own permissive license; the full license texts
|
||||
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
|
||||
@@ -60,7 +60,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
bytes 1.12.0 — MIT — https://github.com/tokio-rs/bytes
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cexpr 0.6.0 — Apache-2.0/MIT — https://github.com/jethrogb/rust-cexpr
|
||||
cfg-expr 0.20.8 — MIT OR Apache-2.0 — https://github.com/EmbarkStudios/cfg-expr
|
||||
@@ -114,7 +114,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
filetime 0.2.29 — MIT/Apache-2.0 — https://github.com/alexcrichton/filetime
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flate2 1.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/flate2-rs
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
@@ -1598,7 +1598,7 @@ DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: assert_matches 1.5.0, async-channel 2.5.0, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.9, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, glob 0.3.3, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, nasm-rs 0.3.2, num-integer 0.1.46, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1, xattr 1.6.1
|
||||
The following license (LICENSE-APACHE) applies to: assert_matches 1.5.0, async-channel 2.5.0, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, glob 0.3.3, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, nasm-rs 0.3.2, num-integer 0.1.46, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1, xattr 1.6.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -2853,7 +2853,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
|
||||
@@ -59,14 +59,6 @@
|
||||
"licenses": [{ "license": { "id": "Zlib" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/libsdl-org/SDL" }]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"name": "VB-CABLE",
|
||||
"version": "redistributed installer, see packaging/windows/install-vbcable.ps1",
|
||||
"description": "Third-party kernel-mode virtual audio driver redistributed with the Windows host; notice at packaging/windows/licenses/VB-CABLE-NOTICE.txt. Planned to be replaced by an attestation-signed first-party driver.",
|
||||
"licenses": [{ "license": { "name": "Proprietary freeware (VB-Audio Software, redistribution permitted per notice)" } }],
|
||||
"externalReferences": [{ "type": "website", "url": "https://vb-audio.com/Cable/" }]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"name": "punktfunk-gamescope",
|
||||
|
||||
+252
-13
@@ -240,6 +240,16 @@ pub enum PlanWarning {
|
||||
/// hosts never emit MMCO 5, so this warning is the field signal if that
|
||||
/// assumption ever breaks.
|
||||
Mmco5Rebase,
|
||||
/// The SPS carried no VUI `bitstream_restriction`, so the DPB had to be sized from
|
||||
/// A.3.1's LEVEL ceiling — and that ceiling demands more hardware slots than a
|
||||
/// mainstream decoder provides. See [`dpb_limit`] for why this is the H.264 shape
|
||||
/// of the defect #96 fixed for HEVC, and why it is a warning here rather than a
|
||||
/// clamp. Every H.264 encoder a punktfunk host can reach writes the restriction
|
||||
/// (measured 2026-08-07), so this warning is the field signal if that ever breaks.
|
||||
LevelDerivedDpb {
|
||||
max_dpb_frames: usize,
|
||||
level_idc: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// The AU cannot be planned at all.
|
||||
@@ -296,12 +306,72 @@ impl From<&Sps> for NegotiationInfo {
|
||||
bit_depth_luma_minus8: sps.bit_depth_luma_minus8,
|
||||
bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8,
|
||||
chroma_format_idc: sps.chroma_format_idc,
|
||||
max_dpb_frames: sps.max_dpb_frames(),
|
||||
max_dpb_frames: dpb_limit(sps),
|
||||
interlaced: !sps.frame_mbs_only_flag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware DPB slot count a mainstream decoder provides. NVIDIA's Vulkan Video
|
||||
/// reports `maxDpbSlots = 16` (RADV 17), and DXVA/D3D11VA's H.264 picture-parameter
|
||||
/// format indexes the DPB with a 16-entry array. Backends allocate one slot per DPB
|
||||
/// frame plus one for the picture in flight, so a stream whose DPB is sized at 16
|
||||
/// demands 17 and is refused outright — losing the codec, not merely a slower path.
|
||||
const MAINSTREAM_MAX_DPB_SLOTS: usize = 16;
|
||||
|
||||
/// DPB size in frames — the same question [`crate::h265::dpb_limit`] answers, and the
|
||||
/// same trap, but H.264 gets a different answer and it is worth writing down why.
|
||||
///
|
||||
/// HEVC's SPS states its own requirement outright (`sps_max_dec_pic_buffering_minus1`),
|
||||
/// so #96 could simply stop consulting equation A-2's level ceiling. H.264 has no such
|
||||
/// unconditional field: the stream declares its need ONLY in the VUI's
|
||||
/// `bitstream_restriction` (`max_dec_frame_buffering`, E.2.1). Absent that, A.3.1's
|
||||
/// level ceiling — `min(MaxDpbMbs / (PicWidthInMbs * FrameHeightInMbs), 16)` — is all
|
||||
/// the spec leaves, and it is genuinely the correct inference, not a bug. It is also
|
||||
/// what the stream MAY use rather than what it needs, which is exactly the shape that
|
||||
/// killed HEVC at 720p and 1080p.
|
||||
///
|
||||
/// The ceiling saturates at 16 — thus [`MAINSTREAM_MAX_DPB_SLOTS`] + 1 hardware slots,
|
||||
/// the fatal value — whenever the picture is small relative to its level's `MaxDpbMbs`:
|
||||
///
|
||||
/// | picture | ceiling by level |
|
||||
/// |---|---|
|
||||
/// | 720p (3600 MBs) | L3.2 → 5, L4.2 → 9, **L5.0+ → 16** |
|
||||
/// | 1080p (8160 MBs) | L4.2 → 4, L5.0 → 13, **L5.1+ → 16** |
|
||||
/// | 1440p (14400 MBs) | L5.1 → 12, **L6.0+ → 16** |
|
||||
/// | 2160p (32400 MBs) | L5.2 → 5, **L6.0+ → 16** |
|
||||
///
|
||||
/// So H.264 escaped #96 twice over rather than by one piece of luck, and BOTH escapes
|
||||
/// are properties of the encoders, not of the format. Measured 2026-08-07 by reading
|
||||
/// the SPS each encoder actually emitted, at 720p/1080p/1440p/2160p:
|
||||
///
|
||||
/// | encoder | level picked | `bitstream_restriction` | `max_dec_frame_buffering` |
|
||||
/// |---|---|---|---|
|
||||
/// | NVENC (RTX 5070 Ti, 610.57.04) | 3.2 / 4.2 / 5.1 / 5.2 | present | 3 |
|
||||
/// | VAAPI via libavcodec (RDNA3, Mesa 26.0.3) | 4.1 / 4.2 / 5.1 / 5.2 | present | 1 |
|
||||
/// | openh264 (software rung) | 3.2 / 4.2 / 5.1 / 5.2 | present | 1 |
|
||||
///
|
||||
/// Every one of them picks a level proportionate to the picture AND states its real
|
||||
/// need in the VUI, so the ceiling is never reached and never consulted. That is why
|
||||
/// this function does NOT clamp: with the restriction present the value below IS the
|
||||
/// stream's own statement, and clamping a stream that genuinely asked for a deep DPB
|
||||
/// would corrupt its output. Absent the restriction there is no honest smaller number
|
||||
/// to substitute — [`PlanWarning::LevelDerivedDpb`] names the situation instead, so the
|
||||
/// field tells us if a driver ever stops writing the VUI, rather than a user silently
|
||||
/// losing H.264 the way #96's users silently lost HEVC.
|
||||
fn dpb_limit(sps: &Sps) -> usize {
|
||||
// A.3.1's cap, the VUI override and the `max_num_ref_frames` floor all live in the
|
||||
// vendored parser; this is the one named place the RESULT is interpreted.
|
||||
sps.max_dpb_frames()
|
||||
}
|
||||
|
||||
/// Whether [`dpb_limit`] had to fall back to A.3.1's level ceiling because the SPS
|
||||
/// carried no VUI `bitstream_restriction` — i.e. whether the number is the stream's own
|
||||
/// statement of need or merely the largest DPB its level permits.
|
||||
fn dpb_is_level_derived(sps: &Sps) -> bool {
|
||||
!(sps.vui_parameters_present_flag && sps.vui_parameters.bitstream_restriction_flag)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum RefPicList {
|
||||
RefPicList0,
|
||||
@@ -606,7 +676,7 @@ impl H264Planner {
|
||||
// uncapped. No hardware decoder implements a deeper DPB — a larger value is a
|
||||
// corrupt (or hostile) VUI, not a feature request — and backends size real
|
||||
// slot pools from this number, so it is gated here, at SPS activation.
|
||||
if sps.max_dpb_frames() > 16 {
|
||||
if dpb_limit(sps) > MAINSTREAM_MAX_DPB_SLOTS {
|
||||
return Err(PlanError::OutsideEnvelope(
|
||||
"DPB deeper than 16 frames (max_dec_frame_buffering)",
|
||||
));
|
||||
@@ -1279,10 +1349,19 @@ impl H264Planner {
|
||||
}
|
||||
|
||||
// Apply the parameters of `sps` to the planning state.
|
||||
fn apply_sps(&mut self, sps: &Sps) {
|
||||
fn apply_sps(&mut self, sps: &Sps, warnings: &mut Vec<PlanWarning>) {
|
||||
self.negotiation_info = NegotiationInfo::from(sps);
|
||||
|
||||
let max_dpb_frames = sps.max_dpb_frames();
|
||||
let max_dpb_frames = dpb_limit(sps);
|
||||
// Sized from the level ceiling rather than the stream's own declaration, and
|
||||
// large enough that backends will ask for more slots than they can get. See
|
||||
// [`dpb_limit`]: warned, not clamped, because there is no honest smaller number.
|
||||
if dpb_is_level_derived(sps) && max_dpb_frames + 1 > MAINSTREAM_MAX_DPB_SLOTS {
|
||||
warnings.push(PlanWarning::LevelDerivedDpb {
|
||||
max_dpb_frames,
|
||||
level_idc: sps.level_idc as u8,
|
||||
});
|
||||
}
|
||||
let interlaced = !sps.frame_mbs_only_flag;
|
||||
let max_num_order_frames = sps.max_num_order_frames() as usize;
|
||||
let max_num_reorder_frames = if max_num_order_frames > max_dpb_frames {
|
||||
@@ -1300,13 +1379,17 @@ impl H264Planner {
|
||||
*old_negotiation_info != negotiation_info
|
||||
}
|
||||
|
||||
fn renegotiate_if_needed(&mut self, sps: &Sps) -> Result<(), PlanError> {
|
||||
fn renegotiate_if_needed(
|
||||
&mut self,
|
||||
sps: &Sps,
|
||||
warnings: &mut Vec<PlanWarning>,
|
||||
) -> Result<(), PlanError> {
|
||||
if Self::negotiation_possible(sps, &self.negotiation_info) {
|
||||
Self::check_envelope(sps)?;
|
||||
// Make sure all the frames planned so far are display-ready before the
|
||||
// stream parameters change under them.
|
||||
self.drain_dpb();
|
||||
self.apply_sps(sps);
|
||||
self.apply_sps(sps, warnings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1412,7 +1495,7 @@ impl H264Planner {
|
||||
)?);
|
||||
|
||||
// A picture's SPS may require renegotiation.
|
||||
self.renegotiate_if_needed(&pps.sps)?;
|
||||
self.renegotiate_if_needed(&pps.sps, warnings)?;
|
||||
|
||||
let first_field = self.find_first_field(hdr).map_err(PlanError::Parse)?;
|
||||
|
||||
@@ -1706,7 +1789,7 @@ impl H264Planner {
|
||||
bit_depth_luma_minus8: sps.bit_depth_luma_minus8,
|
||||
bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8,
|
||||
chroma_format_idc: sps.chroma_format_idc,
|
||||
max_dpb_frames: sps.max_dpb_frames(),
|
||||
max_dpb_frames: dpb_limit(sps),
|
||||
recovery_point,
|
||||
}
|
||||
}
|
||||
@@ -1900,6 +1983,21 @@ mod tests {
|
||||
(sps, pps)
|
||||
}
|
||||
|
||||
/// The warnings a plan carries, minus the DPB-sizing signal.
|
||||
///
|
||||
/// [`base_sps`]'s fixtures are 64x64 with no VUI bitstream restriction, so A.3.1's
|
||||
/// ceiling saturates (`MaxDpbMbs(L1) = 396` over 16 macroblocks) and every plan
|
||||
/// built on them carries [`PlanWarning::LevelDerivedDpb`]. That is the arithmetic
|
||||
/// `the_level_ceiling_alone_would_reproduce_96_and_is_warned_about` exists to pin —
|
||||
/// SMALL pictures saturate the ceiling most easily, not large ones — and it says
|
||||
/// nothing about the picture, which is what the tests below are checking.
|
||||
fn picture_warnings(plan: &AuPlan) -> Vec<&PlanWarning> {
|
||||
plan.warnings
|
||||
.iter()
|
||||
.filter(|w| !matches!(w, PlanWarning::LevelDerivedDpb { .. }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn param_set_au(sps: &Sps, pps: &Pps) -> Vec<u8> {
|
||||
let mut au = Vec::new();
|
||||
Synthesizer::<'_, Sps, _>::synthesize(3, sps, &mut au, true).unwrap();
|
||||
@@ -2022,7 +2120,7 @@ mod tests {
|
||||
let p4 = planner.plan_au(&au4).unwrap();
|
||||
for plan in [&p0, &p1, &p2, &p3, &p4] {
|
||||
assert!(
|
||||
plan.warnings.is_empty(),
|
||||
picture_warnings(plan).is_empty(),
|
||||
"authored stream must plan clean: {plan:?}"
|
||||
);
|
||||
}
|
||||
@@ -2101,7 +2199,10 @@ mod tests {
|
||||
let p1 = planner.plan_au(&au1).unwrap();
|
||||
let p2 = planner.plan_au(&au2).unwrap();
|
||||
for plan in [&p0, &p1, &p2] {
|
||||
assert!(plan.warnings.is_empty(), "must plan clean: {plan:?}");
|
||||
assert!(
|
||||
picture_warnings(plan).is_empty(),
|
||||
"must plan clean: {plan:?}"
|
||||
);
|
||||
}
|
||||
let idr_id = p0.dpb.stored.unwrap();
|
||||
let lt_id = p1.dpb.stored.unwrap();
|
||||
@@ -2364,6 +2465,144 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An SPS at a given picture size and level, with the VUI bitstream restriction
|
||||
/// either absent (so [`dpb_limit`] must fall back to A.3.1's level ceiling) or
|
||||
/// present with an explicit `max_dec_frame_buffering`.
|
||||
fn sps_at(width: u32, height: u32, level: Level, declared: Option<u32>) -> Sps {
|
||||
Sps {
|
||||
profile_idc: Profile::Main as u8,
|
||||
level_idc: level,
|
||||
frame_mbs_only_flag: true,
|
||||
direct_8x8_inference_flag: true,
|
||||
// Every H.264 encoder measured below sits at or under this; it is the
|
||||
// A.3.1 floor `max_dpb_frames` applies, never the value under test.
|
||||
max_num_ref_frames: 3,
|
||||
pic_width_in_mbs_minus1: (width / 16 - 1) as u16,
|
||||
pic_height_in_map_units_minus1: (height / 16 - 1) as u16,
|
||||
vui_parameters_present_flag: declared.is_some(),
|
||||
vui_parameters: VuiParams {
|
||||
bitstream_restriction_flag: declared.is_some(),
|
||||
max_dec_frame_buffering: declared.unwrap_or(0),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The H.264 half of `pf-encode`'s `rfi_dpb_fits_a_mainstream_vulkan_decoder`.
|
||||
///
|
||||
/// That test guards the PRODUCER end for HEVC — that `RFI_DPB` never grows past
|
||||
/// what a client can allocate. This is the CONSUMER end for H.264, where the
|
||||
/// number is not ours to choose: the client derives it from whatever SPS arrives,
|
||||
/// and #96 proved that deriving a DPB from a level's ceiling instead of a stream's
|
||||
/// need costs the codec outright.
|
||||
///
|
||||
/// Pinned here are the (picture, level) pairs a punktfunk host can actually reach,
|
||||
/// with the level each shipping encoder was MEASURED to pick on 2026-08-07 (see
|
||||
/// [`dpb_limit`] for the full table and the hardware). The property that has to
|
||||
/// hold is the one the backends enforce: `dpb_limit + 1 <= 16` slots.
|
||||
#[test]
|
||||
fn every_reachable_h264_stream_fits_a_mainstream_slot_pool() {
|
||||
// (picture, level measured on NVENC / VAAPI / openh264, their VUI declaration)
|
||||
let measured = [
|
||||
((1280, 720), Level::L3_2, 3), // NVENC, openh264
|
||||
((1280, 720), Level::L4_1, 1), // VAAPI via libavcodec
|
||||
((1920, 1080), Level::L4_2, 3), // all three
|
||||
((2560, 1440), Level::L5_1, 3),
|
||||
((3840, 2160), Level::L5_2, 3),
|
||||
];
|
||||
for ((w, h), level, declared) in measured {
|
||||
let sps = sps_at(w, h, level, Some(declared));
|
||||
assert!(
|
||||
!dpb_is_level_derived(&sps),
|
||||
"{w}x{h} L{:?}: the measured encoders all write the VUI restriction — \
|
||||
an SPS that carries it must never be treated as level-derived",
|
||||
level
|
||||
);
|
||||
let slots = dpb_limit(&sps) + 1;
|
||||
assert!(
|
||||
slots <= MAINSTREAM_MAX_DPB_SLOTS,
|
||||
"{w}x{h} L{level:?} declaring {declared} needs {slots} DPB slots, \
|
||||
mainstream hardware caps at {MAINSTREAM_MAX_DPB_SLOTS}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cliff the measured encoders walk past, pinned so it stays visible.
|
||||
///
|
||||
/// These are the SAME resolutions, at levels a host could legally pick, with no
|
||||
/// VUI restriction to state the real need. Each computes a 16-frame DPB — 17
|
||||
/// hardware slots — which is precisely the arithmetic that killed HEVC at 720p and
|
||||
/// 1080p before #96. H.264 escapes it because every encoder both picks a
|
||||
/// proportionate level AND writes the restriction, not because the format is safe.
|
||||
#[test]
|
||||
fn the_level_ceiling_alone_would_reproduce_96_and_is_warned_about() {
|
||||
// (picture, a level that saturates A.3.1's ceiling for it)
|
||||
let cliff = [
|
||||
((1280, 720), Level::L5),
|
||||
((1280, 720), Level::L5_2),
|
||||
((1920, 1080), Level::L5_1),
|
||||
((2560, 1440), Level::L6),
|
||||
((3840, 2160), Level::L6_2),
|
||||
];
|
||||
for ((w, h), level) in cliff {
|
||||
let sps = sps_at(w, h, level, None);
|
||||
assert!(dpb_is_level_derived(&sps), "{w}x{h} L{level:?}");
|
||||
assert_eq!(
|
||||
dpb_limit(&sps),
|
||||
16,
|
||||
"{w}x{h} L{level:?} should saturate A.3.1's 16-frame ceiling"
|
||||
);
|
||||
assert!(
|
||||
dpb_limit(&sps) + 1 > MAINSTREAM_MAX_DPB_SLOTS,
|
||||
"{w}x{h} L{level:?} is the #96 arithmetic and must be recognised as such"
|
||||
);
|
||||
|
||||
// ...and the planner must NAME it rather than let a user silently lose the
|
||||
// codec. The SPS activates on the first slice, so the warning rides out on
|
||||
// the IDR's plan.
|
||||
let sps = Rc::new(sps);
|
||||
let pps = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(0)
|
||||
.pic_init_qp(26)
|
||||
.build();
|
||||
let mut au = param_set_au(&sps, &pps);
|
||||
au.extend(write_idr_slice());
|
||||
|
||||
let plan = H264Planner::new()
|
||||
.plan_au(&au)
|
||||
.unwrap_or_else(|e| panic!("{w}x{h} L{level:?} should plan, got {e:?}"));
|
||||
assert!(
|
||||
plan.warnings.contains(&PlanWarning::LevelDerivedDpb {
|
||||
max_dpb_frames: 16,
|
||||
level_idc: level as u8,
|
||||
}),
|
||||
"{w}x{h} L{level:?}: expected LevelDerivedDpb, got {:?}",
|
||||
plan.warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A proportionate level is the other half of the escape: at the levels the
|
||||
/// encoders actually pick, the ceiling is small enough that even a stream with no
|
||||
/// VUI at all fits — so neither escape is doing all the work alone.
|
||||
#[test]
|
||||
fn a_proportionate_level_fits_even_without_a_vui_restriction() {
|
||||
let proportionate = [
|
||||
((1280, 720), Level::L3_2, 5),
|
||||
((1280, 720), Level::L4_1, 9),
|
||||
((1920, 1080), Level::L4_2, 4),
|
||||
((2560, 1440), Level::L5_1, 12),
|
||||
((3840, 2160), Level::L5_2, 5),
|
||||
];
|
||||
for ((w, h), level, expected) in proportionate {
|
||||
let sps = sps_at(w, h, level, None);
|
||||
assert_eq!(dpb_limit(&sps), expected, "{w}x{h} L{level:?}");
|
||||
let slots = dpb_limit(&sps) + 1; // + the picture in flight
|
||||
assert!(slots <= MAINSTREAM_MAX_DPB_SLOTS, "{w}x{h} L{level:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// MMCO 5 writer: op 5 takes NO argument (Table 7-9), so the generic
|
||||
/// [`write_p_slice`] — whose supported ops all take exactly one — cannot author
|
||||
/// it.
|
||||
@@ -2688,11 +2927,11 @@ mod tests {
|
||||
let plan = planner.plan_au(&write_idr_slice()).unwrap();
|
||||
assert!(plan.picture.is_idr);
|
||||
assert_eq!(plan.picture.pic_order_cnt, 0);
|
||||
assert!(plan.warnings.is_empty());
|
||||
assert!(picture_warnings(&plan).is_empty());
|
||||
|
||||
// And the stream continues cleanly on the reset state.
|
||||
let plan = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
|
||||
assert!(plan.warnings.is_empty());
|
||||
assert!(picture_warnings(&plan).is_empty());
|
||||
assert_eq!(plan.slices[0].ref_list0.len(), 1);
|
||||
}
|
||||
|
||||
@@ -2725,7 +2964,7 @@ mod tests {
|
||||
au.extend(write_idr_slice_at(8, 1));
|
||||
|
||||
let plan = H264Planner::new().plan_au(&au).unwrap();
|
||||
assert!(plan.warnings.is_empty());
|
||||
assert!(picture_warnings(&plan).is_empty());
|
||||
assert_eq!(plan.slices.len(), 2);
|
||||
// The picture parameters come from the FIRST slice's PPS (the uncropped
|
||||
// SPS 0); they must not drift to the last slice's.
|
||||
|
||||
+176
-13
@@ -158,8 +158,9 @@ pub struct PicturePlan {
|
||||
pub bit_depth_luma_minus8: u8,
|
||||
pub bit_depth_chroma_minus8: u8,
|
||||
pub chroma_format_idc: u8,
|
||||
/// DPB size in frames per A.4 (equation A-2, capped at 16) — backends size their
|
||||
/// slot pool from this.
|
||||
/// DPB size in frames: the stream's own `sps_max_dec_pic_buffering_minus1 + 1`,
|
||||
/// capped at 16 — backends size their slot pool from this. See [`dpb_limit`] for
|
||||
/// why this is NOT equation A-2's level ceiling.
|
||||
pub max_dpb_frames: usize,
|
||||
/// Bits of the `st_ref_pic_set()` the FIRST slice carried inline (0 when the RPS
|
||||
/// came from the SPS by index) — Vulkan's `NumBitsForSTRefPicSetInSlice`.
|
||||
@@ -311,16 +312,52 @@ impl From<&Sps> for NegotiationInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// The DPB size the planner enforces: equation A-2 (via the vendored
|
||||
/// `Sps::max_dpb_size`), never above 16 — and never below the stream's
|
||||
/// `sps_max_dec_pic_buffering_minus1 + 1`, which C.5.2.2's bumping uses as its
|
||||
/// fullness bound. A conforming stream keeps the latter within A-2 (its constraint
|
||||
/// clause), so the `max` only widens the pool for streams that already violate A.4 —
|
||||
/// storing their pictures beats erroring the AU.
|
||||
/// The DPB size the planner enforces: the stream's own
|
||||
/// `sps_max_dec_pic_buffering_minus1[HighestTid] + 1`, capped at 16.
|
||||
///
|
||||
/// This deliberately does NOT consult equation A-2 (the vendored
|
||||
/// `Sps::max_dpb_size`). A-2 is a CEILING on what an SPS may signal — 7.4.3.2.1
|
||||
/// constrains `sps_max_dec_pic_buffering_minus1[i]` to `0..=MaxDpbSize - 1` — not a
|
||||
/// statement of what the stream needs. The size the stream actually needs is the
|
||||
/// signalled buffering, which is precisely what C.5.2.2's fullness clause bumps
|
||||
/// against, and A.4.1 bounds the total RPS entries by the same number: `buffering`
|
||||
/// pictures hold `buffering - 1` references plus the current one, exactly.
|
||||
///
|
||||
/// Reading A-2 as a requirement cost us HEVC outright, measured on `.21`
|
||||
/// (RTX 5070 Ti, 2026-08-07). The host's SPS is minimal and honest at every
|
||||
/// resolution — `general_level_idc = 153` (L5.1 High, which NVENC autoselects
|
||||
/// because 130 Mbps needs it), `sps_max_dec_pic_buffering_minus1 = 5`, i.e. six
|
||||
/// pictures: `RFI_DPB` references plus the current one. But A-2 branches on picture
|
||||
/// size against the LEVEL's `MaxLumaPs`, and at 1080p the coded 1920x1088 =
|
||||
/// 2 088 960 samples fall under `MaxLumaPs(L5.1) >> 2` = 2 228 224, taking the first
|
||||
/// branch: `min(4 * MaxDpbPicBuf, 16)` = 16. The old `max(A-2, buffering)` therefore
|
||||
/// reported 16 where the stream asked for 6, backends added the current picture and
|
||||
/// demanded 17 hardware DPB slots, and NVIDIA's Vulkan Video caps `maxDpbSlots` at
|
||||
/// 16 — so every access unit was refused, the ladder ran out of rungs, and the
|
||||
/// session reconnected without HEVC (fatal on a build with no software HEVC
|
||||
/// decoder).
|
||||
///
|
||||
/// A resolution sweep on the same box put the blast radius at the two commonest
|
||||
/// streaming resolutions and nowhere else, which is A-2's branch table exactly:
|
||||
/// 720p (1280x720 = 921 600) and 1080p died on branch 1 at 16 frames; 1440p
|
||||
/// (2560x1440 = 3 686 400, under `MaxLumaPs >> 1`) survived on branch 2 at 12; 4K
|
||||
/// (3840x2176 = 8 355 840, past `3 * MaxLumaPs >> 2`) survived on the `else` branch
|
||||
/// at 6 and decoded at 1.9 ms. One host, one level, one six-picture requirement —
|
||||
/// only which branch the picture size landed in decided whether HEVC worked at all.
|
||||
///
|
||||
/// The `max()` that produced the 16 bought nothing even for the malformed streams
|
||||
/// it was written for: `Dpb::needs_bumping` (C.5.2.2) already keys on the signalled
|
||||
/// buffering, not on `max_num_pics`, so a stream referencing more pictures than it
|
||||
/// declared was ALREADY being bumped below its own declared depth before every
|
||||
/// store. All the widened limit ever did was over-allocate hardware surfaces.
|
||||
///
|
||||
/// The cap stays: `check_envelope` rejects `buffering > 16` at activation, but this
|
||||
/// also runs from `NegotiationInfo::from`, which sees SPSes that have not reached
|
||||
/// the gate yet, and backends size real slot pools from the result.
|
||||
fn dpb_limit(sps: &Sps) -> usize {
|
||||
let buffering =
|
||||
usize::from(sps.max_dec_pic_buffering_minus1[usize::from(sps.max_sub_layers_minus1)]) + 1;
|
||||
sps.max_dpb_size().max(buffering).min(16)
|
||||
buffering.min(16)
|
||||
}
|
||||
|
||||
/// The RefPicSet data (8.3.2), derived once per picture.
|
||||
@@ -1755,6 +1792,10 @@ mod tests {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bit_depth_minus8: u32,
|
||||
/// `general_level_idc` (30 x the level number: 120 = L4, 153 = L5.1). Only the
|
||||
/// DPB-sizing tests care, and they care because equation A-2 keys on it — the
|
||||
/// planner deliberately does not.
|
||||
level_idc: u32,
|
||||
/// (left, right, top, bottom) conf_win offsets, in chroma units.
|
||||
conf_win: Option<(u32, u32, u32, u32)>,
|
||||
max_dec_pic_buffering_minus1: u32,
|
||||
@@ -1771,6 +1812,7 @@ mod tests {
|
||||
width: 64,
|
||||
height: 64,
|
||||
bit_depth_minus8: 0,
|
||||
level_idc: 120, // L4
|
||||
conf_win: None,
|
||||
max_dec_pic_buffering_minus1: 4,
|
||||
max_num_reorder_pics: 0,
|
||||
@@ -1801,7 +1843,7 @@ mod tests {
|
||||
s.bits(31, 0);
|
||||
s.bits(12, 0); // 43 zero bits total
|
||||
s.bit(0); // general_inbld_flag / reserved
|
||||
s.bits(8, 120); // general_level_idc: level 4
|
||||
s.bits(8, o.level_idc); // general_level_idc
|
||||
|
||||
s.ue(0); // sps_seq_parameter_set_id
|
||||
s.ue(o.chroma_format_idc);
|
||||
@@ -2093,7 +2135,11 @@ mod tests {
|
||||
assert_eq!(plan.picture.general_profile_idc, 1);
|
||||
assert_eq!(plan.picture.level_idc, Level::L4);
|
||||
assert_eq!(plan.picture.chroma_format_idc, 1);
|
||||
assert_eq!(plan.picture.max_dpb_frames, 16, "A-2 for a 64x64 L4 stream");
|
||||
assert_eq!(
|
||||
plan.picture.max_dpb_frames, 5,
|
||||
"the DPB is the stream's sps_max_dec_pic_buffering_minus1 + 1, NOT A-2's \
|
||||
level ceiling (which would say 16 for a 64x64 L4 stream)"
|
||||
);
|
||||
// Zero-reorder low-delay: the picture is display-ready in its own plan.
|
||||
assert_eq!(plan.dpb.outputs, vec![plan.dpb.stored.unwrap()]);
|
||||
// An IDR carries no RPS.
|
||||
@@ -3072,8 +3118,10 @@ mod tests {
|
||||
other => panic!("the rebind must not activate the rejected SPS: {other:?}"),
|
||||
}
|
||||
|
||||
// Leg B: a 17-frame DPB, same geometry (dpb_limit caps both sides at 16, so
|
||||
// NegotiationInfo alone cannot catch the rebind).
|
||||
// Leg B: a 17-frame DPB, same geometry. The envelope gate is what must catch
|
||||
// this on BOTH the direct AU and the PPS-only rebind: renegotiation runs only
|
||||
// after `check_envelope` has passed, so a NegotiationInfo difference (5 vs the
|
||||
// capped 16) is never reached and cannot stand in for the gate.
|
||||
let mut planner = H265Planner::new();
|
||||
planner
|
||||
.plan_au(&opening_idr_au(&SpsOpts::default()))
|
||||
@@ -3094,6 +3142,121 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// The DPB the planner reports is the stream's own declared depth, and it must fit
|
||||
/// a mainstream Vulkan Video decoder — pinned at the exact stream that broke it.
|
||||
///
|
||||
/// Field defect, `.21` (RTX 5070 Ti), 2026-08-07: a punktfunk 1080p HEVC session
|
||||
/// refused EVERY access unit with "stream needs 17 DPB slots, device caps at 16",
|
||||
/// exhausted the decode ladder and reconnected without HEVC — fatal, because there
|
||||
/// is no permissively licensed software HEVC decoder to fall back to. The host was
|
||||
/// blameless: its SPS asked for six pictures at both resolutions. The planner was
|
||||
/// reporting equation A-2's LEVEL ceiling instead, and A-2 branches on picture size
|
||||
/// against `MaxLumaPs`, so the identical stream reported 16 at 1080p and 6 at 4K.
|
||||
///
|
||||
/// The three assertions below are the whole defect: the two resolutions must agree,
|
||||
/// they must agree on the number the STREAM signalled, and `+ 1` for the current
|
||||
/// picture must clear the 16-slot floor that NVIDIA's `maxDpbSlots` sets. The host
|
||||
/// side of the same arithmetic — that `RFI_DPB` never grows past what this leaves
|
||||
/// room for — is pinned in `pf-encode`'s `rfi_dpb_fits_a_mainstream_vulkan_decoder`.
|
||||
#[test]
|
||||
fn the_reported_dpb_is_the_streams_own_depth_and_fits_a_16_slot_decoder() {
|
||||
/// `VkVideoCapabilitiesKHR::maxDpbSlots` on NVIDIA — the lowest cap among the
|
||||
/// decoders punktfunk targets (RADV reports 17). A stream needing more than
|
||||
/// this cannot be decoded natively at all.
|
||||
const VULKAN_MAX_DPB_SLOTS: usize = 16;
|
||||
|
||||
// The field stream, byte for byte on the fields that matter: L5.1 High (which
|
||||
// NVENC autoselects at 130 Mbps), coded 1920x1088, six pictures declared.
|
||||
let field = SpsOpts {
|
||||
width: 1920,
|
||||
height: 1088,
|
||||
level_idc: 153,
|
||||
max_dec_pic_buffering_minus1: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let mut planner = H265Planner::new();
|
||||
let at_1080p = planner.plan_au(&opening_idr_au(&field)).unwrap();
|
||||
assert_eq!(
|
||||
at_1080p.picture.max_dpb_frames, 6,
|
||||
"sps_max_dec_pic_buffering_minus1 = 5 means six pictures — five references \
|
||||
plus the current one. A-2 would have said 16 here, because 1920x1088 = \
|
||||
2088960 luma samples fall under MaxLumaPs(L5.1) >> 2 = 2228224."
|
||||
);
|
||||
// What the backends ask the driver for: one slot per DPB picture, plus one for
|
||||
// the picture in flight (`pf_vkdecode::slots` pins the same convention).
|
||||
let slots_for = |plan: &AuPlan| plan.picture.max_dpb_frames + 1;
|
||||
assert!(
|
||||
slots_for(&at_1080p) <= VULKAN_MAX_DPB_SLOTS,
|
||||
"{} DPB frames need {} slots, over the {VULKAN_MAX_DPB_SLOTS} a mainstream \
|
||||
Vulkan Video decoder offers — this is the refusal that cost HEVC",
|
||||
at_1080p.picture.max_dpb_frames,
|
||||
slots_for(&at_1080p)
|
||||
);
|
||||
|
||||
// The same stream at the other three resolutions punktfunk streams at. 720p and
|
||||
// 1080p died in the field and 1440p and 4K survived, purely because A-2 branches
|
||||
// on picture size: 1280x720 and 1920x1088 fall under MaxLumaPs >> 2 (16 frames),
|
||||
// 2560x1440 under MaxLumaPs >> 1 (12), and 3840x2176 past 3 * MaxLumaPs >> 2 (6).
|
||||
// All four must now be indistinguishable, because the stream is.
|
||||
for (w, h) in [(1280, 720), (2560, 1440), (3840, 2176)] {
|
||||
let mut planner = H265Planner::new();
|
||||
let plan = planner
|
||||
.plan_au(&opening_idr_au(&SpsOpts {
|
||||
width: w,
|
||||
height: h,
|
||||
..field.clone()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.picture.max_dpb_frames, at_1080p.picture.max_dpb_frames,
|
||||
"{w}x{h}: the DPB requirement is a property of the stream, not of which \
|
||||
A-2 branch the picture size lands in"
|
||||
);
|
||||
}
|
||||
|
||||
// The invariant behind all of them: the reported depth is the declared one, and
|
||||
// every depth that leaves a slot for the picture in flight fits a 16-slot device.
|
||||
for minus1 in 0..=14u32 {
|
||||
let mut planner = H265Planner::new();
|
||||
let plan = planner
|
||||
.plan_au(&opening_idr_au(&SpsOpts {
|
||||
max_dec_pic_buffering_minus1: minus1,
|
||||
..field.clone()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(plan.picture.max_dpb_frames, minus1 as usize + 1);
|
||||
assert!(slots_for(&plan) <= VULKAN_MAX_DPB_SLOTS);
|
||||
}
|
||||
|
||||
// The one honest residue: A.4 lets a conforming stream declare a full 16-picture
|
||||
// DPB, and 16 pictures plus the one in flight is 17 slots, which NVIDIA does not
|
||||
// have. Refusing that is right — it genuinely does not fit, and decoding it with
|
||||
// fewer slots would silently corrupt references. No punktfunk host comes near it
|
||||
// (`RFI_DPB` puts us at 6), and this is pinned so the distinction stays visible:
|
||||
// what was fixed is streams that never needed the slots, not this one.
|
||||
let mut planner = H265Planner::new();
|
||||
let deepest = planner
|
||||
.plan_au(&opening_idr_au(&SpsOpts {
|
||||
max_dec_pic_buffering_minus1: 15,
|
||||
..field.clone()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(deepest.picture.max_dpb_frames, 16);
|
||||
assert_eq!(slots_for(&deepest), VULKAN_MAX_DPB_SLOTS + 1);
|
||||
let mut planner = H265Planner::new();
|
||||
assert!(
|
||||
matches!(
|
||||
planner.plan_au(&opening_idr_au(&SpsOpts {
|
||||
max_dec_pic_buffering_minus1: 16,
|
||||
..field.clone()
|
||||
})),
|
||||
Err(PlanError::OutsideEnvelope(what)) if what.contains("DPB")
|
||||
),
|
||||
"a 17-picture DPB is past A.4's cap and must be refused at activation, not \
|
||||
clamped silently into a slot pool that cannot hold it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Finding 4: the RASL refusal must run BEFORE renegotiation — a RASL AU carrying
|
||||
/// a renegotiating SPS must not drain the DPB on its way out.
|
||||
#[test]
|
||||
|
||||
@@ -118,7 +118,11 @@ rand = "0.9"
|
||||
# need the hidapi driver). Linux links the system SDL3; Windows builds it from source
|
||||
# (no system SDL3 there — same choice as clients/windows).
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pipewire = "0.9"
|
||||
# `v0_3_49` for `Buffer::requested` — the graph's per-cycle frame ask, without which the
|
||||
# playback callback can only size writes from the buffer CEILING (quantum-limit, ~170 ms).
|
||||
# Pure cfg gate; needs libpipewire ≥ 0.3.49 (2022-03) at runtime, which every ship target
|
||||
# (SteamOS, flatpak runtimes, Arch, Ubuntu ≥ 22.10) clears.
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# Native VAAPI decode (M6 of the native-decode program): the hand-declared libva buffer
|
||||
# layouts, the profile/format/surface decisions, the AuPlan → picparams/IQ/slice
|
||||
@@ -165,11 +169,16 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
"winuser",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
# The native D3D11VA rung's frame-hash parity test compares decoded surfaces against the
|
||||
# libavcodec goldens M5 captured — the same SHA-256 list, and the same crate, pf-vkdecode's
|
||||
# Vulkan parity legs use (already in the workspace lock). The goldens are checked-in
|
||||
# hashes; nothing links FFmpeg to read them.
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dev-dependencies]
|
||||
# The two platform native rungs' frame-hash parity tests compare decoded surfaces against
|
||||
# the libavcodec goldens M5 captured — the same SHA-256 list, and the same crate,
|
||||
# pf-vkdecode's Vulkan parity legs use (already in the workspace lock). The goldens are
|
||||
# checked-in hashes; nothing links FFmpeg to read them.
|
||||
#
|
||||
# Windows was the only platform here until the VAAPI rung grew a readback: `cfg(windows)`
|
||||
# for `video_d3d11_native::parity`, now `cfg(linux)` as well for
|
||||
# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything —
|
||||
# which is also part of why the VAAPI readback cannot reach the production video path.
|
||||
sha2 = "0.10"
|
||||
|
||||
[features]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user