chore(release): bump workspace version to 0.23.0
audit / bun-audit (plugin-kit) (push) Successful in 30s
audit / bun-audit (sdk) (push) Successful in 31s
audit / bun-audit (web) (push) Successful in 33s
audit / pnpm-audit (push) Successful in 28s
audit / cargo-audit (push) Successful in 1m17s
audit / docs-site-audit (push) Successful in 1m2s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m52s
apple / swift (push) Successful in 1m34s
audit / license-gate (push) Successful in 5m54s
ci / rust-arm64 (push) Successful in 6m26s
android-screenshots / screenshots (push) Successful in 3m2s
android / android (push) Successful in 4m59s
ci / rust (push) Canceled after 18m52s
decky / build-publish (push) Successful in 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 5m57s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
deb / build-publish-host (push) Successful in 4m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 12s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 11s
release / apple (push) Successful in 10m31s
windows-host / winget-source (push) Skipped
sbom / sbom (push) Successful in 19s
linux-client-screenshots / screenshots (push) Successful in 3m59s
arch / build-publish (push) Failing after 44s
windows-host / canary-manifest (push) Successful in 20s
windows-host / package (push) Successful in 15m12s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 34s
apple / screenshots (push) Canceled after 4m13s
web-screenshots / screenshots (push) Successful in 4m56s
flatpak / build-publish (push) Canceled after 11m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m11s

A minor bump: 133 commits since v0.22.3 across 400-odd files. The wire grew two
negotiated abilities (slice-streamed access units, phase-locked capture); the
Android presenter was rebuilt; the microphone path was rebuilt end to end on
every client; the web console moved from polling to the host's event stream;
gamescope HDR is on by default; HDR and 4:4:4 stopped being mutually exclusive
on Windows; and the one-click update apply that missed the 0.22.3 cut ships here.
The canary base is already 0.23 — release.yml derives it as one minor ahead of
the latest stable tag — so this is the version the canary channel has been
publishing against all along.

Lock touched for the 32 workspace members only, via `cargo update --workspace`
rather than a sed: `wasapi` is itself at 0.23.0 and `rustls` at 0.23.41, so the
version space we are moving into is occupied by third-party crates this time.
Diff against origin/main is versions-only, 32 insertions and 32 deletions;
`cargo metadata --locked` resolves; `cargo fmt --all --check` clean in both the
main and the packaging/windows/drivers workspaces.

Notes at docs/releases/v0.23.0.md, per docs/releases/README.md — authored with
the bump so CI's ensure_release seeds the body at tag creation.
This commit is contained in:
2026-08-01 01:01:16 +02:00
parent 3c509d48c9
commit 49bbdcf4ef
3 changed files with 156 additions and 33 deletions
Generated
+32 -32
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.22.3"
version = "0.23.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ash",
@@ -2897,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ash",
@@ -2936,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ash",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"ash",
"bindgen",
@@ -2969,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"libc",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2995,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.22.3"
version = "0.23.0"
[[package]]
name = "pf-inject"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3028,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ash",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"serde",
"serde_json",
@@ -3058,7 +3058,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"base64",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3115,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ash",
@@ -3323,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3334,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"android_logger",
"jni",
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3382,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3402,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3434,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3519,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"anyhow",
"ksni",
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.22.3"
version = "0.23.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -53,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.3"
version = "0.23.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+123
View File
@@ -0,0 +1,123 @@
Wire-compatible with 0.22.x — everything you have already paired keeps working, and you can update one side at a time. Every new ability here is negotiated per device: a phone, TV or host that does not know about one simply streams the way it does today.
This is a big release, and it pulls in four directions. **Latency:** Android phones and tablets get a new frame scheduler that cut glass-to-glass delay by roughly a third on the devices we measured, a host can line its capture up with your device's display instead of drifting against it, and a frame can start decoding before the rest of it has arrived. **Your microphone:** it can be muted mid-stream on every client now, echo cancellation is on by default, and the uplink itself was rebuilt to be about half as slow. **Settings that do what they say:** the Linux app has been quietly streaming at defaults since 0.22.0 — bitrate, resolution, codec, HDR, profiles — and that is fixed, along with HDR and full chroma no longer being mutually exclusive on Windows. **The web console:** it now follows the host live instead of polling it, shows you what just happened, hands a phone a link to connect with, and installs to a home screen. Plus HDR by default on Steam Deck and other gamescope handhelds, a DualSense over USB on Android, and a real **Update now** button.
## New
- **Your microphone has an off switch you can reach mid-stream.** Until now the only way to stop the room being heard was to end the session and turn the microphone off in Settings — a per-app setting for what is really a per-conversation act. Every client can mute now: **Ctrl+Alt+Shift+V** on Windows and Linux, **⌃⌥⇧A** or the HUD button on Mac, iPhone and iPad, and a tap on the pill (or **Select + Y** on a controller) on Android. A "Microphone muted" badge sits over the stream independently of the stats overlay, because "am I muted?" is not a statistic and the overlay is exactly what a player turns off — and on touch, tapping the badge is the way back. Muting is local and instant: the host is never asked and never told. It is per session and deliberately not remembered, so a mute you made three days ago never follows you into a call.
- **Echo cancellation, and a switch for it.** If you play through speakers, the host used to hear its own game audio coming back through your microphone. Every client now runs the microphone through the operating system's echo canceller by default — on Mac and iPhone that meant putting playback and capture on one engine so the voice processor can see both, and on Android it meant opening the microphone in the mode that keeps the phone's hardware canceller switched on. There is an **Echo cancellation** toggle beside the microphone setting on every platform if you want it off. A new [Why do I hear myself](https://punktfunk.unom.io/docs) page walks through the four different ways an echo loop can form and which switch stops each one.
- **Android streams reach the glass sooner.** The app used to hand each decoded frame to the screen the instant it appeared, so every frame inherited whatever jitter the network and decoder had just added, and bursts piled up behind the display. Frames are now scheduled onto the panel's own refresh. Measured end to end on a 120 Hz phone under game load: 43.7 ms down to 26.4 ms; on a second phone against a Linux host, 2224 ms down to 1418 ms. Two ingredients were plain bugs — Android quietly down-rates a game's frame callbacks to 60 Hz and then *reports* 60 Hz as the panel's rate, so the app was pacing a 120 fps session at half speed, and the panel was not being pinned to the stream's refresh at all. Under **Decoding** you can pick **Lowest latency** (the default) or **Smoothness**, matching the iPhone and Apple TV apps.
- **The host can line its capture up with your screen.** A host's capture and your device's display are two independent clocks, so the wait between "captured" and "shown" slowly sweeps across a whole refresh — a large part of residual judder, and something no client can fix alone. Your device can now report the phase of its own display schedule and the host walks its capture timing until the two agree. It engages only when the reports are steady enough to trust, which in practice means a clean or wired link; over noisy Wi-Fi it stays out of the way rather than chasing noise. Android, the Apple apps and the Windows and Linux desktop clients all report it.
- **A frame starts decoding before it has finished arriving.** Video is cut into slices during encoding, and those slices now travel as they are produced instead of waiting for the whole picture, so a decoder can work on the front of a frame while its tail is still on the wire. It is on by default between hosts and clients that both understand it; devices that can only handle whole frames keep receiving exactly what they did before.
- **HDR and full chroma stop being mutually exclusive on Windows.** An HDR desktop used to cost you 4:4:4: the only 10-bit capture format available threw away colour detail, *after* the host had already told the client it was sending full chroma. Both now work together on HDR sessions. Separately, full chroma can now stay on your graphics card's decoder instead of falling back to software, and the stats overlay tells you the truth about what you got — it prints the encoder's actual target next to the measured rate (`19.4 Mb/s · target 20 Mb/s (auto)`) and shows `4:4:4→4:2:0` when the host declined. That missing number is the reason the Linux settings bug below survived four releases: measured throughput alone cannot tell "the encoder is capped at 20" from "my 200 Mb/s allowance met a cheap scene".
- **HDR turns itself on for Steam Deck and other gamescope handhelds.** A 10-bit HDR session used to need a build of gamescope most people did not have and a setting nobody knew to turn on, so capable boxes quietly streamed SDR — an RX 5700 reported "10-bit HEVC unsupported" when the GPU was never the problem. HDR is attempted by default now, and every install route ships the gamescope build that can actually capture it: the Bazzite system extension, the Arch extension, the NixOS module, and a new script that builds it on a Steam Deck's own on-device install. A box still running stock gamescope keeps exactly the 8-bit behaviour it has today.
- **The web console follows the host instead of interrogating it.** It used to poll ten endpoints on timers, so a change could be five seconds stale and two pages could disagree while you looked at them — and the Library page did not poll at all, so a game you installed in Steam never appeared until you reloaded. The console now subscribes to the host's live event stream and refreshes exactly what each event affects. Three things come with it: a **Recent activity** feed on the dashboard, so a client that connected and left while you were on another page leaves a trace; a **Connect a device** card with the host's address and a copyable link that opens an installed client straight onto this host; and an **Automation** page for the start/stop hooks the host has always run and the console never showed. The console also installs to a phone's home screen, and warns you when another Moonlight-compatible server such as Sunshine or Apollo is running on the same machine — the most common reason a host looks fine but no client can reach it.
- **A DualSense or DualShock 4 plugged into an Android phone behaves like a real one.** Over USB the app now talks to the pad directly: rumble, adaptive trigger effects, the lightbar and player LEDs, the gyro and the touchpad, on the DualSense, DualSense Edge and DualShock 4. Android offers no way to do any of this over Bluetooth, so USB is the route — plug the pad in and the app asks for permission the moment it appears rather than hiding the switch in Settings.
- **Updating the host is one click.** The Updates card grows an **Update now** button. On Windows it asks for your console password again (a remembered login should not be able to restart your PC), downloads the installer, checks it against the signed release manifest *and* its code signature, and runs it silently — the service restarts and the page reconnects by itself. If a freshly installed version ever crash-loops, the host puts the previous installer back on its own and tells you. On Linux the button is opt-in: `sudo usermod -aG punktfunk-update $USER` is the entire grant, and all it can ever do is run your own package manager's normal update for the Punktfunk packages from repositories you already trust. Fedora Silverblue-style installs stage the update and ask you to reboot; Arch additionally needs you to accept a full `pacman -Syu`, because a partial upgrade is how Arch boxes break. A Steam Deck on-device install gets the button with no opt-in at all. `PUNKTFUNK_UPDATE_APPLY=0` removes it entirely. Details on the [Updating the Host](https://punktfunk.unom.io/docs/updating) page.
- **The Linux app can tell you it is out of date, and update itself.** `punktfunk-client --check-update` verifies the same signed release information the host does and reports what can be done on *your* kind of install; `--apply-update` does it. Where nothing on the box can install an update — a system extension, a Nix profile, a source build — it says so and gives you the command rather than a button that could only fail, and a check that *failed* reads as a failure rather than "up to date". The Steam Deck plugin now follows the client you actually launch, instead of offering updates only for the Flatpak and leaving every other kind silently stale.
- **A dead tray icon on Windows is one command away.** `punktfunk-host tray start`, `stop` and `status` — previously the icon only ever appeared at sign-in, so anything that killed it left you without one until you signed out and back in.
- **"Capture system shortcuts" finally decides where Alt+Tab goes.** The switch has been in settings for a while and nothing read it: Windows always grabbed the keyboard whether you asked or not, Linux never grabbed at all. Both honour it now, on Wayland and X11 as well. It is on by default, so **on Linux the stream now keeps chords like Alt+Tab and Super by default** — release input capture, or turn the switch off, to hand them back.
- **Host cards show which system a machine actually runs.** Bazzite, CachyOS and Nobara used to collapse onto their parent distribution's logo and Windows machines wore the Windows 8 flag; all have their own mark now. On Android every non-square logo was being stretched sideways — fixed too.
- **An Apple Pencil near the screen keeps its full sampling rate.** Drawing over a 60 fps stream on a 120 Hz iPad silently halved how often the Pencil was sampled, which is exactly the workload that feels it. The panel is lifted to its top rate whenever the Pencil is in range and drops back afterwards.
## Improved
- **The microphone is about half as slow, and heals itself.** The uplink used to send 20 ms stereo packets built out of buffers up to 42 ms long, and duplicated a mono voice into two channels for no reason. Every client now sends 10 ms mono packets with forward error correction, so a lost packet's audio is reconstructed from the next one instead of leaving a hole. The queues behind it were worse than the format: on some paths a single stall could park over a second of standing delay that never drained, and now a backlog is dropped rather than played out late. On the host side the microphone gets a proper jitter buffer that sizes itself to the client it actually has, between 10 and 60 ms, instead of the fixed worst-case buffer everyone used to pay for. The stats overlay's Detailed tier shows whether your microphone frames are arriving.
- **Windows hosts encode with less delay by default.** Reading each slice out of the encoder while the frame is still being encoded — which Linux hosts have done for a while — now switches itself on wherever the graphics card reports support, instead of needing an environment variable.
- **Android picks AV1 when the silicon prefers it.** Under **Automatic**, a device that hardware-decodes AV1 and cannot use slice-progressive delivery now asks for AV1, measured about 1.2 ms faster end to end than HEVC in an identical A/B. An explicit choice still wins, and the host only honours the preference within what both ends actually support.
- **The picture quality control notices when the decoder is drowning.** If your device keeps asking for a fresh keyframe, that is the decoder saying it cannot keep up — but with nothing lost on the network the bitrate used to hold steady while the picture kept breaking up. Repeated requests now back it off.
- **The console shows numbers it was already being told.** How long a session took to start, what a mid-stream resolution change cost, and packet loss and error-correction recovery *while the capture runs* rather than only in a saved recording. There is also a card that names each plugin which has added games to your library, counts them, and can remove them in one go — previously a plugin's entries were stuck there once it was gone.
- **A controller no longer disappears over a brief hiccup.** A momentary gap in controller state used to tear the virtual pad down and build a new one, which Windows treats as a full unplug and replug. It waits a fraction of a second before believing it now.
- **Windows logs explain a stutter instead of guessing.** When frames stop arriving the host now says whether the *game* went quiet (a menu, a loading screen, an ordinary hitch) or the display path did, rather than blaming the display path by default — and a long degraded stretch is summarised in one line at the end instead of falling silent exactly while you suffer.
- **The documentation caught up with five releases.** Around 280 corrections and nine new pages, including what works on which platform, mouse/touch/pen input and the in-stream chords, client settings, profiles and links, the game library, clipboard, Wake-on-LAN, HDR and uninstalling. The five-minute quickstart could not actually work as written; it can now. Debian is no longer claimed as supported, because it was never tested and its system libraries are too old.
## Fixed
- **The Linux app ignored every setting you chose.** Since 0.22.0 the desktop app handed the stream a set of defaults instead of your settings, so bitrate, resolution, refresh rate, render scale, codec, decoder, HDR, full chroma, audio channels, microphone, touch and mouse mode, scroll direction, the stats overlay and the controller type all did nothing — the report that started this was "my bitrate seems to be stuck at 20 Mbps", which is what the host falls back to when the app sends nothing. Profiles never applied at all. The GPU and audio-device pickers kept working the whole time, which is what made it so confusing. Everything is read again, and connecting from a script or the Steam Deck plugin with `--profile` now actually selects that profile.
- **Full chroma (4:4:4) did nothing on Linux or Windows.** It was announced in 0.22.0 and only the Apple apps ever asked the host for it. Both desktop apps ask now.
- **Settings saves stopped reverting each other.** The settings file has several writers and no merge, so saving from one screen silently undid what another had stored — most visibly the window size the app itself had just remembered. Every whole-file save now re-reads the file first. Windows also gained the speaker and microphone pickers it never had, its decoder list no longer offers a macOS-only option while hiding the actual Windows one, and its controller picker learned Steam Deck.
- **A host that was reinstalled locked your client out forever.** Regenerate a host's identity — a reinstall, a wiped configuration — and the desktop clients refused it permanently with "Host identity rejected", *including* right after a successful re-pair, showing two cards for one address with no way to tell which to forget. Pairing was appending a second record and the lookup kept returning the dead one. Trust decisions now retire the stale record and carry your settings across, so a reinstall no longer costs you what you had configured.
- **The bottom of the picture stopped smearing.** On some 1080p streams the last few rows repeated the final row of pixels and the whole image looked slightly stretched. Video is decoded into a buffer taller than the picture itself — 1088 rows for a 1080-row frame — and the colour conversion was sampling that padding into view, squashing the picture by 0.7%.
- **HDR stopped leaking out of the stream and blowing out the interface.** Connect to an HDR host, disconnect, and the gamepad interface came back overblown with wrong colours until you restarted. The interface is drawn into the same surface the stream used, and nothing ever switched that surface back out of HDR when the stream ended.
- **A Steam Deck streaming to a Windows PC had a stuck stick and a stuck d-pad.** Every virtual controller was presented to Windows as a DualSense regardless of what it was, so a Deck's button data was read as a DualSense's — a stick jammed hard left and a d-pad permanently pressed up. Controllers enumerate as what they are now; DualShock 4 and DualSense Edge pads were affected the same way.
- **Chromecast with Google TV 4K stopped crashing.** Since 0.17.0, streaming to one froze on the first frame and usually crashed and rebooted the device — with Moonlight too, not just Punktfunk. Linux hosts had started cutting every frame into four slices, which that class of TV decoder cannot handle. The host now asks what the client can take.
- **A muted microphone is a pause, not a hole.** On Android the frame counter kept running while muted, so the first frame after unmuting looked to the host like lost audio and it played up to five frames of stale voice trying to cover the gap. Quick mute-unmute toggles were the visible case.
- **A Windows host could feed your own voice back into the stream.** On a machine with VoiceMeeter, the host could pick two strips of the same virtual mixer as the microphone and the loopback — a digital feedback loop with no acoustic path to break. Virtual mixer outputs are excluded from loopback now.
- **Installing a plugin no longer fails on some machines.** If any folder above the plugins directory happened to contain a `package.json` — entirely normal in a home directory — the install landed there instead, and the console reported the plugin as "not present after install" or failed outright. A newly installed plugin also shows up in the sidebar on its own now, in about seven seconds rather than whenever you next reloaded.
- **The console's Logs page came back after a host restart.** It died permanently every time the host restarted — which the console's own update flow does — showing stale lines with no error and no way out short of a full reload. Following also stopped following once the log got busy enough to matter, and pausing did not actually pause.
- **One wrong password stopped locking out the whole console.** The login throttle was documented as per-address and was not: every attempt from anywhere was charged to one shared bucket, so five wrong guesses from any machine on your network locked out everybody, including you, and including the update button. Logging out now genuinely revokes the session too — previously it only deleted the browser's copy of the cookie, and a captured one stayed valid for its full seven days, surviving even a restart.
- **The console's charts stopped lying about time.** Every sample was drawn as an evenly spaced step, so a capture that idled for two minutes rendered that gap as a single notch — in the one view you open specifically to find where the time went. They use a real time axis now, and no longer join two separate sessions into one continuous line.
- **A fresh Linux install could not start the host at all.** `systemctl --user enable --now punktfunk-host` died with "Failed to load environment files", because the service demanded a configuration file that no package creates and only the docs told you to copy. It is optional now — without it you get the defaults, which is what it always should have meant.
- **A Linux host with no configuration file served a test pattern.** Moonlight and other GameStream clients connecting to a packaged install that had never been configured got synthetic test video instead of the screen.
- **A brand-new host stopped looking broken.** The Updates card reported "feed returned HTTP 404" on the stable channel, which is simply what an empty channel answers before anyone has published to it. It says so plainly now instead of showing a failure banner.
- **Streaming from a Steam Deck-style session no longer starts a login storm.** On some images the login manager tried to restart the gaming session about three times a second for the entire stream — over eight minutes one test box logged 1481 login sessions, the journal overflowed, and a 5070 Ti "could not hold 240 fps".
- **The web console on Windows stops going quiet.** It is started and watched by the host service itself now, restarted whenever it exits with a widening delay, and its output finally lands in a log file. Three separate outages in one week were the same missing supervision. A separate defect let a half-finished console deployment report success while every page served an error under a 200 status.
- **Updating no longer kills the tray icon for good**, and the script and plugin runner on Windows can start at all — it could not read its own program file, and the error that said so was thrown away.
- **A cold-start connect on Android no longer loses HDR.** Opening a link from a cold start could reach the connect before the app was attached to a display, and the checks silently fell back to their worst answers: SDR, and 1080p60.
- **Drawing no longer loses parts of a stroke.** A stroke held still for more than a fifth of a second while the app was busy could be dropped by the host's safety timer, and a fast burst of samples was truncated rather than split — losing exactly the geometry that matters most, at exactly the moment the app hitched. Fixed on iPad and Android.
- **Rumble survives a game that hammers it.** A game driving a virtual DualSense's outputs at over 2000 messages a second overflowed a queue and silenced rumble for the whole storm, while filling the log with about 230 warnings a second.
- **A game link on Windows launches its game.** A `punktfunk://` link carrying a game opened a plain desktop session instead — the id was parsed, validated, planned, and dropped at the last step. A link to a host you have not paired with now runs the pairing ceremony instead of refusing outright.
- **Android tablets and foldables stop being forced into landscape**, and the stream handles camera cutouts explicitly so the notch area behaves the same on every Android version.
- **A PC whose only screen is a switched-off TV stops warning on every disconnect**, and **the Apple and Android apps credit their icons again** — the bundled third-party notices had drifted behind the project's own.
## Under the hood (for developers)
- **Versions.** Wire protocol 2, virtual-display driver protocol 6 and the Windows virtual-gamepad channel 3 are unchanged from 0.22.x. **C ABI 13 → 14**: the client surface grew `punktfunk_connection_report_phase` and the `PUNKTFUNK_CLIENT_CAP_PHASE_LOCK` mirror constant. Additive and capability-gated — an embedder that never calls the new function needs no change — and the wire grows only a control message an old host never reads plus a strict-prefix append on the host-timing tail, which is why `WIRE_VERSION` stays 2.
- **Slice-streamed access units.** `USER_FLAG_SLICE_STREAM` marks frames whose FEC blocks are cut at slice boundaries rather than at a fixed byte count, so blocks are variable-size and can no longer be addressed by the uniform `block_index * K_max` formula: sentinel blocks reuse `frame_bytes` as a shard-aligned base offset, the final block's base derives from the totals, and the receiver pins positionally (sentinels must sit strictly below the final block's base; a mixed-flag or out-of-range header is dropped before it can place a byte). The host sets the flag only toward clients advertising `STREAMED_AU` + `VIDEO_CAP_MULTI_SLICE`; `PUNKTFUNK_SLICE_STREAM=0` pins legacy for an A/B.
- **Partial delivery.** `connect`'s `frame_parts` opt-in yields each AU's newly-contiguous prefix as `Frame::part` pieces (offset tiling, first/last marked, the completing push carrying only the suffix). The reassembler walks a per-frame cursor over successfully-completed blocks, coalesces out-of-order completions, keeps probe filler whole and stops short of the final block so the zero-padded tail still trims. Per-AU accounting keeps its units — OWD/ABR feeds, the inter-arrival series and the jump-to-live detector count only completing deliveries, and `FrameChannel::depth()` counts AUs. Never flows without the opt-in, never on PyroWave. Consumer contract: a gap or orphan part means the AU is lost — abandon, flush, resync on the next `first`. Field note: on the NP3 all three `c2.qti` low-latency decoders refuse `FEATURE_PartialFrame`, and forcing it through `debug.punktfunk.force_parts` showed they accept the pieces but only assemble them — codec time unchanged, so the overlap is dead on SM8735 either way.
- **Multi-slice gating and sub-frame readback.** `VIDEO_CAP_MULTI_SLICE` is `0x80`, the video-caps byte's last free bit; the next cap needs a second byte and an ABI bump. GameStream reads `x-nv-video[0].videoEncoderSlicesPerFrame` (absent or out of range ⇒ 1). The Windows direct-NVENC session now seeds `resolve_subframe` from `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` instead of a hard false, so `PUNKTFUNK_NVENC_SUBFRAME` is the same tri-state escape it is on Linux. The submit-time IDR hint gained the Linux twin's `opening` term — NVENC emits the session-opening frame as an IDR regardless of pic flags, which was firing the divergence check at every stream start.
- **Phase-locked capture.** `PhaseReport` (control `0x32`) carries the client's next display latch already converted to host clock, the panel period, an uncertainty and a circular arrival-lead statistic with a coherence permille; the `0xCF` host-timing tail grows a 29-byte `applied_phase_ns` ACK under the existing strict-prefix append discipline. The controller locks submits to an absolute grid (`epoch + k × period + offset`) and walks only the offset, so phase actuation is linear by construction and a saturating plant disengages rather than free-running or parking a hold. Coherence floor 300‰, antipode damping within 1 ms of ±period/2, decay-to-zero on incoherence or travel-budget exhaustion. `PUNKTFUNK_PHASE_LOCK=0` disarms. The desktop clients have the best latch signal of the three — true on-glass stamps via `VK_KHR_present_wait` — and advertise the cap only when present timing is real. `punktfunk_core::phase::circular_latch` is shared by every reporter and by a closed-loop simulation harness that turns both on-glass failures (the median-driven orbit and the parked-hold e2e tax) into CI regressions.
- **Android presenter.** An `AChoreographer` thread publishes the vsync grid and frame timelines; releases go through `releaseOutputBufferAtTime` against the timeline's expected present minus a latch margin that starts at 0 and widens by 500 µs per 1 Hz window only when the paced counter shows real misses, one-way per stream. The panel period is learned downward from observed timeline spacing because `Display.getRefreshRate` reports the frame-rate-category override rather than the panel. `debug.punktfunk.latch_margin_us` (0..=8000) and `debug.punktfunk.presenter=arrival` pin the margin and restore the legacy path without a rebuild. `nativeVideoStats` is now 33 doubles — the decode stage splits into feed (received→queued) and codec (queued→decoded) at 30/31, with a skipped counter at 32 that separates benign newest-wins pacing from parked-AU overflow; 025 remain frozen and the JNI KDoc is the authoritative index list.
- **Microphone path.** Uplink is 10 ms mono Opus at 48 kbps with in-band FEC against 10% assumed loss, on every client; the wire is unchanged (the host decodes any Opus frame ≤ 120 ms and its stereo decoder upmixes mono). `MIC_QUEUE` drops 64 → 12 and sheds oldest-first past a ~60 ms backlog — the old constant claimed 320 ms of 5 ms frames but the frames were 20 ms, so it really allowed 1.28 s of standing delay that only a >600 ms silence gap ever flushed. `MicFrame {seq, pts_ns, opus}` now survives ingest, feeding a real host-side de-jitter (`audio/mic_jitter.rs`): two-frame reorder window, libopus concealment up to 5 frames, adaptive target depth from inter-arrival jitter clamped to 1060 ms, with `PUNKTFUNK_MIC_LEGACY_BUFFER=1` as a one-release escape hatch and a 30 s "mic uplink health" line. Sequence deliberately freezes while muted on every client — a pause is not loss and must not be concealed or counted as a gap. `Settings::echo_cancel` (default on, `#[serde(default)]`) gates what `PUNKTFUNK_NO_AEC` gated; the env var still wins one-way and can only turn AEC off.
- **HDR × 4:4:4 on Windows.** `HdrRgb10Converter` is one full-res pass from the FP16 scRGB desktop to packed `R10G10B10A2` in BT.2020 PQ, reusing the P010 shader's `scrgb_to_pq2020` verbatim so both HDR outputs share bit-identical colour math — it simply stops before the RGB→YUV matrix, the studio-range squeeze and the chroma decimation, and NVENC does the CSC to YUV 4:4:4 itself under FREXT. No swizzle is involved despite appearances: NVENC names packed formats MSB-down, so its `ABGR10` is bit-identical to DXGI `R10G10B10A2_UNORM`. `capturer_supports_444` no longer has a depth it cannot serve, so the chroma resolved before the Welcome is the chroma the wire carries. AV1 is deliberately untouched — Range Extensions are HEVC-only. On the client side `vkframe_plane_views` accepts the 2-plane 4:4:4 pool formats (8-bit and the 10-bit 3PACK16 sibling); 3-plane 4:4:4 stays rejected and demotes cleanly. No capability probe gates `VIDEO_CAP_444` on purpose — software decode is the guaranteed display floor, and a probe-gated bit would go inert on exactly the boxes relying on the fallback.
- **Vulkan decode crop.** The CSC pass sampled decoded planes with the fullscreen triangle's 0..1 UVs while its render target is the *cropped* size. FFmpeg sizes the decode pool from `avctx->coded_*` and H.264 codes `16 * mb_height`, so a 1080-row picture decodes into a 1088-row pool and destination row 1079 sampled source row ~1087.5. `VkVideoFrame` carries the pool extent now and `record_csc` takes a `uv_scale`. Only the Vulkan-Video path passes a scale below 1.0; every other call site passes `[1.0, 1.0]` and is inert.
- **Console architecture.** The event subscription needed four separate defects fixed before it could work, each found by measuring: Nitro's `localFetch` accumulates the whole response so nothing streams through the deployed Bun server (SSE gets its own route handing back a web `Response` wrapping the upstream stream); hydration mounts the shell and discards it ~15 ms later, so the subscription is a refcounted module singleton with a grace period; `getRouter()` runs more than once in the browser and each call built its own QueryClient; and `invalidateQueries` only refetches queries with a live observer. Events never carry data into the cache — they only mark staleness — so an unknown future kind costs nothing and a missed event degrades to the slow safety-net polling underneath.
- **Console security.** The login throttle's per-IP bucket was always `undefined` because Nitro's synthetic request has no `remoteAddress`; the Bun entry stamps the real peer into a header (deleting any client-supplied copy first). Session cookies carry an epoch persisted next to the host config, so logout revokes across a restart — it was a module-level counter that reset with the process. Installing an unreviewed package or adding a catalog source now re-ask for the console password, the plugin-UI response filter became an allowlist (`Clear-Site-Data` from a plugin error page could sign the operator out on our own origin), the ui-credential denylist matches the normalised path, and a half-configured TLS setup refuses to start rather than serving with the login password in the clear. `@unom/ui/button` reached two game-UI sprite sheets through `sound/defaults.js` — a 4.8 MB `.wav` and a 2.2 MB `.mp3` that rode into the Windows installer and the .deb and could never be played; a build-time rewrite takes the asset payload from 8.2 MB to 1.5 MB.
- **Known-hosts store.** `upsert` matches on fingerprint, which is what lets a host that moved address keep its record; a host that changed *identity* matched nothing and appended a second record, and `find_by_addr` returned whichever came first. Trust decisions funnel through `upsert_trusted`, which retires — deletes, not demotes — any other record for that address, carrying the box-describing fields across (MAC, OS chain, bound profile, pinned cards, last used) while `paired` and `clipboard_sync` are re-decided, being decisions about one specific certificate. Only trust decisions may retire; the wake path's address re-key and every learn-from-advert path stay on plain `upsert`, since letting an unauthenticated mDNS advert delete a saved host by claiming its address trades this bug for a much worse one. `find_by_addr` stops being positional: a real fingerprint beats a placeholder, newest trust decision wins among real ones.
- **Environment.** `PUNKTFUNK_GAMESCOPE_HDR` defaults **on** (explicit-off grammar); the NixOS module's `gamescopeHdr` only controls whether the patched binary is on `PATH`. `PUNKTFUNK_VIDEO_SOURCE` defaults to `virtual` — unset previously fell through to the synthetic test pattern, reachable once `host.env` became optional. `PUNKTFUNK_SECURE_DDA` is read by nothing and is no longer written into a fresh `host.env`; `PUNKTFUNK_INPUT_BACKEND` documented a `uinput` value that does not exist and omitted `kwin`. `PFVD_NO_RT_GPU` makes the virtual display's realtime GPU priority A/B-able without a rebuild. `PUNKTFUNK_STALL_PROBES=0` opts a box out of the capture micro-probe engine.