Compare commits

..
Author SHA1 Message Date
enricobuehler 6ff62b087c feat(client): say which GPU can do Vulkan Video, and why not when it can't
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
2026-08-07 12:35:55 +02:00
333 changed files with 2695 additions and 29203 deletions
-16
View File
@@ -160,22 +160,6 @@ 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
+8 -206
View File
@@ -48,26 +48,7 @@ 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
@@ -113,52 +94,7 @@ 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
@@ -191,30 +127,12 @@ 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)
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
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
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
@@ -255,31 +173,6 @@ 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.
@@ -317,63 +210,6 @@ 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
@@ -401,48 +237,14 @@ jobs:
done
echo "published to $OWNER/arch/$REPO"
# 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 != ''
# 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')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
. scripts/ci/gitea-release.sh
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RID=$(ensure_release "$TAG" "$TAG" auto)
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" 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
-7
View File
@@ -66,13 +66,6 @@ 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
+1 -1
View File
@@ -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, gamescope).
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, 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
+8
View File
@@ -150,6 +150,13 @@ 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', ''
@@ -399,6 +406,7 @@ 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
View File
@@ -1,717 +0,0 @@
# 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 3539 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 (2042 ms/unit at `n_fc=2` vs 2153 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 45 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 821 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 3539 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
+43 -44
View File
@@ -647,9 +647,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.1"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -994,7 +994,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.25.0"
version = "0.24.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 = "9.0.0"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
dependencies = [
"bitflags 2.13.0",
"ffmpeg-sys-next",
@@ -1301,9 +1301,9 @@ dependencies = [
[[package]]
name = "ffmpeg-sys-next"
version = "9.0.0"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
dependencies = [
"bindgen",
"cc",
@@ -1341,9 +1341,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
@@ -2358,7 +2358,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.25.0"
version = "0.24.0"
[[package]]
name = "lazy_static"
@@ -2463,7 +2463,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"punktfunk-core",
]
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3051,7 +3051,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3069,7 +3069,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3090,7 +3090,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3100,7 +3100,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3124,7 +3124,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -3136,7 +3136,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3150,11 +3150,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.25.0"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3183,20 +3183,19 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
"async-channel",
"pf-client-core",
"pf-vkdecode",
"punktfunk-core",
"sdl3",
"tracing",
@@ -3205,7 +3204,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -3213,7 +3212,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"base64",
@@ -3225,7 +3224,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3234,7 +3233,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3267,7 +3266,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3278,7 +3277,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3290,7 +3289,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3513,7 +3512,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3524,7 +3523,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"android_logger",
"jni",
@@ -3542,7 +3541,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3559,7 +3558,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3574,7 +3573,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3593,7 +3592,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3625,7 +3624,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3710,7 +3709,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3724,7 +3723,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3747,7 +3746,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.25.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -57,7 +57,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.25.0"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+8 -8
View File
@@ -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.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
cc 1.2.65 — 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 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg
ffmpeg-sys-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
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
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.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
find-msvc-tools 0.1.9 — 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 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
ffmpeg-sys-next 8.1.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.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
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
----------------------------------------------------------------------------
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.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
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
----------------------------------------------------------------------------
Copyright (c) 2014 Alex Crichton
@@ -6183,7 +6183,7 @@ DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: ffmpeg-next 9.0.0
The following license (LICENSE) applies to: ffmpeg-next 8.1.0
----------------------------------------------------------------------------
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
+4 -181
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.25.0"
"version": "0.24.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).\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.",
"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).",
"operationId": "getLibrary",
"parameters": [
{
@@ -1021,13 +1021,13 @@
],
"responses": {
"200": {
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"description": "Unified library across all stores",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OperatorGameEntry"
"$ref": "#/components/schemas/GameEntry"
}
}
}
@@ -1301,79 +1301,6 @@
}
}
},
"/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": [
@@ -4118,51 +4045,6 @@
}
}
},
"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.",
@@ -5626,37 +5508,6 @@
}
}
},
"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.",
@@ -6443,23 +6294,6 @@
}
}
},
"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.",
@@ -6971,17 +6805,6 @@
"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."
+1 -20
View File
@@ -9,26 +9,7 @@
# 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-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.
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
-8
View File
@@ -45,14 +45,6 @@ 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
+1 -3
View File
@@ -13,9 +13,7 @@ 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 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.
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
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.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
cc 1.2.65 — 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.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
find-msvc-tools 0.1.9 — 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.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
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
----------------------------------------------------------------------------
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.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
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
----------------------------------------------------------------------------
Copyright (c) 2014 Alex Crichton
@@ -69,14 +69,11 @@ fun App(forceGamepadUi: Boolean = false) {
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
@@ -67,7 +67,7 @@ class GamepadPalette(
)
/**
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark light, so stepping the row walks the range one way.
*/
val ALL = listOf(
@@ -77,22 +77,6 @@ class GamepadPalette(
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
"oled", "OLED",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
Triple(0.120, 0.024, 0.130),
),
ground = Triple(0.0, 0.0, 0.0),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
@@ -57,7 +57,6 @@ 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
@@ -127,8 +126,6 @@ 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 }
@@ -162,7 +159,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, hasGyroscope, av1Capable, ::update) +
val allRows = buildSettingsRows(s, hasBodyVibrator, 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.
@@ -448,13 +445,12 @@ 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 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. */
* [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. */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
hasGyroscope: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
@@ -602,18 +598,6 @@ 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.
@@ -665,21 +649,6 @@ internal fun buildSettingsRows(
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
) + listOfNotNull(
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
// this very screen and the row under the cursor would otherwise be one deciding nothing,
// on a screen that is itself about to disappear.
if (s.gamepadUiEnabled) {
choice(
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
"With a controller: the touch interface comes back when the last one " +
"disconnects. Always keeps this layout either way — for a device that lives " +
"docked to a TV. A TV itself is always in this mode regardless.",
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
) { update(s.copy(gamepadUiMode = it)) }
} else {
null
},
)
}
@@ -16,35 +16,15 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
* the switch meant when it was a lone Boolean.
*/
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
/**
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad for a phone or
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
* always awake.
*/
const val GAMEPAD_UI_ALWAYS = "always"
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally its remote/gamepad is
* the only input, so it's always the console UI (as long as the setting is on), which is why the
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
* client wrote can never strand this one in a layout it has no way back out of.
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
*/
fun gamepadUiActive(
enabled: Boolean,
mode: String,
controllerConnected: Boolean,
tv: Boolean,
forced: Boolean,
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
@@ -94,20 +94,11 @@ data class Settings(
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
* gamepad chrome) whenever a controller is connected mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* When [gamepadUiEnabled] actually takes over the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
* is on, which is why both settings screens hide the row when the switch is off. Anything
* unrecognized resolves to `"connected"`. A TV ignores it it is always in console mode.
*/
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
@@ -116,10 +107,9 @@ data class Settings(
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through the
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
* client's under the same names. Presentation only: nothing
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing a newer client may have shipped
* a palette this build doesn't know.
@@ -168,16 +158,6 @@ 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)
@@ -313,8 +293,6 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
@@ -322,7 +300,6 @@ 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),
@@ -356,7 +333,6 @@ class SettingsStore(context: Context) {
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
@@ -364,7 +340,6 @@ 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)
@@ -397,7 +372,6 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -416,7 +390,6 @@ 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"
@@ -792,13 +765,6 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
)
}
/** (stored value, label) for when the console UI takes over the Apple client's table verbatim.
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
val GAMEPAD_UI_MODE_OPTIONS = listOf(
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
GAMEPAD_UI_ALWAYS to "Always",
)
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -77,7 +77,6 @@ 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
@@ -592,24 +591,11 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Swap the touch home for the console home — the host carousel and " +
"gamepad chrome. A TV always uses it.",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
// Only decides anything while the switch above is on, so it is HIDDEN rather than
// dimmed when it isn't — a picker whose every option changes nothing is worse than
// no picker, and this group is short enough that nothing jumps far.
if (s.gamepadUiEnabled) {
SettingDropdown(
label = "Show it",
options = GAMEPAD_UI_MODE_OPTIONS,
selected = s.gamepadUiMode,
caption = "With a controller: the touch home comes back when the last one " +
"disconnects. Always keeps the console home either way — for a device " +
"that lives docked to a TV.",
) { v -> update(s.copy(gamepadUiMode = v)) }
}
}
}
}
@@ -863,8 +849,7 @@ 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. An X-Box type has no " +
"gyroscope, so pick a DualSense-class one if you want motion.",
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
@@ -903,18 +888,6 @@ 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,13 +18,12 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 35-double
* `capturedecoded` headline without the `display` term when it didn't. Reads the 33-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, audioBufferMs,
* audioAvOffsetMs]`. Every read
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. 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
@@ -45,7 +44,7 @@ import kotlin.math.roundToInt
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
* and the excluded-floor line when one was measured.
* [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).
*/
@@ -179,42 +178,10 @@ 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 capturedisplayed 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,13 +67,11 @@ 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
@@ -139,19 +137,6 @@ 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
@@ -374,9 +359,6 @@ 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
@@ -473,39 +455,9 @@ 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), 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) }
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved
// 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
@@ -635,17 +587,12 @@ 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.
@@ -906,11 +853,6 @@ 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))
}
}
}
@@ -997,28 +939,6 @@ 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
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(7, firstLight)
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
@@ -72,25 +72,6 @@ class GamepadPaletteTest {
}
}
/**
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
* not merely the darkest of the dark fields. The blob field this client draws samples the
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
*/
@Test
fun oledIsActuallyBlack() {
val oled = GamepadPalette.named("oled")
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
val darkestOther = GamepadPalette.ALL
.filter { it.id != "oled" && it.stops.isNotEmpty() }
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
@@ -142,9 +123,7 @@ class GamepadPaletteTest {
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
) {}
val rows = buildSettingsRows(Settings(), hasBodyVibrator = 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.
@@ -158,9 +137,7 @@ class GamepadPaletteTest {
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
@@ -24,7 +24,6 @@ class GamepadSettingsRowsTest {
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
) { sink += it }
@@ -95,47 +94,4 @@ class GamepadSettingsRowsTest {
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
/**
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
* OFFERED only while that switch is on hidden rather than dimmed, because with the switch
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
* be one last thing to step past on the way out.
*/
@Test
fun `the activation-mode row follows the switch it belongs to`() {
fun ids(enabled: Boolean) = buildSettingsRows(
Settings(gamepadUiEnabled = enabled),
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) {}.map { it.id }
val on = ids(enabled = true)
assertTrue("the mode row is missing", "gamepadUIMode" in on)
assertEquals(
"the mode belongs directly under the switch it qualifies",
on.indexOf("gamepadUI") + 1,
on.indexOf("gamepadUIMode"),
)
val off = ids(enabled = false)
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
}
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
@Test
fun `the activation-mode row steps the shared key`() {
var s = Settings()
fun mode() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }.first { it.id == "gamepadUIMode" }
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertEquals("With a controller", mode().value)
assertFalse("already the first = thud", mode().adjust(-1))
assertTrue(mode().adjust(1))
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
// A from the last entry wraps home.
mode().activate()
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
}
}
@@ -1,53 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [gamepadUiActive] is pure table-tested over its inputs, and the mirror of the Apple client's
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
* disagreement here is a device that behaves differently from the same setting.
*/
class GamepadUiTest {
/** The default mode is what the switch meant when it was a lone Boolean. */
@Test
fun whenConnectedWaitsForAPad() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
// A TV is in console mode whatever the mode says — its remote is the only input.
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
}
/** Always drops the controller from the decision but never the switch, which is the one
* way back to the touch UI. */
@Test
fun alwaysIgnoresThePadButNotTheSwitch() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
}
/** A value a newer client wrote waits for a pad rather than stranding this build in a
* layout it has no way back out of. */
@Test
fun anUnknownModeWaitsForAPad() {
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
}
/** The shipped default: the console UI still waits for a controller. */
@Test
fun theDefaultIsUnchangedBehaviour() {
val s = Settings()
assertTrue(s.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
}
}
@@ -77,7 +77,6 @@ class ProfilesTest {
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
@@ -1,94 +0,0 @@
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,12 +355,10 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// 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].
// 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].
// 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
@@ -378,12 +376,6 @@ 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",
+19 -61
View File
@@ -67,37 +67,30 @@ 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
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")
// 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
@@ -120,41 +113,6 @@ 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
@@ -1,170 +0,0 @@
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,11 +22,9 @@ 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, 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
* 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
* unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
@@ -57,13 +55,6 @@ 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
@@ -133,11 +124,6 @@ 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
@@ -149,88 +135,9 @@ 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
@@ -250,10 +157,6 @@ 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
@@ -265,10 +168,7 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: 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
if (!DsDevice.parseState(m, report, len, state)) 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
@@ -289,9 +189,7 @@ class DsCapture(
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
// 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
val p = router.openExternal(m.pref) ?: 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.
@@ -381,10 +279,6 @@ 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()
@@ -416,8 +310,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 (already in wire units the parse applies
* this pad's calibration, and sensor noise makes per-report dedup pointless).
* 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).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
@@ -589,9 +483,5 @@ 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,7 +1,5 @@
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
@@ -30,168 +28,14 @@ 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), its touchpad extent
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* 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).
* onto the wire's 0..65535 space.
*/
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)
}
}
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),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
@@ -206,9 +50,8 @@ 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 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
* 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
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
@@ -216,8 +59,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) // wire i16: 20 LSB per °/s (pitch/yaw/roll)
val accel = IntArray(3) // wire i16: 10000 LSB per g
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
@@ -265,25 +108,15 @@ 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,
cal: MotionCal = MotionCal.NOMINAL,
): Boolean =
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out, cal)
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out, cal)
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): 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))
@@ -319,8 +152,8 @@ object DsDevice {
}
out.buttons = w
if (len >= 28) {
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))
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)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
@@ -329,7 +162,7 @@ object DsDevice {
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
private fun parseDs4(r: ByteArray, len: Int, out: State): 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))
@@ -355,8 +188,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] = 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))
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)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
@@ -3,7 +3,6 @@ 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
@@ -55,31 +54,6 @@ 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/ 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,7 +7,6 @@ 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
/**
@@ -32,8 +31,7 @@ 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, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(
context: Context,
@@ -71,18 +69,7 @@ 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,
/**
* 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,
) {
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
@@ -98,33 +85,12 @@ class GamepadRouter(
private val slots = ConcurrentHashMap<Int, Slot>()
/**
* 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.
* 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.
*/
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.
@@ -149,17 +115,6 @@ 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
@@ -365,82 +320,13 @@ 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,
/**
* 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,
) {
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
// 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]
@@ -471,7 +357,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 && motionReaches) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -487,26 +373,15 @@ 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, hasGyro: Boolean = false): ExternalPad? {
fun openExternal(pref: Int): 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, motionReaches)
return ExternalPad(syntheticId, index)
}
/**
@@ -562,18 +437,8 @@ 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)
// 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),
)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
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,42 +442,6 @@ 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].
*
@@ -505,13 +469,12 @@ class HidUsbLink(
device = null
}
companion object {
private const val READ_TIMEOUT_MS = 100L
private const val WRITE_TIMEOUT_MS = 250
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
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 ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -1,63 +0,0 @@
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 35 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 33 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, audioBufferMs, audioAvOffsetMs]`
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* 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,10 +285,7 @@ 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` = receivedqueued (hand-off + input-slot wait),
* `codec` = queueddecoded, 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;
* 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).
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
* Poll ~1 Hz; each call resets the measurement window.
*/
external fun nativeVideoStats(handle: Long): DoubleArray?
@@ -519,23 +516,6 @@ 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
@@ -1,247 +0,0 @@
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 (yawRY, pitchRX, rollRZ) 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/, 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 })
}
}
}
@@ -1,54 +0,0 @@
package io.unom.punktfunk.kit
import android.view.Surface
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the phone-gyro mirror's devicecontroller 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,185 +151,6 @@ 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
@@ -1,152 +0,0 @@
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)
}
}
@@ -1,92 +0,0 @@
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/: 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)
}
}
+12 -106
View File
@@ -20,16 +20,6 @@
//! (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,
@@ -44,14 +34,6 @@ 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;
@@ -112,45 +94,15 @@ 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
@@ -175,15 +127,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| -> OpenedPlayback {
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
AudioStream,
SyncSender<Vec<f32>>,
Receiver<Vec<f32>>,
)> {
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()
@@ -194,7 +146,6 @@ 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
@@ -220,13 +171,6 @@ 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
@@ -257,6 +201,9 @@ 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);
@@ -356,7 +303,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, sync))
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
.ok();
Some(AudioPlayback {
@@ -387,7 +334,6 @@ 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
@@ -408,44 +354,9 @@ 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.
@@ -493,17 +404,12 @@ 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={} av_ms={} peak={window_peak:.3}",
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
(depth / ms.max(1)) as u64,
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
counters.target_ms.load(Ordering::Relaxed),
av.offset_ms(),
);
window_peak = 0.0;
}
@@ -204,15 +204,7 @@ 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());
// 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 tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
let render_cb = install_render_callback(&codec, &tracker);
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
+9 -45
View File
@@ -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, AtomicU64, Ordering};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use super::latency::now_realtime_ns;
@@ -35,16 +35,6 @@ 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>,
@@ -58,13 +48,11 @@ 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()),
})
@@ -117,14 +105,7 @@ pub(super) fn install_render_callback(
}
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
if sym.is_null() {
// 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"
);
log::info!("decode: no render callback on this API level (<33) — no display stage");
return None;
}
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
@@ -164,10 +145,8 @@ 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) — 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.
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
/// unwind out of an `extern "C"` fn would abort the process.
unsafe extern "C" fn on_frame_rendered(
_codec: *mut ndk_sys::AMediaCodec,
userdata: *mut c_void,
@@ -207,28 +186,13 @@ 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);
// 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.
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
// 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,
);
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);
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
@@ -185,12 +185,9 @@ 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);
+2 -15
View File
@@ -33,21 +33,8 @@ 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`) 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
}
// 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
}
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
+7 -10
View File
@@ -26,15 +26,6 @@ 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
@@ -93,7 +84,13 @@ 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| -> OpenedCapture {
let try_open = |sharing: AudioSharingMode,
voice: bool|
-> ndk::audio::Result<(
AudioStream,
Receiver<Vec<f32>>,
SyncSender<Vec<f32>>,
)> {
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
+2 -2
View File
@@ -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 slot in frame.iter_mut().take(channels).skip(2) {
*slot = sample;
for c in 2..channels {
frame[c] = sample;
}
}
if let Err(e) = playback.write_interleaved(&chunk) {
@@ -361,40 +361,6 @@ 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
+4 -16
View File
@@ -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 35 doubles
/// (unified stats spec, `design/stats-unification.md`). Returns 33 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, audioBufferMs, audioAvOffsetMs]`
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 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,10 +203,7 @@ 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); 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.
/// newest-wins pacing)), 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).
@@ -230,7 +227,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; 35] = [
let buf: [f64; 33] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -284,15 +281,6 @@ 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,
-8
View File
@@ -19,14 +19,6 @@
<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
@@ -99,10 +99,6 @@ struct ContentView: View {
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over "connected" (default) or "always". See
/// `GamepadUIEnvironment`.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@@ -117,8 +113,7 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
// The body is split in two `driven` (the screen plus its lifecycle drivers and sheets) and
@@ -389,13 +384,7 @@ struct ContentView: View {
.frame(minWidth: 940, minHeight: 620)
}
#else
// 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
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
@@ -412,14 +401,6 @@ 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 } })
}
@@ -577,8 +558,7 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -594,8 +574,7 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
// 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
@@ -785,15 +764,6 @@ 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,11 +83,6 @@ 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
@@ -95,24 +90,21 @@ struct ConnectOverlay: View {
VStack(spacing: gamepadUI ? 16 : 14) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(overlayFG)
ProgressView().controlSize(.large).tint(.white)
Text("Connecting to \(name)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.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(overlayFG.opacity(0.9))
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
Text("\(w.hostName) didn't wake")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
@@ -120,13 +112,12 @@ struct ConnectOverlay: View {
}
.padding(.top, 6)
case .waking(let w):
ProgressView().controlSize(.large).tint(overlayFG)
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6))
.monospacedDigit()
.font(.geistFixed(bodySize)).foregroundStyle(.white.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,15 +14,7 @@ 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.
@@ -44,8 +36,8 @@ struct GamepadAddHostView: View {
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { performClose() },
isActive: controllerActive && editing == nil
onBack: { dismiss() },
isActive: editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -53,8 +45,7 @@ struct GamepadAddHostView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
@@ -63,14 +54,14 @@ struct GamepadAddHostView: View {
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -82,10 +73,7 @@ struct GamepadAddHostView: View {
.background { GamepadTrayScrim(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
.background { 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()
@@ -93,18 +81,6 @@ 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
@@ -165,10 +141,22 @@ struct GamepadAddHostView: View {
#endif
}
/// 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() }
/// 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")
}
// MARK: - Rows
@@ -249,7 +237,7 @@ struct GamepadAddHostView: View {
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(port) ?? 9777))
performClose()
dismiss()
default:
openKeyboard(id)
}
@@ -55,14 +55,7 @@ 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
/// 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
@ViewBuilder let card: (Item) -> Card
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
@@ -90,26 +83,6 @@ 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).
@@ -121,27 +94,24 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
// 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
ForEach(items) { 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, entrance(idx))
card(item)
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item, entrance(idx))
card(item)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
.id(item.id) // explicit scroll-target identity for scrollPosition
#endif
}
}
@@ -195,10 +165,7 @@ 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()
@@ -233,55 +200,9 @@ 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() {
@@ -425,87 +346,4 @@ 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, 01.
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 01, 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,47 +23,22 @@ 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. 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.
/// at all, so the iOS value hugs the top edge there.
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#if os(macOS)
26
#elseif os(tvOS)
24
#else
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
compact ? 4 : 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. Sized as a proper screen heading the field verdict
/// on the smaller first cut was "way too small" once the title moved off-centre.
/// in-hand compact-aware sizes elsewhere.
func gamepadTitleSize(compact: Bool) -> CGFloat {
#if os(tvOS)
44
#else
compact ? 24 : 34
compact ? 20 : 30
#endif
}
@@ -83,7 +58,8 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
@@ -96,8 +72,8 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
#endif
}
@@ -171,21 +147,8 @@ struct GamepadHintBar: View {
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// 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
}
/// Quiet the field for a form screen (see the type comment).
var calm = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -221,22 +184,21 @@ 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(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)
.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)
}
// 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(0.42 * strength)],
colors: [.clear, scrim.opacity((calm ? 0.21 : 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.
@@ -380,39 +342,20 @@ 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)
// 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.
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .black.opacity(0.9), location: 0.5),
.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. 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 }
// text always sits on the strong part, rows blur out before they reach it.
.padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea()
}
}
@@ -74,9 +74,6 @@ 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).
@@ -96,56 +93,29 @@ 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 {
// 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
GeometryReader { geo in
hero(for: geo.size)
}
// 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)
// 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)
}
.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()
@@ -159,17 +129,6 @@ 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.
@@ -177,9 +136,8 @@ struct GamepadHomeView: View {
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive`
// 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.
// 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.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
@@ -190,7 +148,7 @@ struct GamepadHomeView: View {
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#elseif os(tvOS)
#else
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
@@ -198,110 +156,6 @@ 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 {
@@ -327,27 +181,32 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
// 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.
// 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.
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, alignment: .leading)
statusChip
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 24)
.padding(.horizontal, 20)
}
/// 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.
@ViewBuilder private var statusChip: some View {
/// 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 {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
@@ -370,9 +229,14 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
isActive: homeOwnsController
) { tile, entrance in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
// 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))
}
.frame(height: cardHeight + 40)
}
@@ -381,12 +245,8 @@ 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, entrance: CardEntrance
) -> some View {
private func hostCard(_ tile: HomeTile, size: CGSize) -> 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
@@ -542,15 +402,10 @@ 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(GamepadInk.onlineGreen)
.fill(Color.green)
.frame(width: Self.pipSide, height: Self.pipSide)
.shadow(
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
radius: 5)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
}
@@ -586,7 +441,7 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
@@ -37,12 +37,6 @@ 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)
@@ -66,10 +60,6 @@ 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 {
@@ -85,40 +75,16 @@ extension EnvironmentValues {
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink AND the matching colour scheme to
/// everything below. Applied by the gamepad screens' common root so no individual view has to
/// read the setting.
///
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
/// palette's scheme would invert their own system colours instead of matching them.
func gamepadPaletteInk(_ active: Bool = true) -> some View {
modifier(GamepadInkModifier(active: active))
}
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
}
private struct GamepadInkModifier: ViewModifier {
var active = true
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
/// The ambient scheme from ABOVE this modifier what gets republished unchanged when the
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
/// that would change this view's identity.
@Environment(\.colorScheme) private var systemScheme
func body(content: Content) -> some View {
let palette = GamepadPalette.named(paletteID)
return content
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
// screens `.secondary` in a placeholder, a `.bordered` button's chrome, a
// NavigationStack's title, a material's frost resolves against the DEVICE's
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
// the interface was unreadable. Publishing the scheme here once, beside the ink it
// has to agree with is what makes a pale palette mean "light" to UIKit too.
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
}
}
@@ -111,9 +111,7 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
// 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)
.foregroundStyle(focused ? Color.black : ink.fg)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
@@ -1,55 +0,0 @@
// 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
@@ -1,95 +0,0 @@
// 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,16 +21,11 @@ import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let artLoader: LibraryArtLoader?
let imageSession: URLSession?
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.
@@ -41,18 +36,6 @@ 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
@@ -63,19 +46,10 @@ struct LibraryCoverflowView: View {
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
// 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() }
}
.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()
// 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 {
@@ -107,11 +81,9 @@ struct LibraryCoverflowView: View {
spacing: 34,
onActivate: { onLaunch?($0.id) },
onBack: { onDismiss?() },
shoulderJump: 5,
isActive: controllerActive,
contentReady: contentReady
) { game, entrance in
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
shoulderJump: 5
) { game in
cover(game, width: coverWidth, height: coverHeight)
}
.frame(height: coverHeight + 44)
}
@@ -120,26 +92,18 @@ 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, entrance: CardEntrance
) -> some View {
PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
onLoaded: { artSettled += 1 })
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) {
// `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)
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
// Beneath the scroll transition, never around it see CardEntrance.
.modifier(entrance)
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
let v = phase.value
let d = CGFloat(min(abs(v), 1))
@@ -12,32 +12,23 @@ 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?
/// 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?
/// 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?
#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.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
@@ -64,33 +55,9 @@ struct LibraryView: View {
}
.task { await load() }
.onDisappear {
// 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() }
imageSession?.finishTasksAndInvalidate()
imageSession = nil
}
#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
#if os(iOS) || os(macOS) || os(tvOS)
// Published HERE, not just inside the coverflow, because the coverflow is only one of
// four things this view renders: the loading spinner, the error state and the empty
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
// directly in a NavigationStack, so under a pale palette every one of them kept the
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
// UI isn't drawing the plain grid belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
#endif
}
@ViewBuilder private var content: some View {
@@ -104,9 +71,8 @@ struct LibraryView: View {
} else {
if gamepadUIActive {
LibraryCoverflowView(
games: games, artLoader: artLoader, onLaunch: onLaunch,
onDismiss: { (onClose ?? { dismiss() })() },
controllerActive: controllerActive)
games: games, imageSession: imageSession, onLaunch: onLaunch,
onDismiss: { dismiss() })
} else {
grid
}
@@ -139,10 +105,10 @@ struct LibraryView: View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, artLoader: artLoader)
GameCard(game: game, imageSession: imageSession)
}
}
}
@@ -221,7 +187,8 @@ struct LibraryView: View {
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256
).launchersFirst
artLoader = try LibraryArtLoader(
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
@@ -235,39 +202,15 @@ 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 artLoader: LibraryArtLoader?
let imageSession: URLSession?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
@@ -17,29 +17,16 @@ 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 || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(fill, in: Capsule())
.background(
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
in: Capsule())
.padding(6)
}
}
@@ -60,8 +47,8 @@ private extension Image {
}
}
/// 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
/// 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
/// 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
@@ -70,11 +57,7 @@ private extension Image {
struct PosterImage: View {
let candidates: [URL]
let title: String
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)?
let session: URLSession?
@State private var index = 0
@State private var image: PlatformImage?
@@ -84,38 +67,26 @@ 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 {
// 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]),
guard index < candidates.count else { return }
guard let session, let data = try? await session.data(from: candidates[index]).0,
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 }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ 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 }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ in })
} else {
ShotHome()
}
@@ -132,17 +132,6 @@ 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.
@@ -164,20 +153,6 @@ 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
@@ -549,21 +524,6 @@ 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
@@ -613,11 +573,6 @@ 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
@@ -673,7 +628,6 @@ final class SessionModel: ObservableObject {
displayValid = false
clientQueueValid = false
osFloorValid = false
audioValid = false
lostFrames = 0
lostPct = 0
mouseCaptured = false
@@ -748,14 +702,7 @@ final class SessionModel: ObservableObject {
micUID: settings.micUID,
micChannel: settings.micChannel,
micEnabled: settings.micEnabled,
echoCancel: settings.echoCancel,
// The A/V sync reference: `endToEnd` is captureon-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)
echoCancel: settings.echoCancel)
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
@@ -775,9 +722,6 @@ 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)
@@ -916,15 +860,6 @@ 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)
@@ -940,12 +875,7 @@ 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 "
// 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",
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -957,9 +887,7 @@ 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.audioValid ? self.audioBufferMs : -1,
self.audioValid ? self.audioAvOffsetMs : 0)
self.clientQueueValid ? self.clientQueueP50Ms : -1)
statsLog.info("\(line, privacy: .public)")
}
}
@@ -154,28 +154,6 @@ 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
@@ -246,18 +224,9 @@ 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² (ry)²)` 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
@@ -277,20 +246,13 @@ struct StreamHUDView: View {
#endif
}
/// 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.
/// 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.
private var cardCornerRadius: CGFloat {
#if os(iOS)
return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset))
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else
@@ -305,39 +267,6 @@ 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
@@ -1,180 +0,0 @@
// 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,18 +47,10 @@ 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
@@ -81,9 +73,6 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over the row is only built while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background colour family the backdrop BEHIND this screen re-colours as
/// the row steps, which is why the picker lives here and not in a sheet.
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -96,7 +85,6 @@ 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
@@ -138,8 +126,7 @@ struct GamepadSettingsView: View {
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { back() },
onShoulder: { step(tabBy: $0) },
isActive: controllerActive
onShoulder: { step(tabBy: $0) }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -147,20 +134,18 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
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).
VStack(spacing: compact ? 4 : 8) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
// 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, gamepadTitleBottomPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
@@ -182,12 +167,8 @@ 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. 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() }
}
// applies here too, so this screen previews the row you're stepping.
.background { 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()
@@ -196,18 +177,6 @@ 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
@@ -260,12 +229,10 @@ 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. A Liquid Glass
// surface (accent-tinted through consoleGlass), so the strip wears the same
// material language as the rows it sits above.
// in and out the highlight travels the way the press did.
if selected {
Color.clear
.consoleGlass(Capsule(), tint: ink.accent(0.85))
Capsule()
.fill(ink.accent(0.85))
.matchedGeometryEffect(id: "tab", in: tabHighlight)
}
}
@@ -307,10 +274,22 @@ struct GamepadSettingsView: View {
focusID = landing
}
/// 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() }
/// 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")
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
@@ -358,7 +337,7 @@ struct GamepadSettingsView: View {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else {
performClose()
dismiss()
}
}
@@ -384,31 +363,24 @@ struct GamepadSettingsView: View {
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
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)
// 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)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
} 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)
.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)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
@@ -438,17 +410,6 @@ 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 {
@@ -461,11 +422,6 @@ 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
@@ -662,21 +618,6 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled),
]
// WHEN the switch above takes over. Built only while it is on: with the switch off this
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
// that decides nothing would exist purely to be found in a screenshot.
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
list.insert(
choiceRow(
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
label: "Show it",
detail: "With a controller: the touch interface comes back when the last one "
+ "disconnects. Always keeps this layout either way — for a device that "
+ "lives on a TV.",
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
) { gamepadUIMode = $0 },
at: at + 1)
}
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video tab) macOS only, mirroring the touch SettingsView's Presentation row
@@ -708,31 +649,7 @@ 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
// The smoothness buffer only decides anything under Smoothness. Every other settings
// surface touch, tvOS, the GTK and WinUI shells hides it under Lowest latency; this
// screen alone left it live and steppable, which is a row that thuds or silently stores
// a value nothing reads. Removed here rather than omitted from the literal above so the
// macOS safe-present insertion can still anchor on it.
if presentPriority != "smooth" {
list.removeAll { $0.id == "smoothBuffer" }
}
return list + profileRows
}
@@ -793,8 +710,6 @@ 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 }
@@ -861,10 +776,6 @@ 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.
@@ -892,10 +803,6 @@ 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.
@@ -53,14 +53,6 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) only meaningful while
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
/// is off rather than showing a picker that decides nothing.
static let gamepadUIModes: [(label: String, tag: String)] = [
("With a controller", GamepadUIEnvironment.modeWhenConnected),
("Always", GamepadUIEnvironment.modeAlways),
]
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
@@ -712,36 +712,14 @@ 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 {
described("The host list and library switch to a controller-friendly layout — "
+ "larger focus targets, a swipeable cover browser.") {
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
// Only meaningful while the switch above is on, so it is HIDDEN rather than
// disabled when it isn't: a picker whose every option decides nothing is worse
// than no picker, and this Section is short enough that nothing jumps far.
if gamepadUIEnabled {
described("With a controller: the touch interface comes back when the last "
+ "one disconnects. Always keeps the controller-friendly layout either "
+ "way — for a device that lives on a TV.") {
Picker("Show it", selection: $gamepadUIMode) {
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
}
#endif
#if DEBUG && !os(tvOS)
@@ -75,13 +75,6 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
/// When the switch above takes over read (and shown) only while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) every other
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
/// controller cannot open.
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
@@ -98,7 +91,6 @@ 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).
@@ -495,22 +487,6 @@ struct SettingsView: View {
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
// Hidden while the switch above is off see the touch settings' identical gate.
if gamepadUIEnabled {
TVSelectionRow(
title: "Show it",
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
// Background row lives on the gamepad settings screen, which is reached from
// the gamepad launcher and on tvOS that launcher needs an extended-profile
// controller, so an Apple TV driven by the Siri Remote alone could not reach
// the palettes at all. It belongs beside "Show it" because both describe the
// same interface: this row is what that interface looks like once it is up.
TVSelectionRow(
title: "Background",
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
selection: $uiPalette)
}
tvCaption(Self.controllersFooter)
NavigationLink("About") { AboutView() }
.padding(.top, 8)
@@ -70,17 +70,12 @@ extension View {
// MARK: - Console glass (gamepad host tiles + settings rows)
/// Liquid Glass tuned for the gamepad UI's "console" surfaces the host-carousel tiles and
/// Liquid Glass tuned for the gamepad UI's dark "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 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.
/// 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.
private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
@@ -91,45 +86,25 @@ 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 {
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
// against the device appearance which is how the pale palettes came out light-on-light
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
// tvOS and pre-26 branches were the odd ones out.
#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 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) }
}
}
.environment(\.colorScheme, scheme)
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
}
#endif
}
@@ -137,13 +112,8 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass {
// 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)
var g: Glass = .regular
if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() }
return g
}
@@ -151,54 +121,9 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
}
extension View {
/// 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.
/// 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.
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 {
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
content
.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
.environment(\.colorScheme, scheme)
}
}
}
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,23 +16,11 @@ 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: 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.
/// **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`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
@@ -60,33 +48,6 @@ 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
@@ -109,32 +70,6 @@ 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()
@@ -150,71 +85,9 @@ 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) 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
/// sustain a target below its own quantum).
private var target: Int {
max(targetLive, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -262,24 +135,12 @@ 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) {
@@ -303,9 +164,6 @@ 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)
}
@@ -320,84 +178,27 @@ 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 || 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.
if emptyReads >= Self.deprimeAfter {
primed = false
emptyReads = 0
}
if !restored {
underrunsInWindow += 1
}
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
// 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 {
if quietRun >= Self.shrinkQuietMS * perMS {
quietRun = 0
let prev = targetLive
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
if targetLive < prev {
probeRun = Self.shrinkProbeMS * perMS
probePrevTarget = prev
}
}
}
}
@@ -438,12 +239,6 @@ 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 {
@@ -453,154 +248,7 @@ final class AudioRing: @unchecked Sendable {
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
targetMS: target / max(perMS, 1),
underruns: underrunCount,
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)
sheds: shedCount)
}
}
@@ -62,13 +62,6 @@ 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 (captureon-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
@@ -98,16 +91,9 @@ 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 (captureon-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,
videoLatency: LatencyMeter? = nil
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
self.videoLatency = videoLatency
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -319,31 +305,6 @@ 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
@@ -440,25 +401,9 @@ 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).
@@ -472,25 +417,6 @@ 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)
@@ -504,7 +430,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) av_offset_ms=\(s.avOffsetMS)"
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
)
}
return true
@@ -557,52 +483,19 @@ public final class SessionAudio {
}
engine.attach(source)
engine.connect(source, to: engine.mainMixerNode, format: format)
// 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()
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.
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)")
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)
input.removeTap(onBus: 0)
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
return
}
stateLock.lock()
@@ -640,16 +533,8 @@ public final class SessionAudio {
}
}
#endif
// 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".
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
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 {
@@ -1,116 +0,0 @@
// 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,6 +18,9 @@
import CryptoKit
import Foundation
import Security
import os
private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls")
enum ClientTLS {
enum TLSError: LocalizedError {
@@ -131,8 +134,62 @@ enum ClientTLS {
}
}
// 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.
/// 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)
}
}
}
@@ -1,195 +0,0 @@
// 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,10 +83,6 @@ 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)
@@ -95,22 +91,12 @@ 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):
// 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."
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)."
}
}
}
@@ -129,67 +115,48 @@ public enum LibraryClient {
keyPEM: String,
hostFingerprint: Data?
) async throws -> [GameEntry] {
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: 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.
for i in games.indices {
games[i].art = games[i].art.resolved(against: base)
}
return games
// 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(response.status)
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
throw LibraryError.unreachable("invalid host address")
}
}
/// `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 {
let identity: SecIdentity
do {
return try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
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() }
/// 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 {
let req = URLRequest(url: url, timeoutInterval: 10)
let (data, response): (Data, URLResponse)
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))
(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 {
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
// 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:
throw LibraryError.unauthorized
default:
throw LibraryError.http(http.statusCode)
}
}
}
@@ -212,77 +179,23 @@ extension Artwork {
}
}
/// 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(
/// 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(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) 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)
) 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)
}
}
@@ -1,377 +0,0 @@
// 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,51 +311,6 @@ 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
@@ -363,18 +318,6 @@ 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
@@ -629,9 +572,6 @@ 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
@@ -1,221 +0,0 @@
// 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,13 +66,7 @@ public final class GamepadCapture {
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
/// 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)
var lastMotionNs: UInt64 = 0
// Hold-Selectguide 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
@@ -95,6 +89,9 @@ 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
@@ -131,15 +128,6 @@ 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
@@ -165,15 +153,6 @@ 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
@@ -183,17 +162,6 @@ 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() {
@@ -219,9 +187,6 @@ 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(
@@ -234,15 +199,11 @@ 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) }
@@ -263,8 +224,6 @@ 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
@@ -340,43 +299,10 @@ 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.
//
// 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)
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) } }
}
}
}
@@ -635,96 +561,36 @@ public final class GamepadCapture {
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
guard !suspended else { return }
#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 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.
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
// 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
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_)
))
}
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
@@ -768,14 +634,6 @@ 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,10 +41,6 @@ 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
@@ -269,10 +265,7 @@ public final class GamepadManager: ObservableObject {
kind: kind,
hasLight: c.light != nil,
hasHaptics: c.haptics != 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,
hasMotion: c.motion != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
// Edge included); the DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
@@ -3,40 +3,20 @@
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
import PunktfunkShared
public enum GamepadUIEnvironment {
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
/// and what the switch meant when it was a lone Bool.
public static let modeWhenConnected = "connected"
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad asked
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
/// one they want and the pad is not always awake.
public static let modeAlways = "always"
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) off means
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
/// value a newer client wrote) waits for a controller.
///
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
/// (which XCTest can't construct).
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
/// everyone who picked Always back on "only with a controller", which is exactly the bug
/// this parameter exists to make impossible.
public static func isActive(
gamepadConnected: Bool,
enabledSetting: Bool,
mode: String
) -> Bool {
guard enabledSetting else { return false }
return mode == modeAlways || gamepadConnected || forced
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && (gamepadConnected || forced)
}
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
@@ -73,30 +73,6 @@ 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.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
cc 1.2.65 — 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.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
find-msvc-tools 0.1.9 — 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.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
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
----------------------------------------------------------------------------
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.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
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
----------------------------------------------------------------------------
Copyright (c) 2014 Alex Crichton
@@ -24,10 +24,6 @@ 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() {}
@@ -53,42 +49,10 @@ 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
@@ -176,23 +176,16 @@ public enum DefaultsKey {
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default only while a
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
/// settings rows hide it when the switch is off. Anything unrecognized reads as
/// `"connected"`. A device preference, never part of a stream profile.
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
/// Which colour family the gamepad UI's living backdrop drifts through a
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
/// console and the Android client carry the same table under the same names. Presentation
/// only, so it is a device preference and never part of a stream profile. An unknown value
/// reads as the default rather than failing a newer client may have shipped a palette this
/// build doesn't know.
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
/// Android client carry the same table under the same names. Presentation only, so it is
/// a device preference and never part of a stream profile. An unknown value reads as the
/// default rather than failing a newer client may have shipped a palette this build
/// doesn't know.
public static let uiPalette = "punktfunk.uiPalette"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
@@ -200,14 +193,6 @@ 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
@@ -65,25 +65,13 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off no glow,
// no power. The first two stops are literally (0,0,0), so the shaded half of the
// field is genuinely off rather than "very dark grey", and the ground is pure black
// too: the calm mix on the form screens lifts toward nothing. What is left is a
// faint indigoviolet ember in the bright corner. The accent stays the brand violet
// focus has to be findable on black.
id: "oled", name: "OLED",
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
SIMD3(0.120, 0.024, 0.130)],
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -53,22 +53,11 @@ final class AudioRingDriftTests: XCTestCase {
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
}
/// 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() {
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
/// "corrected" into a stutter.
func testNegativeDriftKeepsPlaying() {
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
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")
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
}
/// A device that pulls a large quantum cannot sustain a target below it the ring must lift
@@ -90,14 +79,11 @@ 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 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) }
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
// already holding it exclusively is an exclusivity violation.
var huge = [Float](repeating: 0, count: 200 * perMS)
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
let feed = [Float](repeating: 0.5, count: want)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
@@ -106,17 +92,14 @@ 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`, 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.
/// 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.
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: 60 * perMS)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
@@ -125,27 +108,20 @@ final class AudioRingDriftTests: XCTestCase {
}
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
// 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.
// 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.
write(ms: 25)
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.)
for _ in 0..<5 { read() } // drains to zero
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, 40, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.underruns, 3)
// 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) {
// A long clean run (30 s of consumed audio) relaxes the growth back to the base
for _ in 0..<(30_000 / 5 + 10) {
write(ms: 5)
read()
}
@@ -222,431 +198,5 @@ 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
@@ -1,62 +0,0 @@
// Pins the phone-gyro mirror's devicecontroller 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
@@ -1,91 +0,0 @@
// 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)
}
}
@@ -1,62 +0,0 @@
// 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))
}
}
@@ -46,29 +46,12 @@ final class GamepadPaletteTests: XCTestCase {
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
["violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 7)
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
/// not merely the darkest of the dark fields.
func testOLEDIsActuallyBlack() {
let oled = GamepadPalette.named("oled")
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
let cells = oled.meshColors
XCTAssertGreaterThanOrEqual(
cells.filter { luma($0) == 0 }.count, 3,
"the shaded corner has to be switched off, not dimmed")
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
let darkestOther = GamepadPalette.all
.filter { $0.id != "oled" }
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
.min() ?? 0
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
@@ -1,58 +1,14 @@
// GamepadUIEnvironment.isActive is pure table-tested exhaustively over its inputs.
// GamepadUIEnvironment.isActive is a pure AND table-tested exhaustively over its 2x2 inputs.
import XCTest
@testable import PunktfunkKit
final class GamepadUIEnvironmentTests: XCTestCase {
private let connected = GamepadUIEnvironment.modeWhenConnected
private let always = GamepadUIEnvironment.modeAlways
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
/// that never sees the new row is exactly where it was.
func testWhenConnectedIsAPlainAnd() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: connected))
}
/// Always drops the controller from the decision entirely but NOT the switch, which stays
/// the one way back to the touch UI.
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: always))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: always))
}
/// A value a newer client wrote must wait for a controller, never strand this build in a
/// layout it has no way back out of.
func testUnknownModeWaitsForAController() {
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: ""))
func testActiveOnlyWhenEnabledAndConnected() {
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
}
}
@@ -71,65 +71,4 @@ 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,234 +78,4 @@ 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")
}
}
+5 -5
View File
@@ -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.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
cc 1.2.65 — 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.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
find-msvc-tools 0.1.9 — 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.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
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
----------------------------------------------------------------------------
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.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
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
----------------------------------------------------------------------------
Copyright (c) 2014 Alex Crichton

Some files were not shown because too many files have changed in this diff Show More