Compare commits

..
Author SHA1 Message Date
enricobuehler 6eb5edaff4 feat(audio): capture gain on punktfunk/1, and a soft knee instead of the clamp that made boosting a trap
android / android (pull_request) Failing after 1m5s
apple / swift (pull_request) Successful in 1m57s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / web (pull_request) Successful in 1m58s
ci / bun-nix (pull_request) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
ci / rust (pull_request) Successful in 21m14s
ci / rust-arm64 (pull_request) Failing after 3m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
`PUNKTFUNK_AUDIO_GAIN` had two defects that compounded.

It existed only on the GameStream plane, so on native `punktfunk/1` it silently did
nothing — and since WASAPI loopback is tapped UPSTREAM of the endpoint's master volume,
turning the host's speaker slider up does not change the level a client receives either.
Between the two there was no host-side way at all to lift a quiet desktop mix on the
protocol that matters.

And where it did apply it was `(s * gain).clamp(-1.0, 1.0)` — a hard clip. Flat-topping a
waveform is a first-derivative discontinuity, which radiates harsh high-order harmonics, so
any operator who pushed past roughly 1.5x heard gross distortion long before reaching the
level they were chasing. A field report of "+18 dB and everything warbles" is the expected
output of that line, not a fault anywhere downstream of it.

`punktfunk_core::audio::apply_gain` replaces the clamp with a tanh soft knee above 0.7
(~-3.1 dBFS), chosen for three properties: C1-continuous where the branches meet (slope 1
on both sides, so the onset of limiting is not itself an audible event), bounded by
construction (asymptotic to 1.0, and +-inf maps to +-1.0, so nothing leaves out of range),
and odd-symmetric (benign harmonics, no DC). It is a memoryless waveshaper, so it costs
zero latency in the realtime encode path.

Unity is a no-op inside `apply_gain` itself, not merely at the call sites, so the default
wire stays byte-for-byte identical and a future caller that forgets to gate cannot quietly
bend every peak. `capture_gain` is now shared by both planes and rejects the two values
that are always typos: non-positive (would invert or mute) and above 8.0/+18 dB (capped,
and said out loud).

This buys headroom, NOT loudness. It cannot close a peak-to-loudness gap against
already-limited broadcast content; that needs a compressor with a real time constant, which
this deliberately is not, and the docs say so.

`SOFT_LIMIT_KNEE` is excluded from cbindgen: it is host-side capture processing that no C
embedder can act on, and exporting it would add a bare `#define` against the config's own
R21 rule. Verified by regenerating `include/punktfunk_core.h` — byte-identical, ABI 19
untouched.
2026-08-14 18:49:29 +02:00
enricobuehler d0a3eca7b8 Merge pull request 'A per-user Playnite install is invisible to a SYSTEM host, and one tile killed the whole library' (#225) from fix/playnite-launcher-resolve into main
arch / build-publish (push) Successful in 9m22s
deb / build-publish-host (push) Successful in 5m46s
ci / docs-site (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 33s
ci / rust-arm64 (push) Successful in 4m33s
ci / rust (push) Successful in 5m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / deploy-docs (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-host / package (push) Successful in 16m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 4m0s
windows-host / canary-manifest (push) Successful in 38s
deb / smoke-install (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
android / android (push) Successful in 7m28s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m2s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 1m30s
docker / builders-arm64cross (push) Successful in 50s
2026-08-14 16:07:37 +00:00
enricobuehler bf741f8693 fix(library): a per-user Playnite install is invisible to a SYSTEM host, and one tile killed the library
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m42s
Syncing the Playnite plugin failed outright:

  PUT /library/provider/playnite failed: entries[9]: launch.value for kind
  launcher_ui names a launcher this host cannot open (playnite)

Two defects, and the second is why it cost every game rather than one tile.

1. The host looked for Playnite in the wrong registry hive and the wrong
   profile. `playnite_fullscreen_exe()` read HKEY_CURRENT_USER, then fell back
   to %LOCALAPPDATA% — but the Windows host is a LocalSystem service, so its
   HKCU is the SYSTEM hive (S-1-5-18) and its %LOCALAPPDATA% is
   C:\Windows\System32\config\systemprofile\AppData\Local. Playnite installs
   per-user by default, so both lookups miss on a default install. The doc
   comment reasoned correctly that Playnite is per-user and then read the one
   HKCU that cannot see it.

   It also hardcoded `…\Uninstall\Playnite`. Playnite ships an Inno Setup
   installer, and Inno registers `<AppId>_is1` — measured on a Windows box
   where Git and Inno itself appear as `Git_is1` and `Inno Setup 6_is1` — so
   that key matched nothing anywhere.

   Now: every loaded hive under HKEY_USERS plus both HKLM views, matched on
   DisplayName rather than key name, then `C:\Users\*\AppData\Local\Playnite`
   for the conventional install (and for a user whose hive is not loaded).

2. One unopenable tile 400'd the whole reconcile. The Playnite plugin appends
   a single launcher tile beside its games, so refusing the payload cost the
   operator the entire library — the same shape as the unservable-cover bug
   that sanitize_art_paths was introduced to fix, on the launch side this time.

   `valid_launcher_ui` conflated two different failures. Split into
   `known_launcher_ui` (vocabulary — a plugin bug, still a hard 400, because
   the author has no other way to find out) and `resolvable_launcher_ui`
   (environment — the launcher just is not installed here, which is a fact
   about the box). `sanitize_launcher_entries` drops only the latter, with one
   warn, and the games sync.
2026-08-14 15:28:41 +02:00
enricobuehler ea5afbaa8c Merge pull request '0.28.1 notes — the Mac microphone loop is a headline fix the notes had never heard of' (#224) from worktree-release-0281-notes into main
decky / build-publish (push) Successful in 43s
arch / build-publish (push) Successful in 7m39s
ci / rust-arm64 (push) Successful in 6m17s
windows-host / package (push) Successful in 13m46s
windows-host / canary-manifest (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m25s
windows-host / winget-source (push) Successful in 20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
android / android (push) Successful in 6m39s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m31s
linux-client-screenshots / screenshots (push) Successful in 3m50s
sbom / sbom (push) Successful in 22s
deb / build-publish-host (push) Successful in 6m58s
deb / build-publish (push) Successful in 7m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 23s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
deb / build-publish-client-arm64 (push) Successful in 1m24s
deb / build-publish-gamescope (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 20s
android-screenshots / screenshots (push) Successful in 2m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m58s
ci / rust (push) Successful in 5m30s
apple / swift (push) Successful in 2m4s
web-screenshots / screenshots (push) Successful in 4m45s
flatpak / build-publish (push) Successful in 8m47s
apple / distribute (push) Successful in 12m34s
deb / smoke-install (push) Successful in 10m14s
ci / bun-nix (push) Successful in 5m52s
apple / screenshots (push) Successful in 8m59s
2026-08-14 11:26:53 +00:00
enricobuehler 832a5ffd8d docs(release): the Mac microphone loop is a headline fix, and the notes had never heard of it
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / web (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 38s
ci / docs-site (pull_request) Successful in 2m19s
ci / rust (pull_request) Successful in 6m56s
Ten more commits landed after the 0.28.1 release commit — the deb image fix, the
two macOS audio ones (#221 + #223) and the TV screenshot automation — so the
release paperwork no longer described the release.

CHANGELOG: 50 -> 60 commits since v0.28.0. Nothing else moves; the version table
is unchanged on every row, re-verified against the tag (`include/`,
`crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/` are all still
byte-identical to v0.28.0, so the C ABI stays 19). #221 brought its own CHANGELOG
section, so the technical half already covered it.

NOTES: the user-facing file had no mention of the macOS fault at all, and it is
headline-grade — streaming from a Mac with the mic on cut audio AND froze input
on a ~2.5 s metronome, with turning the microphone off as the only workaround. It
now leads the summary paragraph, has a TL;DR line and a full Fixed entry
explaining the loop in plain terms (a mic that cannot run echo cancellation, each
failed attempt knocking out the working path and thereby triggering the next).

The TL;DR was also trimmed from nine multi-line bullets to seven one-liners.
`docs/releases/README.md` asks for 3-6, and this release has an unusual number of
genuinely severe entries — seven is the honest floor without hiding one, and the
long-form detail was already duplicated below in Fixed, which is where it belongs.
The Apple stats-overlay and Apple TV colour bullets lost their TL;DR slots and
keep their Fixed entries.

`SessionAudio.start()` being asynchronous on macOS is added to the notes' `For
developers` paragraph — it is the one embedder-visible edge in #223, and an
embedder who only reads the notes would otherwise meet it at runtime.

Play notes are untouched and still accurate: the only commit to touch
clients/android since is `b6b3c10c`, which is screenshot CI, not app behaviour.

Gates on this tree: fmt clean, `cargo metadata --locked` consistent,
`cargo test -p punktfunk-core` 210 passed, C ABI harness abi_version=19,
`api/openapi.json` and the docs-site copy still byte-identical.
2026-08-14 13:15:08 +02:00
enricobuehler 76c677a8f8 Merge pull request 'The TV storefronts were the only ones with no automated screenshot captures' (#222) from worktree-store-shots-tv-automation into main
apple / screenshots (push) Successful in 8m26s
ci / rust-arm64 (push) Successful in 6m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 8m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 14s
ci / web (push) Successful in 9m5s
android / android (push) Successful in 12m53s
apple / distribute (push) Successful in 10m48s
ci / rust (push) Canceled after 14m52s
apple / swift (push) Successful in 1m57s
ci / bun-nix (push) Successful in 43s
Reviewed-on: #222
2026-08-14 11:11:46 +00:00
enricobuehler 7cb70bf6ea Merge pull request 'Apple audio engine starts leave the main thread — input never waits on the audio server' (#223) from worktree-macos-mic-rebuild-loop into main
apple / swift (push) Canceled after 18s
apple / screenshots (push) Canceled after 0s
apple / distribute (push) Canceled after 0s
ci / rust (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 14s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
2026-08-14 11:05:55 +00:00
enricobuehler b6b3c10cb5 ci(screenshots): the TV storefronts were the only ones with no automated captures
ci / bun-nix (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 4m41s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m53s
apple / swift (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Successful in 5m11s
android / android (pull_request) Successful in 11m24s
ci / rust (pull_request) Failing after 10m14s
Google Play's Android TV slot needs 16:9 1920x1080 shots and the App Store
needs Apple TV 1920x1080 — neither existed as automation output:

- apple.yml screenshots job now runs the tvos leg. The harness supported it
  all along (tools/screenshots.sh tvos); what the job was missing is the
  Tier-3 tvOS xcframework slices (nightly + -Zbuild-std, same recipe the
  distribute job uses on this runner) and an explicit scene list — the
  gamepad-console scenes are compiled out on tvOS, and an UNKNOWN scene
  name falls back to a normal app launch, which would silently capture the
  real empty app. Still best-effort: a tvOS hiccup warns, never reds.
- TvScreenshotTest renders the console scenes + the stream HUD at Android
  TV geometry (w960dp-h540dp-television-xhdpi = native 1920x1080, no
  resampling), prefixed tv- so the artifact separates the form factors.
  Verified locally: 6 scenes, all 1920x1080.

android-screenshots.yml needs no change — it runs the whole unit-test task
and uploads the whole roborazzi output dir.
2026-08-14 12:55:03 +02:00
enricobuehler d669064dc0 Merge pull request 'The macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s' (#221) from worktree-macos-mic-rebuild-loop into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m43s
ci / web (push) Successful in 3m20s
docker / deploy-docs (push) Successful in 37s
apple / screenshots (push) Canceled after 3m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9m4s
ci / docs-site (push) Successful in 3m38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7m25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 7m12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m36s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5m46s
apple / distribute (push) Successful in 10m35s
ci / bun-nix (push) Successful in 17s
ci / rust (push) Successful in 6m11s
docker / builders-arm64cross (push) Successful in 2m57s
apple / swift (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
ci / rust-arm64 (push) Successful in 3m5s
Reviewed-on: #221
2026-08-14 10:49:37 +00:00
enricobuehler d4ad8be6bf Merge pull request 'The gamescope deb image never needed x11-xcb until we started building the WSI layer' (#220) from worktree-gamescope-deb-x11xcb into main
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 16s
ci / web (push) Canceled after 18s
ci / bun-nix (push) Canceled after 19s
ci / rust-arm64 (push) Canceled after 16s
ci / docs-site (push) Canceled after 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
Reviewed-on: #220
2026-08-14 10:49:24 +00:00
enricobuehler 1b28a7f7f1 fix(ci): the gamescope deb image never needed x11-xcb until we started building the WSI layer
ci / docs-site (pull_request) Successful in 1m28s
ci / bun-nix (pull_request) Successful in 2m57s
ci / web (pull_request) Successful in 3m9s
ci / rust-arm64 (pull_request) Successful in 5m17s
ci / rust (pull_request) Successful in 8m33s
The v0.28.1 deb leg failed for real, and the package it costs is the whole
punktfunk-gamescope .deb:

    gamescope/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found, tried pkgconfig

Not a flake and not the pin. v0.28.1 flipped
`-Denable_gamescope_wsi_layer=true` in build-punktfunk-gamescope.sh (it was off
before, on the recorded and false premise that the layer is version-independent
of the compositor). The layer is a separate meson subdir with its own dependency
set, and it wants x11-xcb — which the compositor never did. So an image that had
been sufficient for every previous release stopped being sufficient the moment
the layer started building, and nothing named the new dep anywhere.

Debian is the only channel that has to name it: Arch's libx11 and Fedora's
libX11-devel both ship x11-xcb.pc themselves, which is why arch.yml and rpm.yml
build the same tree fine and only the trixie image came up short.

Asserted as well as installed. The image already asserts the wayland-server
floor at build time, on the argument that the one version deciding whether the
image can do its job should fail loudly HERE rather than inside a deb.yml run —
and this is the same class, only worse: a missing x11-xcb does not fail the
compositor build, it fails the layer's, and the layer is the only route to an
HDR10 swapchain for a nested game. Losing it silently produces a package that
looks completely healthy and denies every game HDR, which is precisely the
failure v0.28.1 exists to end. The assertion means the next dependency the layer
grows fails at image build instead of mid-release.

ORDERING, for whoever lands this: docker.yml rebuilds the image on a push to
main (its key hashes the ci/ tree, so this change busts it), and deb.yml's
gamescope job consumes `:latest`. Let the image publish before the deb job that
needs it runs — on a release cut that means merging this, letting docker.yml
finish, and only then pushing the tag. The failed job saved no cache, so the tag
run rebuilds against the new image rather than restoring the broken state.

NOT verified locally: no Docker on this machine, so the image was not built and
the layer was not compiled here. The package name is confirmed against Debian's
own package index (libx11-xcb-dev ships x11-xcb.pc, and exists in trixie), and
the assertion added here is what proves it in CI — if the name were wrong the
image build fails loudly instead of the deb leg failing quietly.
2026-08-14 12:03:55 +02:00
14 changed files with 582 additions and 80 deletions
+21 -10
View File
@@ -676,20 +676,23 @@ jobs:
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
#
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
# `clients/apple/tools/screenshots.sh macos`.
screenshots:
needs: swift
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 75
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Rust toolchain + iOS Simulator targets
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -699,6 +702,10 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
@@ -735,10 +742,10 @@ jobs:
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
working-directory: clients/apple
env:
SETTLE: "8" # Simulators settle slower than a local run
@@ -746,6 +753,10 @@ jobs:
# Independent invocations: one platform failing skips it, not the other.
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
# tvOS shoots only the scenes that exist there — the 0609 gamepad-console scenes are
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
SCENES="01-stream 02-hosts 05-settings 03-pair" \
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
+1 -1
View File
@@ -14,7 +14,7 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1
50 commits since v0.28.0.
60 commits since v0.28.0.
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
+15
View File
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
# themselves, while Debian splits it into its own -dev package.
libx11-xcb-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
@@ -66,3 +71,13 @@ RUN set -eux; \
pkg-config --atleast-version=1.23.1 wayland-server \
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
echo "wayland-server $have — OK"
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
RUN set -eux; \
pkg-config --exists x11-xcb \
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
@@ -0,0 +1,55 @@
package io.unom.punktfunk.screenshots
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px the Play Store's 16:9 TV screenshot size,
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
* prefixed `tv-` so the artifact separates the form factors.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
class TvScreenshotTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private val out = "build/outputs/roborazzi"
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.mainClock.advanceTimeBy(800)
compose.onRoot().captureRoboImage("$out/tv-$name.png")
}
@Test
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
fun streamDetailed() =
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
}
+13 -1
View File
@@ -17,7 +17,19 @@ parse_deps = false
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
#
# `SOFT_LIMIT_KNEE` is host-side CAPTURE processing (the operator gain's soft knee, applied before
# the encoder). No C embedder can act on it — they receive already-gained audio — so exporting it
# would add a bare `#define` to the ABI surface, against R21 below, for a constant with no meaning
# on that side of the boundary. Excluded rather than renamed: the header stays byte-identical.
exclude = [
"MsghdrX",
"recvmsg_x",
"mmsghdr",
"sendmmsg",
"recvmmsg",
"SOFT_LIMIT_KNEE",
]
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
+135
View File
@@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
ring.drain(..drop);
}
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ 3.1 dBFS). Below this the
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
/// it.
pub const SOFT_LIMIT_KNEE: f32 = 0.7;
/// Multiply `samples` by `gain`, bending anything that would overshoot full scale into a soft knee
/// instead of slicing it flat.
///
/// **Why this is not a `clamp`.** The GameStream plane's gain was `(s * gain).clamp(-1.0, 1.0)`,
/// which is a hard clip: the waveform's peaks are replaced by literal flat tops, and a flat top is
/// a discontinuity in the first derivative. That radiates high-order harmonics — the harsher and
/// more aliasing-prone the higher they go — which is why a field report of "+18 dB and everything
/// warbles" is the expected outcome of that code and not a bug in anything downstream. Any operator
/// who set `PUNKTFUNK_AUDIO_GAIN` much above ~1.5 was hearing this.
///
/// The curve here is `tanh`-based and chosen for three properties, in this order:
///
/// 1. **C¹-continuous at the knee.** The shaped branch's slope at `m == KNEE` is
/// `(1-K) · sech²(0) · 1/(1-K) == 1`, exactly the slope of the linear branch it meets. There is
/// no corner in the transfer curve, so the onset of limiting is not itself an audible event —
/// the failure mode of a naïve piecewise limiter, which trades one discontinuity for another.
/// 2. **Bounded by construction.** `tanh` is asymptotic to 1, so the output approaches but never
/// exceeds full scale for any finite input, and `±inf` maps to `±1.0`. No sample can leave here
/// out of range, which is what the encoder downstream assumes.
/// 3. **Odd-symmetric.** `f(-x) == -f(x)`, so the distortion it does introduce is odd-harmonic and
/// adds no DC offset — the benign, "saturating" flavour rather than the rectifying one.
///
/// Callers gate on `gain != 1.0`, so the default path is untouched and the wire stays byte-for-byte
/// identical to a build without this. Note this is a WAVESHAPER, not a lookahead limiter: it is
/// memoryless and therefore costs zero latency, which is the trade that makes it acceptable in the
/// realtime encode path. It raises headroom; it does not raise *loudness* the way a compressor
/// with a real time constant would, and it should not be sold as one.
pub fn apply_gain(samples: &mut [f32], gain: f32) {
// Unity is a no-op, not "multiply by one and shape": the shaper is only correct to apply to a
// signal somebody asked to boost. Without this, calling at unity would bend every peak above
// the knee — a silent quality change for anyone who forgot to gate the call, and the reason
// the callers' `gain != 1.0` guards are a convenience rather than a load-bearing contract.
if gain == 1.0 {
return;
}
for s in samples {
*s = soft_limit(*s * gain);
}
}
/// The waveshaper behind [`apply_gain`]: identity below [`SOFT_LIMIT_KNEE`], asymptotic to ±1.0
/// above it. Exposed so the clients can mirror the curve if they ever grow a gain of their own.
pub fn soft_limit(x: f32) -> f32 {
let m = x.abs();
if m <= SOFT_LIMIT_KNEE {
return x;
}
let head = 1.0 - SOFT_LIMIT_KNEE;
let shaped = SOFT_LIMIT_KNEE + head * ((m - SOFT_LIMIT_KNEE) / head).tanh();
if x < 0.0 {
-shaped
} else {
shaped
}
}
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
@@ -2432,4 +2494,77 @@ mod tests {
assert!(s.audible_tail <= 4, "{s:?}");
assert!(s.audible <= 12, "{s:?}");
}
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
/// holding, every default session's wire would shift and the "byte-for-byte identical" claim
/// the tier machinery rests on would quietly become false.
#[test]
fn unity_gain_is_bit_exact() {
let src: Vec<f32> = (0..512).map(|i| (i as f32 / 512.0) * 2.0 - 1.0).collect();
let mut got = src.clone();
apply_gain(&mut got, 1.0);
assert_eq!(got, src, "unity gain must not touch a single sample");
}
/// Below the knee the limiter is not in circuit at all: a boost whose peaks stay under
/// `SOFT_LIMIT_KNEE` must be plain multiplication, or quiet material pays for a limiter it
/// never needed.
#[test]
fn below_the_knee_is_plain_multiplication() {
let mut got = vec![0.0, 0.1, -0.2, 0.34, -0.05];
apply_gain(&mut got, 2.0);
for (i, (g, s)) in got.iter().zip([0.0f32, 0.1, -0.2, 0.34, -0.05]).enumerate() {
assert_eq!(*g, s * 2.0, "sample {i} must be untouched below the knee");
}
}
/// The property the hard `clamp` violated and this exists to restore: no input, however
/// absurdly gained, may leave the shaper out of range — and non-finite input must not escape
/// as something the encoder would choke on.
#[test]
fn nothing_escapes_full_scale() {
for gain in [1.5f32, 4.0, 8.0, 64.0, 1000.0] {
let mut got: Vec<f32> = (0..401).map(|i| (i as f32 - 200.0) / 200.0).collect();
apply_gain(&mut got, gain);
for s in &got {
assert!(s.abs() <= 1.0, "gain {gain} produced {s}");
}
}
assert_eq!(soft_limit(f32::INFINITY), 1.0);
assert_eq!(soft_limit(f32::NEG_INFINITY), -1.0);
}
/// Monotonic and odd-symmetric. Monotonicity is what keeps the shaper a limiter rather than a
/// fold-back distortion; odd symmetry is what keeps its harmonics benign and its DC at zero.
#[test]
fn the_curve_is_monotonic_and_odd() {
let mut prev = f32::NEG_INFINITY;
for i in 0..=4000 {
let x = (i as f32 - 2000.0) / 500.0; // -4.0 ..= 4.0
let y = soft_limit(x);
assert!(y >= prev, "not monotonic at {x}: {y} < {prev}");
prev = y;
assert!(
(soft_limit(-x) + y).abs() < 1e-6,
"not odd-symmetric at {x}"
);
}
}
/// The knee must not itself be an audible event. Both branches meet at the same value AND the
/// same slope, so the transfer curve has no corner — a piecewise limiter that gets this wrong
/// just swaps the clip's discontinuity for a softer one.
#[test]
fn the_knee_has_no_corner() {
let k = SOFT_LIMIT_KNEE;
assert!((soft_limit(k) - k).abs() < 1e-6, "value jumps at the knee");
let h = 1e-4;
let below = (soft_limit(k) - soft_limit(k - h)) / h;
let above = (soft_limit(k + h) - soft_limit(k)) / h;
assert!((below - 1.0).abs() < 1e-2, "linear side slope {below}");
assert!(
(above - below).abs() < 1e-2,
"slope jumps at the knee: {below} -> {above}"
);
}
}
+48
View File
@@ -13,6 +13,54 @@ pub const SAMPLE_RATE: u32 = 48_000;
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
pub const CHANNELS: usize = 2;
/// Highest boost `PUNKTFUNK_AUDIO_GAIN` will honour (+18 dB). Past this the soft knee is doing
/// essentially all the work and the result is a squashed signal, not a louder one — so a runaway
/// value (a stray `180` for `1.8`) is capped and said out loud rather than silently shipped.
const MAX_CAPTURE_GAIN: f32 = 8.0;
/// The operator's capture gain, shared by BOTH audio planes (`PUNKTFUNK_AUDIO_GAIN`, default
/// `1.0` = untouched).
///
/// **Why the host needs one at all.** WASAPI loopback is tapped UPSTREAM of the endpoint's master
/// volume, so turning the host's speaker slider up does nothing whatsoever to the level a client
/// receives. Before this, the native `punktfunk/1` plane had no gain of any kind, which left no
/// host-side way to raise a quiet desktop mix — the GameStream plane's knob was the only one, and
/// it applied to the wrong protocol.
///
/// Applied through [`punktfunk_core::audio::apply_gain`], whose soft knee replaces the hard
/// `clamp(-1.0, 1.0)` this used to be. That clamp is why boosting was a trap: it flat-tops peaks,
/// and flat tops are audible as harsh distortion long before the operator reaches the level they
/// were chasing.
///
/// ⚠ This is headroom, not loudness. It cannot close a peak-to-loudness gap against
/// already-limited broadcast content — that needs a real compressor with a time constant, which is
/// deliberately NOT what this is.
pub fn capture_gain() -> f32 {
let raw: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
// A negative or non-finite gain is a typo, never an intent: it would invert or poison every
// sample. Fall back to unity rather than shipping it.
if !raw.is_finite() || raw <= 0.0 {
if std::env::var("PUNKTFUNK_AUDIO_GAIN").is_ok() {
tracing::warn!(
"PUNKTFUNK_AUDIO_GAIN must be a positive number (1.0 = unchanged) — ignoring"
);
}
return 1.0;
}
if raw > MAX_CAPTURE_GAIN {
tracing::warn!(
requested = raw,
capped = MAX_CAPTURE_GAIN,
"PUNKTFUNK_AUDIO_GAIN is above the +18 dB ceiling — capping"
);
return MAX_CAPTURE_GAIN;
}
raw
}
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
/// with. Lives on its own thread; never blocks the capture loop (drops if the consumer
/// falls behind).
@@ -397,11 +397,9 @@ fn audio_body(
// stays small.
let start = Instant::now();
let mut frame_no: u64 = 0;
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
// Optional gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0). Soft-limited
// rather than clamped — see `crate::audio::capture_gain`.
let gain = crate::audio::capture_gain();
tracing::info!(
channels = layout.channels,
streams = layout.streams,
@@ -418,9 +416,7 @@ fn audio_body(
while acc.len() >= frame_len {
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
if gain != 1.0 {
for s in &mut frame {
*s = (*s * gain).clamp(-1.0, 1.0);
}
punktfunk_core::audio::apply_gain(&mut frame, gain);
}
let n = enc.encode_float(&frame, &mut out)?;
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
+79 -5
View File
@@ -442,6 +442,35 @@ pub fn validate_store_claim(store: &str) -> Result<(), String> {
}
}
/// Drop every `launcher_ui` entry naming a launcher this host cannot actually open, returning the
/// `(title, value)` pairs removed.
///
/// The launch-side counterpart to [`sanitize_art_paths`], and it exists for the same reason: a
/// plugin reconciles its **whole** entry set at once, so anything that fails the payload costs the
/// operator every game in it. The Playnite plugin appends one launcher tile beside the games, so a
/// host that could not resolve `Playnite.FullscreenApp.exe` refused the lot — the operator saw an
/// empty grid and a `HostRequestError` naming `entries[9]`, with nothing to say the other entries
/// were fine.
///
/// Only the *unresolvable* case is dropped. A value outside the platform's vocabulary is still a
/// hard 400 in [`validate_provider_payload`]: that one is a bug in the plugin, and silently
/// swallowing it would leave the author with a tile that never appears and no reason why.
///
/// Dropping the whole entry rather than clearing its `launch` is deliberate — a launcher tile with
/// no launch is a dead tile, which is strictly worse than no tile.
pub fn sanitize_launcher_entries(inputs: &mut Vec<ProviderEntryInput>) -> Vec<(String, String)> {
let mut dropped = Vec::new();
inputs.retain(|e| {
let Some(launch) = &e.launch else { return true };
if launch.kind != "launcher_ui" || resolvable_launcher_ui(&launch.value) {
return true;
}
dropped.push((e.title.clone(), launch.value.clone()));
false
});
dropped
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
@@ -467,12 +496,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
// Only the VOCABULARY is refused here. Whether the launcher is actually installed on
// this box is not the payload's fault, and 400ing over it threw away every game in the
// reconcile — see `sanitize_launcher_entries`, which drops just the tile instead.
if launch.kind == "launcher_ui" && !known_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
"entries[{i}]: `launch.value` for kind `launcher_ui` is not a launcher this \
host's platform supports (`{}`)",
launch.value
));
}
@@ -1065,6 +1095,14 @@ mod tests {
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
// `launcher_ui` is checked for VOCABULARY only. A launcher that is merely not installed
// must pass here and be dropped later — see `an_unopenable_launcher_tile_costs_only_itself`.
assert!(validate_provider_payload(&[with_launch("launcher_ui", "nonesuch")]).is_err());
#[cfg(windows)]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "playnite")]).is_ok());
#[cfg(target_os = "linux")]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "lutris")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
@@ -1129,4 +1167,40 @@ mod tests {
"duplicate external_id"
);
}
/// The regression `sanitize_launcher_entries` exists for: a launcher tile this host cannot open
/// must cost that tile, not the games reconciled beside it.
///
/// Field shape — the Playnite plugin appends exactly one `launcher_ui` tile after its games, so
/// `entries[N]` failing validation used to refuse the entire payload and leave the operator with
/// an empty grid and a `HostRequestError` that named only the index.
#[test]
fn an_unopenable_launcher_tile_costs_only_itself() {
let mut tile = input("launcher", "Playnite");
tile.role = GameRole::Launcher;
tile.launch = Some(LaunchSpec {
kind: "launcher_ui".into(),
value: "playnite".into(),
});
let mut inputs = vec![input("a", "A"), tile, input("b", "B")];
let dropped = sanitize_launcher_entries(&mut inputs);
if resolvable_launcher_ui("playnite") {
// A Windows box with Playnite actually installed keeps all three.
assert!(dropped.is_empty());
assert_eq!(inputs.len(), 3);
} else {
// Everywhere else the tile goes and both games survive — the whole point of the split.
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].1, "playnite");
assert_eq!(inputs.len(), 2);
assert!(inputs.iter().all(|e| e.external_id != "launcher"));
}
// A payload of nothing but games is untouched on every OS.
let mut only_games = vec![input("a", "A"), input("b", "B")];
assert!(sanitize_launcher_entries(&mut only_games).is_empty());
assert_eq!(only_games.len(), 2);
}
}
+170 -42
View File
@@ -478,13 +478,31 @@ fn launcher_ui_stores() -> &'static [&'static str] {
}
}
/// Is this a `launcher_ui` value this host can resolve?
/// Is `value` a launcher this host's platform knows about at all?
///
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
/// publishing a tile that does nothing when a user clicks it.
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
if !launcher_ui_stores().contains(&value) {
/// The *vocabulary* half of the old `valid_launcher_ui`. A value outside this set is a plugin
/// author's mistake — a typo, or a launcher this OS has no support for — and no amount of
/// installing things on the box will make it resolve, so the reconcile refuses the payload.
pub(crate) fn known_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Can this host open `value`'s launcher **right now**?
///
/// The *environment* half. Deliberately separate from [`known_launcher_ui`], because the two
/// failures are not the same kind of thing and must not get the same answer:
///
/// - an unknown value is a bug in the plugin, and a 400 is the only way its author finds out;
/// - a known value that will not resolve means the launcher simply is not installed here, which is
/// an ordinary fact about the box, not a defect in the payload.
///
/// Conflating them cost a real library: the Playnite plugin publishes one launcher tile alongside
/// every game, so a host that could not resolve Playnite 400'd the whole reconcile and the operator
/// got **no games at all** — the same shape as the unservable-cover bug that
/// [`super::sanitize_art_paths`] was introduced to fix. The tile is dropped now (see
/// [`super::sanitize_launcher_entries`]) and the games sync.
pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
if !known_launcher_ui(value) {
return false;
}
#[cfg(windows)]
@@ -502,36 +520,141 @@ pub(crate) fn valid_launcher_ui(value: &str) -> bool {
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
/// literal `"playnite"`.
///
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
/// `None` when nothing resolves, which is what drops the tile.
#[cfg(windows)]
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
const EXE: &str = "Playnite.FullscreenApp.exe";
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
playnite_install_dirs()
.into_iter()
.find_map(|root| {
RegKey::predef(root)
.open_subkey(KEY)
.ok()?
.get_value::<String, _>("InstallLocation")
.ok()
})
.map(std::path::PathBuf::from);
from_registry
.into_iter()
.chain(
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
)
.map(|dir| dir.join(EXE))
.find(|p| p.is_file())
}
/// Windows: every directory that might hold a Playnite install, best candidates first.
///
/// **Playnite installs per-user by default, and this host is a LocalSystem service** — which
/// invalidates all three of the obvious lookups, and is why this is not a two-liner:
///
/// - `HKEY_CURRENT_USER` is *SYSTEM's own* hive (`S-1-5-18`), never the person's, so a per-user
/// install is invisible there. Every **loaded** hive under `HKEY_USERS` is read instead: only
/// logged-on users' hives are loaded, which is exactly the set that can be streaming, and it
/// avoids a `WTSQueryUserToken` dance for what is a best-effort probe. Same trade-off
/// [`crate::procscan::steam_running_hint`] makes, for the same reason.
/// - The uninstall subkey is matched by its **`DisplayName`**, not by key name. Playnite ships an
/// Inno Setup installer and Inno registers `<AppId>_is1` — measured on a Windows box where Git
/// and Inno itself appear as `Git_is1` and `Inno Setup 6_is1`. The hardcoded
/// `…\Uninstall\Playnite` this replaced matched nothing on any box.
/// - `%LOCALAPPDATA%` for a SYSTEM service is `C:\Windows\System32\config\systemprofile\AppData\
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
///
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
/// a stale entry costs one `is_file` and nothing else.
#[cfg(windows)]
fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
use winreg::enums::{HKEY_LOCAL_MACHINE, HKEY_USERS, KEY_READ};
use winreg::RegKey;
// 64-bit and 32-bit views. HKCU/HKU `Software` is not redirected (only `Software\Classes` is),
// so the WOW view is a machine-hive concern only.
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
let users = RegKey::predef(HKEY_USERS);
for sid in users.enum_keys().flatten() {
// The `…_Classes` companion hives carry file associations, never uninstall entries.
if sid.ends_with("_Classes") {
continue;
}
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
}
}
// The conventional per-user location, for every profile on the box — this is where Playnite's
// own default install lands, and it covers a user whose hive is not currently loaded.
for profile in windows_user_profiles() {
push_unique(&mut dirs, profile.join(r"AppData\Local\Playnite"));
}
dirs
}
/// Collect `InstallLocation` from every Playnite-looking uninstall entry under `root\path`.
///
/// Matched on `DisplayName` because the key name is the installer's `AppId` (see
/// [`playnite_install_dirs`]). `starts_with` rather than equality so a versioned or suffixed display
/// name still counts; the value is only ever used as a directory to probe for the exe, so a false
/// positive costs one failed `is_file`.
#[cfg(windows)]
fn playnite_dirs_from_uninstall(
root: &winreg::RegKey,
path: &str,
out: &mut Vec<std::path::PathBuf>,
) {
use winreg::enums::KEY_READ;
let Ok(uninstall) = root.open_subkey_with_flags(path, KEY_READ) else {
return;
};
for name in uninstall.enum_keys().flatten() {
let Ok(entry) = uninstall.open_subkey_with_flags(&name, KEY_READ) else {
continue;
};
let display: String = entry.get_value("DisplayName").unwrap_or_default();
if !display.starts_with("Playnite") {
continue;
}
if let Ok(location) = entry.get_value::<String, _>("InstallLocation") {
let location = location.trim();
if !location.is_empty() {
push_unique(out, std::path::PathBuf::from(location));
}
}
}
}
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
///
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
/// [`super::art::art_roots`] uses — with `%SystemDrive%\Users` as the fallback when the variable is
/// missing from a service's environment.
#[cfg(windows)]
fn windows_user_profiles() -> Vec<std::path::PathBuf> {
let base = std::env::var_os("PUBLIC")
.map(std::path::PathBuf::from)
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
.or_else(|| {
std::env::var_os("SystemDrive").map(|d| std::path::PathBuf::from(d).join("Users"))
});
let Some(base) = base else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&base) else {
return Vec::new();
};
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.ends_with("Public"))
.collect()
}
/// Push `path` unless an equal one is already there — the candidate lists are a handful of entries,
/// so a linear check beats carrying a set around.
#[cfg(windows)]
fn push_unique(out: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if !out.contains(&path) {
out.push(path);
}
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
@@ -800,33 +923,38 @@ mod tests {
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
assert!(known_launcher_ui("heroic"));
assert!(known_launcher_ui("lutris"));
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
// becoming a tile that does nothing.
assert!(!known_launcher_ui("gog"));
}
#[cfg(windows)]
{
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
// validation is resolution, so a box without Playnite refuses the entry rather than
// publishing a tile that does nothing when clicked.
// Playnite is in the vocabulary unconditionally — whether this particular box has it
// installed is a separate question, answered by `resolvable_launcher_ui` below. Keeping
// them separate is the fix for the reconcile that 400'd a whole library over one tile.
assert!(known_launcher_ui("playnite"));
assert_eq!(
valid_launcher_ui("playnite"),
resolvable_launcher_ui("playnite"),
playnite_fullscreen_exe().is_some()
);
// The Linux launchers, and the Windows ones whose activation is still unverified
// (Epic, GOG Galaxy, the Xbox app), stay refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
#[cfg(not(any(target_os = "linux", windows)))]
{
// No launcher UIs are wired on this OS, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
// Junk is outside the vocabulary on every OS, so it never reaches a resolver.
assert!(!known_launcher_ui(""));
assert!(!known_launcher_ui("lutris; rm -rf ~"));
assert!(!resolvable_launcher_ui(""));
assert!(!resolvable_launcher_ui("lutris; rm -rf ~"));
}
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
+12
View File
@@ -524,6 +524,18 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// A launcher this box cannot open is a fact about the box, not a defect in the payload, so it
// costs its own tile and nothing else. Before this, the Playnite plugin's single launcher entry
// 400'd every game it shipped alongside.
for (title, value) in crate::library::sanitize_launcher_entries(&mut inputs) {
tracing::warn!(
provider,
launcher = %value,
title = %title,
"library reconcile: dropped a launcher tile this host cannot open — the rest of the \
payload still syncs. Install the launcher, or turn the tile off in the plugin's config"
);
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
+18 -1
View File
@@ -142,6 +142,20 @@ pub(super) fn audio_thread(
};
let frame_len = SAMPLES_PER_FRAME * want as usize;
// Operator capture gain, soft-limited (`PUNKTFUNK_AUDIO_GAIN`, default 1.0 = untouched). This
// plane had NO gain at all until now, so `PUNKTFUNK_AUDIO_GAIN` silently did nothing on
// punktfunk/1 while working on GameStream — and since WASAPI loopback taps upstream of the
// endpoint's master volume, there was no other host-side way to lift a quiet desktop mix.
// Read once per session rather than per frame: this is an operator setting, not a live control.
let gain = crate::audio::capture_gain();
if gain != 1.0 {
tracing::info!(
gain,
"audio: applying operator capture gain (soft-limited above \
{}; headroom, not loudness)",
punktfunk_core::audio::SOFT_LIMIT_KNEE
);
}
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
let mut opus_buf = vec![0u8; 4096];
@@ -253,7 +267,10 @@ pub(super) fn audio_thread(
}
pace_due = Some(pace_due.unwrap_or_else(std::time::Instant::now) + FRAME_INTERVAL);
let frame: Vec<f32> = acc.drain(..frame_len).collect();
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
if gain != 1.0 {
punktfunk_core::audio::apply_gain(&mut frame, gain);
}
let pts_ns = next_pts_ns;
next_pts_ns += FRAME_MS as u64 * 1_000_000;
match enc.encode_float(&frame, &mut opus_buf) {
+1 -1
View File
@@ -156,7 +156,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_AUDIO_QUALITY` | `low` · `standard` · `high` *(default `high`)* | Desktop-audio encode quality. `high` (stereo 256 kbps Opus, effectively transparent) costs about 1 % of a normal video bitrate, so there's rarely a reason to go lower. `standard` is exactly the pre-0.25 encoder (stereo 128 kbps) — handy for an A/B comparison; `low` is for genuinely constrained links (noticeably lossy on music, still fine for game audio and voice). A typo warns in the log and keeps `high` rather than silently downgrading. Host-side only — clients play whatever arrives, no client setting involved. |
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | **(Moonlight/GameStream sessions only)** Linear gain applied to captured desktop audio — bump it for a quiet source. The native `punktfunk/1` path ignores it; adjust the source's own volume there instead. |
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | Gain applied to captured desktop audio — bump it for a quiet source. Applies to **both** the native `punktfunk/1` and Moonlight/GameStream paths. Peaks are rounded off by a soft limiter rather than clipped, so a boost distorts gracefully instead of abruptly; values above `8.0` (+18 dB) are capped, and a non-positive value is ignored. Note this buys **headroom, not loudness** — it cannot make a desktop mix as loud as already-limited streaming-app audio, and pushing it hard to try will audibly squash the signal. On Windows this is the only host-side control that works at all: loopback capture is tapped upstream of the endpoint's master volume, so the speaker slider does not affect what a client receives. |
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
+10 -11
View File
@@ -1,20 +1,18 @@
Wire-compatible with 0.28.0 — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and most of it is about things that were wrong in ways nothing announced. On Windows, your Steam library could stay completely empty after the last update, and a Punktfunk host sitting idle — nothing connected, nothing streaming — could wreck the frame rate of a game you were playing on that same machine. An Android TV box could be silent for a whole session; a Steam Deck's Punktfunk home would not open; games could not reach high dynamic range (HDR) at all on Linux machines running our own compositor, while the Steam interface in front of them glared; and an Apple TV was being pushed into HDR mode by streams that were not HDR at all, lifting blacks to grey.
This is a fix release, and most of it is about things that were wrong in ways nothing announced. On Windows, your Steam library could stay completely empty after the last update, and a Punktfunk host sitting idle — nothing connected, nothing streaming — could wreck the frame rate of a game you were playing on that same machine. Streaming from a Mac with the microphone on cut the sound and froze the controls every couple of seconds. An Android TV box could be silent for a whole session; a Steam Deck's Punktfunk home would not open; games could not reach high dynamic range (HDR) at all on Linux machines running our own compositor, while the Steam interface in front of them glared; and an Apple TV was being pushed into HDR mode by streams that were not HDR at all, lifting blacks to grey.
Several of these came from people who reported them carefully enough to be found. One of them, the crash that lost your game on reconnect, was tracked down, fixed and proven on his own hardware by **luxus** — that fix is in this release as he wrote it.
## TL;DR
- **On Windows, your Steam games went missing after updating to 0.28.0.** The Steam add-on could publish nothing, because Steam keeps its artwork in Program Files and the host was not allowed to read it. Fixed — and one unreadable cover no longer throws away the whole library with it.
- **An idle Punktfunk host could ruin a game you were playing on that same machine**, down to 25 frames per second at worst, with nothing connected and no stream running. Two separate causes, both fixed, both reported from Helldivers 2.
- **No sound at all on some Android TV boxes** — an NVIDIA Shield in particular — is fixed, and the app now recovers when your TV or receiver changes mode mid-stream instead of staying silent for the rest of the session.
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** That is fixed, along with the missing statistics overlay in streams that came from the same fault.
- **Games can finally use high dynamic range on Linux hosts running Punktfunk's compositor.** They could not before, on essentially any machine, and nothing said so.
- **HDR streams looked washed out while the Steam interface looked glaring** — one brightness mistake causing both, now corrected.
- **On an Apple TV, ordinary streams were switching the television into HDR** and lifting blacks to grey — and those same streams were being drawn with no colour information at all.
- **If your Apple device's statistics overlay ever showed impossibly good numbers, that is why** — three separate faults made those figures unreliable. Old measurements are worth re-taking.
- **Streaming your desktop with the mouse pointer forwarded failed outright on Hyprland and Sway**, giving you a black picture. It affected every current install, not only old ones.
- **On Windows, your Steam games went missing after 0.28.0** — the add-on could publish nothing, because Steam keeps its artwork somewhere the host was not allowed to read.
- **An idle host could ruin a game running on that same Windows machine**, down to 25 frames per second, with nothing connected and no stream running.
- **Streaming from a Mac with your microphone on cut the sound and froze your controls every couple of seconds** — and turning the mic off was the only way round it.
- **No sound at all on some Android TV boxes**, an NVIDIA Shield in particular — and sound now returns by itself when your TV changes mode mid-stream.
- **The Punktfunk home would not open on a Steam Deck after 0.28.0**, and streams elsewhere lost their statistics overlay to the same fault.
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway** — every current install, not just old ones.
- **High dynamic range was broken in both directions on Linux hosts:** games could not reach it at all, and everything else was mapped a stop too bright. Both fixed.
## Before you update
@@ -38,6 +36,7 @@ Several of these came from people who reported them carefully enough to be found
- **On Windows, the Steam add-on synced nothing and your grid stayed empty.** Steam installs to Program Files and keeps its artwork there, and the host's list of places it is allowed to read art from covered the launchers that install per-user — but not Steam. Every cover the add-on offered was rejected, and because one bad cover failed the whole submission, that meant every Steam game rather than one thumbnail. Both halves are fixed: Steam's own install locations are now allowed (including on a second drive), and a cover that cannot be served is dropped so the rest of your library still arrives. This only affected Windows — every Steam layout on Linux was already covered — and it arrived with 0.28.0, when the built-in Steam scanner was replaced by the add-on.
- **A Punktfunk host that was doing nothing at all could wreck a game running on the same Windows machine** — 25 frames per second at worst, with no client connected and no stream ever started. Two independent causes, both from the same set of reports: the host presents a controller speaker that looks exactly like a real DualSense's, and a game that finds it engages its DualSense audio path against a device nothing is feeding; and the host was claiming your default microphone permanently, so any game holding an open voice input stalled on a virtual mic that only runs during a stream. The controller speaker is now hidden except while a controller is actually attached, and your microphone is only borrowed for the length of a stream. Machines already stuck repair themselves on the next start.
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway.** Punktfunk asked the desktop's screen-sharing service for the pointer in a form neither compositor offers, and the request was refused before it ever reached them — so the whole capture failed rather than losing just the pointer. It now asks what is actually available and takes the best on offer. Measured against fully up-to-date Hyprland and portal versions: this was every current install, and updating them would not have helped. You will now see a single pointer drawn by the host instead of your client drawing its own.
- **Streaming from a Mac with the microphone on cut the audio and froze the controls on a two-and-a-half second beat**, while the picture carried on untouched. Some microphones — a multi-channel audio interface set as your Mac's input, for instance — cannot run the echo-cancelling mode Punktfunk asks for. Each failed attempt knocked out the working audio path, and the knock-out was itself the signal that triggered the next attempt, so it went round forever. Your controls froze on the same beat because that rebuild happened on the part of the app that also sends your keyboard and mouse. All three links in that loop are broken now, and audio setup no longer happens where your input lives — so even a genuine change of sound device no longer costs you control of the stream. Until now the only workaround was turning the client's microphone off.
- **An Android TV box could have no audio for an entire session.** Reported on an NVIDIA Shield with a host and settings that played fine everywhere else, and video unaffected. The app asked Android for an audio device and then trusted the answer: a device that accepted the request but played nothing was never noticed, a device that refused to start disabled sound for the rest of the session, and — most common on a television — a device that went away during an HDMI mode change was never reopened. It now proves that sound is actually being taken before it accepts a device, and reopens automatically when your TV or receiver changes mode. TV boxes now use the ordinary shared audio path rather than the exclusive low-latency one, which was never buying them the latency it cost.
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** Both the Decky button and the library shortcut failed. The same fault silently removed the statistics and capture overlays inside streams on other machines. It came from a graphics library sizing itself to what the machine's graphics driver supported rather than to what Punktfunk had asked for, which only became a problem once drivers moved ahead of us.
- **Games could not use high dynamic range on Linux hosts running Punktfunk's compositor** — on essentially every machine, not a rare configuration. Reaching HDR requires a small graphics component that has to match the compositor exactly, and we shipped the compositor without it, leaving the host guessing whether the one your distribution provided was compatible. Because we build our own compositor, that guess was almost always wrong in the direction of switching HDR off. We now ship the matching component ourselves, on every Linux packaging channel.
@@ -61,4 +60,4 @@ Most of the rest of this release exists because people took the trouble to repor
Protocol, ABI, driver and embedder detail — including the version table and the (empty) breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.1/CHANGELOG.md).
Nothing breaks: the wire protocol, the C ABI, the virtual-display driver protocol and the plugin contract are all unchanged from v0.28.0. Two things are worth reading if they apply to you: every Linux packaging channel now ships a second gamescope artifact and a package without it fails to build rather than degrading quietly, and Android's `nativeStartAudio` takes one more argument.
Nothing breaks: the wire protocol, the C ABI, the virtual-display driver protocol and the plugin contract are all unchanged from v0.28.0. Three things are worth reading if they apply to you: every Linux packaging channel now ships a second gamescope artifact and a package without it fails to build rather than degrading quietly, Android's `nativeStartAudio` takes one more argument, and `SessionAudio.start()` is now asynchronous on macOS as it already was on iOS and tvOS — playback is live shortly after the call rather than on return.