diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c1bda5bd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,559 @@ +# Changelog + +Protocol, ABI, driver and embedder detail, one section per stable release, newest first. + +This is the **technical** half of a release. The other half — what changed for people who *use* +Punktfunk — is `docs/releases/vX.Y.Z.md`, and it deliberately contains no internal names. The two +were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown +long enough to bury the user-facing half it was appended to. See `docs/releases/README.md`. + +If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start +with the version table of the release you are moving to, then read **Breaking changes**. + +--- + +## v0.25.0 + +327 commits since v0.24.0. + +### Versions + +| | v0.24.0 | v0.25.0 | Notes | +|---|---|---|---| +| Wire protocol | 2 | **2** | unchanged — every addition below is optional or capability-gated | +| C ABI | 14 | **17** | three steps; see below | +| Workspace crate dirs | 22 | **26** | `pf-bitstream` (+ vendored `cros-codecs`), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec` added; `pf-ffvk` removed | +| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) | +| Windows virtual-gamepad channel | 3 | **3** | unchanged | +| Plugin index schema | 1 | **1** | unchanged | +| `api/openapi.json` | 0.23.0 | **0.24.0** | tracks API edits, lags one release by convention | + +`crates/pf-driver-proto` is byte-for-byte identical to v0.24.0 — if you ship the virtual-display +driver or the gamepad channel, nothing in this release touches you. + +**Why the wire did not move.** It grew a lot and still did not break: an optional trailing +`max_shard_payload: u16` on `Hello` (absent/0 = legacy, doubling as the renegotiation capability +flag and the jumbo receive ceiling); two control messages `ShardPayloadChanged` (`0x08`) and +`ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` beside the plain `0xC9`; a +controller-audio plane at `0xD1`; a new `0xCD` kind `0x06`; arrival flag bits 8/9; and +`MAX_DATAGRAM_BYTES` 2048 → 9216. Old peers never send or read any of it. Bump `WIRE_VERSION` only +when the handshake or planes change *incompatibly* — riding a C-ABI bump onto the wire once locked +every new client out of every deployed host (`ABI mismatch: client 3 host 2`, observed live). + +### C ABI 14 → 17 + +- **v15 — the rumble policy engine's C surface.** `punktfunk_connection_next_rumble_cmd`, + `punktfunk_connection_set_rumble_quirks`, `PUNKTFUNK_RUMBLE_QUIRK_*`. These symbols are **not + new**: they landed while the constant still read 7 and no bump was made, so every core since has + exported them while advertising a version that never promised them. A shipped binary says what it + says, so this cannot be corrected retroactively — **v15 is the floor that guarantees them.** At or + above 15 the surface is present; below it, probe for the symbol. No code changed with this bump. +- **v16 — the controller-audio client surface.** `punktfunk_connection_next_pad_audio` (the `0xD1` + per-gamepad DualSense haptics/speaker plane), `punktfunk_connection_set_pad_audio_caps`, and the + `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. +- **v17 — session end reason.** `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` + vocabulary: after a session ends, ask *why* — this client closed it, the host's launched game + exited (its close carried `APP_EXITED_CLOSE_CODE`, which the host had been sending for a long time + with nothing consuming it), the host ended it cleanly, the host reported a failure, or the + connection was lost. Purely a read of state the core already had: **no new call is required of an + embedder**, a client that never calls it is unchanged, and the host sends identical bytes either + way. + +### ⚠ Breaking changes + +**1. 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in +header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and +the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the +header. + +*What to do:* add the prefix. Values are identical; the change is mechanical. + +*It cannot break silently.* The old spellings cease to exist, so this is always an +undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A +colliding `#define` does **not** fail to compile: the preprocessor silently takes the last +definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at +runtime. Associated constants are untouched; the generator already qualifies those by type name. + +**2. Linux hosts: the virtual Steam Deck controller moved to its own `punktfunk` group.** The +capability rode on `input`, which every gamepad guide tells users to join — but it can emulate +arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops +attaching. Ordinary virtual gamepads are unaffected. + +**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a +shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that +populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead. + +**4. Plugin UIs moved to their own origin** on a second listener (default `PORT + 1`, +`PUNKTFUNK_UI_PLUGIN_PORT`). Reverse proxies and firewalls must forward that port; a self-signed +console needs it trusted separately. + +### Capability bits + +Four added, all in the handshake's client/host capability bytes: + +| Bit | Constant | Meaning | +|---|---|---| +| client `0x04` | `CLIENT_CAP_AUDIO_RED` | can decode the redundant desktop-audio plane | +| host `0x20` | `HOST_CAP_AUDIO_RED` | is sending it | +| client `0x08` | `CLIENT_CAP_PAD_AUDIO` | can render controller audio | +| host `0x40` | `HOST_CAP_PAD_AUDIO` | is sending it | + +⚠ **Pressure worth watching:** `client_caps` has four bits free; **`host_caps` is down to its last +one (`0x80`)**; `video_caps` has been full since 0.23.0 (`VIDEO_CAP_MULTI_SLICE = 0x80`). The next +video capability needs a second byte *and* an ABI bump — plan for it rather than discovering it. + +### Wire planes + +- **Controller audio, `0xD1`** — `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`, + one Opus frame per datagram behind a 15-byte header. `PAD_AUDIO_KIND_HAPTICS = 0` is the pad's + BACK channel pair (the voice coils) at 5 ms frames; `PAD_AUDIO_KIND_SPEAKER = 1` is the FRONT pair + at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker, + silence is a frozen sequence under the mic-mute discipline, host gating at −60 dBFS with a 250 ms + hangover. `0xD2` (redundant desktop audio) deliberately skipped `0xD1` to reserve it for this. +- **`HidOutput::AudioCtl`** — `0xCD` kind `0x06`, carrying the DualSense output report's + volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind. +- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host. +- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant; + the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value, + so the struct layout is byte-identical). A zero-length effect body is now rejected rather than + decoding as an empty — that is, a *release* — effect. +- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate + bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it + would use to subscript its own array. The client also clamps the host's rumble lease receive-side + at 5 s, where the ceiling had been sender-side only. + +### Host environment variables + +| Variable | Default | Notes | +|---|---|---| +| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. | +| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows | +| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` | `client_only`/`host_and_client`/`follow_default`. **Windows host only.** | +| `PUNKTFUNK_PAD_AUDIO` | on | `0` disables controller audio host-wide | +| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1` | max 4; multi-pad needs an operator to raise it | +| `PUNKTFUNK_PAD_AUDIO_STAMPS` | unset | debug bisect hook | +| `PUNKTFUNK_WIRE_MTU` | unset | pins on-wire IP MTU for all sessions; above 1500 also enables jumbo | +| `PUNKTFUNK_JUMBO` | unset (off) | fixed 9000-MTU profile | +| `PUNKTFUNK_UI_PLUGIN_PORT` | `PORT + 1` | the plugin-UI origin | +| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` | +| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. | +| `PUNKTFUNK_VAAPI_DEVICE` | client | **new** — pin the VAAPI render node | +| `PUNKTFUNK_DUMP_VIDEO` / `PUNKTFUNK_AU_DUMP` | client | **new** — capture exact decoder input / the AU as it arrived from the host | +| `PUNKTFUNK_AU_FAULT=drop\|truncate\|flip[:period]` | client | **new** — deliberate decoder-input corruption for recovery testing; native rungs only | +| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1` | host | **new** — opt-in live split-encode arbitration (Linux-wired) | +| `PUNKTFUNK_NO_AUDIO_MINT` | host (Win) | **new** — opt out of minted endpoints; restores the name ladder | +| `PUNKTFUNK_GPU_PRIORITY` | host (Win) | **removed** — superseded by `PUNKTFUNK_GPU_PRIORITY_CLASS`, a strict superset | +| `PUNKTFUNK_FFMPEG_LOG` | client | **removed** with the av_log machinery | + +Legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to +`host_and_client` and `follow_default`; `follow_default` wins if both are set. New devtest command: +`punktfunk-host pad-endpoint ensure|remove|status`. + +### Security + +- **Origin isolation.** A second listener serves `/plugin-ui/**` and nothing else; the console origin + refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different + origin (scheme+host+port) so same-origin policy *is* the boundary; same site so the `SameSite=Lax` + session cookie still flows. Bind failure disables plugin UIs rather than falling back. + `x-pf-listener` is stripped inbound and set by the entry; active ports republish as + `*_PORT_ACTIVE`; the plugin origin's CSP names the console as its only `frame-ancestors`; the proxy + allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`. + ⚠ The kit's `postMessage(..., "*")` is **load-bearing** — narrowing it to `location.origin` would + target the plugin's own origin and drop every message. +- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of + permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live + route table and **fails the build on any unclassified route** — the block-list it replaces let new + endpoints through silently. Field authority is tracked separately from route reachability: + requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are + operator-token only. +- **Art serving** gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC + refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before* + canonicalization so `%2e%2e` cannot hide. Validation also runs at write time, so an unservable path + can no longer be persisted. + +### Native decode — FFmpeg is gone from the client + +268 files, +129k / −25k. `cargo tree -p punktfunk-client-session` finds zero `ffmpeg`. **The host +keeps `libavcodec` unconditionally** (pf-encode); no host workflow, packaging script or licence file +was touched. + +| Platform | v0.24.0 | v0.25.0 | +|---|---|---| +| Linux desktop | ffmpeg-next: Vulkan hwcontext (`pf-ffvk`) → VAAPI → libavcodec sw | `pf-vkdecode` (ash, presenter's own `VkDevice`, zero-copy) → `pf-vaadec` (dlopen'd libva, DRM-PRIME dmabuf) → `openh264` + `rav1d` | +| Windows desktop | ffmpeg-next Vulkan → libavcodec D3D11VA half | `pf-vkdecode` → `pf-dxvadec` (plans into `ID3D11VideoDecoder`) → `openh264` + `rav1d` | +| Android | MediaCodec (never had FFmpeg) | unchanged | +| Apple | VideoToolbox (never had FFmpeg) | unchanged | + +**Workspace members:** added `pf-bitstream` (+ vendored `cros-codecs`, compiler-enforced +`unsafe`-free), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec`; removed `pf-ffvk`. **Deleted:** +`video_vulkan.rs`, `video_vaapi.rs`, `video_libav.rs`, the libavcodec half of `video_d3d11.rs`, the +`av_log` machinery, `ffmpeg::codec::Id` as decoder vocabulary, `DecodedImage::VkFrame`/`::Dmabuf`, +the `ffmpeg-fallback` feature, and swscale — and with it the BT.601 default its correction code +existed to undo. + +**Software rung:** `openh264 = "0.9"` (BSD-2) and `rav1d = { version = "1", default-features = +false, features = ["bitdepth_8"] }` (BSD-2). `dav1d-sys` was rejected because it is `system-deps`- +only and would add a system library plus a `.pc` to every client package. `default-features = false` +drops `asm` — rav1d's `build.rs` *panics* without nasm, unlike openh264-sys2, which degrades quietly. +**`bitdepth_8` only** ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before +any byte reaches the decoder. + +**⚠ HEVC has no CPU floor.** An HEVC session that exhausts its hardware rungs tears down and re-dials +advertising HEVC-less caps, and the host picks H.264 (`last_rung_verdict` / `NoSoftwareRung`). This is +a first-class path, not a failure. + +**Rung × codec × hardware evidence** (`native_evidence`) — the admission filter is driven by this, so +an unproven rung yields only to one that is both verified for the codec and usable on the device: + +| Rung | Codecs | Evidence | +|---|---|---| +| `native-vulkan` | H.264, H.265 Main/Main10/4:4:4 | **yes** — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak | +| | AV1 | **yes** — 250/250 bit-identical on one vendor, no soak | +| `native-d3d11va` | H.264, H.265 | **yes** — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak | +| | AV1 | **not proven** — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter | +| `native-vaapi` | H.264, H.265, AV1 | **NO — has never decoded a frame anywhere**; no VAAPI hardware was reachable | +| `software` | H.264 (openh264), AV1 (rav1d) | **not proven**; openh264 has never run on glass. No HEVC at all. | + +Vendor order (unchanged): Linux NVIDIA/AMD `vk → vaapi → sw`; Linux Intel/unknown +`vaapi → vk → sw`; Windows NVIDIA/AMD `vk → d3d11va → sw`; Windows Intel/unknown +`d3d11va → vk → sw`. + +**AV1 advertisement** now answers from device facts (`av1_hardware_decodable`: Vulkan `DECODE_AV1` +queue op, or the Windows D3D11 import path) rather than `ffmpeg::decoder::find(AV1)`, which was true +on any build linking libdav1d. **Settings migration:** stored `vulkan`/`vaapi`/`d3d11va` migrate to +`native-*` at decoder construction *and* at each dialog's lookup — the second is load-bearing, since +an unmatched value renders as "Automatic" and a save would silently rewrite the preference. + +### The three decode data-loss bugs + +**AV1 sub-frame truncation — shipped in v0.24.0, host-side.** NVENC sub-frame readback has two halves +armed by *different* conditions: `build_init_params` arms the writer from `subframe_on` alone, while +the chunked reader additionally requires `slices >= 2` — and `resolve_slices` returns `1` for AV1 +unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to +publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header +declaring two tile rows plus a single Tile Group OBU with `tg_start = tg_end = 0`; libdav1d rejected +**835/836** AUs. NVIDIA's *hardware* decoder accepts it (so Vulkan Video looked healthy at 60 fps); +its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half +the picture. Fixed by disarming sub-frame for AV1 while leaving `split_mode` untouched — AV1 keeps +every engine. Arming the reader instead is *not* a drop-in: the reader cuts at +`bitstreamSizeInBytes` on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not. +Post-fix 654/654 clean. The test that had pinned the old behaviour as *correct* is replaced by one +pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison +nothing made. + +**HEVC DPB from the level ceiling — new in this release, client-side.** `dpb_limit` computed +`max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1)`. HEVC equation A-2 is a **ceiling on +what an SPS may legally signal**, not a statement of need, and it branches on picture size against +the *level's* `MaxLumaPs`. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds +L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields +16 frames / **17 slots** — one more than NVIDIA's `maxDpbSlots` of 16 — so every AU fell outside +device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and +there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that +falls through to the honest answer. Fixed to `buffering.min(16)`: the `max()` bought no tolerance, +since `Dpb::needs_bumping` already evicts at the signalled depth — it only over-allocated ten +surfaces per 1080p session. **H.264 escaped by luck** (its ceiling lands at 13 for 1080p) and is left +alone, because H.264's DPB size genuinely *is* level-derived absent a VUI `bitstream_restriction`. + +**rav1d aborts the process — new in this release, client-side.** rav1d 1.1.0 `abort()`s on *any* +decode error while holding one frame context: the `c.fc.len() == 1` branch decodes inline, always +finishes in `rav1d_decode_frame_exit` which unconditionally takes `frame_hdr`, then on `Err` re-enters +an `on_error` whose first act is `frame_hdr.as_ref().unwrap()` on the `None` it just left. The panic +unwinds into `dav1d_send_data`, which is `extern "C"` ⇒ `panic_cannot_unwind` ⇒ `abort()`. **No +`catch_unwind`, no rung demotion and no refusal can catch it**, and every `rav1d_*` entry is +`pub(crate)`, so no in-process guard is possible. 4K was only *where* the first error happened — the +CPU rung does 35–39 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to +live, and the next AU referenced undecoded frames. Fixed by opening with `n_fc >= 2` and asking +`dav1d_get_frame_delay` what the settings actually bought. Decode now drains **past** the first +`EAGAIN`, which is why two frame contexts cost no latency (20–42 ms/unit at `n_fc=2` vs 21–53 at +`n_fc=1`). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204 +frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as **rav1d#1497** +with a reproducer. Does **not** make the CPU rung panic-proof. + +**Settings loader BOM — shipped in v0.24.0, client-side.** `.and_then(|s| from_str(&s).ok())` turned +every parse failure into `Default`. `Set-Content -Encoding UTF8` writes `EF BB BF`, serde_json +correctly rejects at byte 0, and every setting vanished silently. A shared `load_json_or_default` now +strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a +BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately +still `Default`, never an error. + +### Other decode/encode + +- **Intel Arc pNext ordering.** `vkGetPhysicalDeviceVideoCapabilitiesKHR` was called with the codec + caps struct chained *before* `VkVideoDecodeCapabilitiesKHR` (`push_next` prepends). Arc/Windows + fills those two **by position, not by sType**, and returned them swapped — we read a level as a + capability bitmask. Measured A/B: `decode_flags_raw=12 max_level_idc=1` before, + `decode_flags_raw=1 max_level_idc=12` after. NVIDIA and RADV dispatch by sType, which is why the + fleet stayed green. ⚠ **This does not yet give Arc Vulkan Video** — the refusal only moves down: the + device advertises only COINCIDE, and its NV12 coincide entry does not advertise `SAMPLED` usage, + which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint. +- **NVENC split encode.** The 10-bit rule sat *above* the pixel-rate arm and took no codec, so it + vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an + HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and + Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89× — **split wins at every mode on both + architectures, including the configuration the veto came from.** New order: env override → + pixel-rate arm (now taking `max_forced_split_mode(engines)`, not a hard-coded 2) → + HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because **the driver + honours an over-ask and silently encodes narrower**. Also newly logged: HEVC + plain AUTO + + sub-frame is **silently single-engine** — the fleet's default shape, and nothing said so. + ⚠ **Unvalidated consequence:** 5120×1440@240 Main10 now clears the pixel-rate bar and *will* be + forced to split — the exact configuration the old veto came from. `PUNKTFUNK_SPLIT_ENCODE=0` is the + escape. +- **PyroWave on Windows stamped over the host's GPU scheduling policy.** It raised the process WDDM + class to HIGH at every session open, while `auto_priority_gate` already owns that process-wide — + starting at HIGH, *upgrading* to REALTIME once safe, and leaving a monitor that drops back when VRAM + tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave + stamped HIGH back and **orphaned the monitor's decision**. Removed rather than reconciled. +- **A `pf-vkdecode` AV1 use-after-free fix had stabilised the wrong pointer** — + `OwnedStdAv1SequenceHeader` kept the Std struct *inline*, so `pStdSequenceHeader` was a dead stack + address; it worked only because NVIDIA happened to retain `pColorConfig` instead. Std structs are + now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters + assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265. + +### Windows audio substrate + +The host now mints its **own** devnodes from Valve's INFs (`SteamStreamingSpeakers.inf` / +`SteamStreamingMicrophone.inf` under `{CommonProgramFiles(x86)}\Steam\drivers\Windows10\…`) instead +of bundling VB-CABLE. + +- **Two persistent endpoints**, `Punktfunk Speakers` (client-only loopback sink — the wiring plan + parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host + stays silent) and `Punktfunk Microphone` (host writes decoded client voice into the render side; + the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker. +- **Identity is the recorded endpoint id, never the name** — a minted instance is name-identical to + Steam's primaries. Durable marker `PunktfunkAudioRole` (1 = Speakers, 2 = Mic) under Device + Parameters. Name stamping is device-desc + device-name **only**: a wider stamp set makes + `AudioEndpointBuilder` re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure + the endpoint still wires and simply keeps the driver's default name. +- **Format stamps are per-direction.** Render gets the PCM16-device / float-mix stereo split; capture + gets the **device-format key only** — mix and host-format keys are render-engine properties, and + stamping them onto a capture endpoint breaks its shared-mode graph (`IsFormatSupported` reports + 2ch/48k fine, `Initialize` then fails `0x88890008`). +- **`MintedIds` is tier-0 in the wiring plan.** The mic takes its minted device outright (paired by + provider id — a name search cannot distinguish it from the primary); the loopback prefers the + minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries + → cable → real hardware. `PUNKTFUNK_MIC_DEVICE` still beats everything. +- **Mic-vs-loopback arbitration**: the mic may hold the Streaming Microphone only while the loopback + still gets a non-last-resort pick; otherwise the loopback takes it and `mic_withheld` is set. This + fixes a field case where a headless Steam-only host streamed **silence**. +- **New `AudioReadiness`** — `Full` / `AudioOnly` / `MicOnly` / `Nothing`, logged on every plan + change and surfaced at `GET /api/v1/status` → `RuntimeStatus.audio` (`AudioWiring`, Windows-only, + absent before the first wiring pass; a status poll triggers no COM work or `IPolicyConfig` writes). + The console Dashboard renders it as an "Audio wiring" card. +- **Requires Steam installed** (never running) — without the INFs the host streams video only, and + picks the drivers up automatically if Steam is installed later. Opt out entirely with + `PUNKTFUNK_NO_AUDIO_MINT`, which restores the previous name-based ladder exactly. +- ⚠ **VB-CABLE is no longer bundled but is deliberately NOT uninstalled** — it is a third-party + shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was + considered and rejected: on a box where minting transiently fails, that would let the Steam + Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio + audible on the host. +- ⚠ **The minted endpoints survive Punktfunk's uninstall by design** (they are plain instances of + Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is + the devtest `punktfunk-host audio-probe cleanup`. +- New devtest: `punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup`. + `plan` is the field-triage command; `micpins` maps exclusive+shared `IsFormatSupported` across + {1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins. + +### Apple audio + +- **The microphone was never in the render graph.** On the combined (voice-processing) engine — made + default a week earlier and never run on a device — the input node carried a tap and **no + connection**, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat, + and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence + tripwire counts *captured* frames, so it never fired. Input now runs through a silent sink into the + main mixer at `outputVolume = 0` (Apple's own voice-processing sample topology). Two more: the tap + read the input format **before** `prepare()`, and enabling voice processing swaps in the VPIO unit + and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the + voice-processed engine took the whole uplink down for the session — it now falls back to the split + path, because **the mic outranks the AEC**. +- **No packet-loss concealment on the one client that decodes Opus in core.** Linux, Windows and + Android all feed an `AudioGapTracker` and synthesize libopus PLC; the in-core path had the tracker + sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms + frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane + (`0xD2`) hides single losses, so the survivors were exactly the burstier gaps that most needed + concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a + DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full + concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap). +- **The Apple jitter ring never grew.** The shared Rust `JitterPolicy` has an adaptive target floor; + the hand-written Apple mirror mirrored the *shed* half but not the *growth* half, pinning its + target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than + one delivery stall, so the ring re-primed through every stall for the whole session. Now the full + `note_read` mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70), + 30 s of quiet steps back, and the write-side hard trim follows the grown target. + +### Clients + +- **Nothing in the desktop console had ever been clickable.** `SkiaOverlay::handle_event` matched + only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console + into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while + browsing. New `Overlay::handle_pointer` carries mouse/touch in swapchain pixels; the run loop + converts (it owns the window and hence display scale); the console hit-tests the rects it drew last + frame. Only **direct** touch devices are offered — an indirect trackpad already drives the mouse. + Widgets act on **press**, not release, because both carousels scroll the focused item toward centre + and what you pressed would slide out from under your finger. Host menu on Up from a saved tile; + `UpdateHost` edits **in place** (remove-and-re-add would silently drop the fingerprint, learned MAC, + pinned cards and profile binding), and `ForgetHost` arms on first press and fires on second. +- **Discovery went permanently deaf three ways**, each needing an app relaunch: a failed resolve was + never retried (`browseResultsChangedHandler` fires only when the result *set* changes, and a host + whose resolve failed is still in the set); a stuck resolve never ended (`NWConnection` has no + timeout, so the throwaway UDP flow could sit in `.preparing` forever, and a service with a + connection in flight was skipped); and an `NWBrowser` parking in `.waiting` was ignored — **which is + exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not + revive the browser that was already waiting.** A 1 Hz sweep now times out stuck resolves, retries + failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every + browse report. `discovery::Rescan` forces a fresh mdns-sd query — the browse otherwise re-queries on + a doubling backoff **capped at one hour**, so a long-lived browse is effectively passive. ⚠ + `clients/windows/src/discovery.rs` is a **second copy** of the browse that the earlier IPv4 pinning + missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a + card that failed on every click. +- **Phone gyro mirror**, off by default, player 1 / wire pad 0 only, and only while that pad has no + motion source of its own. iOS/iPadOS only on Apple (`DeviceGyro` wraps `CMDeviceMotion` at ~100 Hz + on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source); + Android phones with a gyroscope at ~200 Hz with `maxReportLatencyUs = 0`, since batching is poison + for gyro aim. Both rotate from the device's natural frame into the controller frame by interface + orientation, and both send **one zero-gyro sample on stand-down** — the host holds motion as state + and re-emits it, so a leftover nonzero angular velocity reads as endless rotation. +- **Safe-area resolution** is purely a *sizing* change — no layout change, no input change; pointer + mapping follows for free since both clients derive the picture rect from the live host mode. Full + native height, width less left+right safe insets. Portrait settings screens report the housing on + `top` with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status + bar never fabricates one). Android adds the rounded-corner radius, which it does not count as + cutout. Both even-floor and clamp, because `validate_dimensions` rejects odd dimensions and an inset + subtraction lands odd about half the time. +- **Gamepad UI**: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input + on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one + shared `ui_palette` key, Violet keeping its explicit sixteen colours so existing installs are an + identity transform. Presentation only → **device preference, never part of a profile**. Palette + maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language; + `every_palette_is_multi_tone` fails under 45° hue spread and caught Ember at 35° and Graphite at 3°. + Three render-only findings: additive blending blows out over a pale ground, a white scrim at the + dark field's strength bleaches the gradient, and white glass over a bright field needs more body. + +### Session and game lifetime + +- **`PunktfunkEndReason` replaces a single "closed" bit** (ABI 17, additive, wire untouched). Five + values — local, game exited, host ended, host error, lost — classified by the connection watcher + from close codes already on the wire (`APP_EXITED_CLOSE_CODE` had been sent for a long time with + nothing consuming it). **Latched before the shutdown flag**, because the two are read by different + threads and the reason must never arrive second. Exposed as `punktfunk_connection_end_reason` + + `is_normal()`. Shells fall back to the old wording when there is no verdict (older core, or a close + that raced the read). +- **The Steam `Running` registry hint was an unbounded veto.** Honouring it reset the absence window + every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented — + pinned a lease in `running` for the life of the host process. The absence timer now runs + regardless; past `VETO_LIMIT` (30 s) with nothing of the game on the box, the session ends anyway + and logs at WARN. Extracted as a pure `exit_confirmed(gone_for, hint_running)` with tests — the + watch loop polls a live process table and cannot be unit-tested, which is exactly how the + unbounded veto shipped. +- **New `launchreg.rs`: one record per `(client fingerprint, library id)`**, written at launch and + independent of the termination policy. The old fingerprint-keyed reclaim only ran under + `GameOnSessionEnd::Always`, so under the default `Keep` nothing was recorded — and a client retry + re-sent `Hello::launch` verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it + (the launcher just focuses the running copy) but a `gog:`/`custom:` target genuinely started a + second instance over the same save files. The same retry also minted a fresh `launch_stamp`, so + procscan refused to adopt a game older than 2 s and **a reconnected session lost game-exit + detection for the rest of its life.** Identity now flows backwards from the watcher, which + publishes the concrete `ProcRef`s it adopted; liveness is `Scanner::alive` over that recorded set, + re-verified by `(pid, start)`. Tradeoffs: a `custom:` command with no detection hints stays + `Unknown` forever (trading exit detection for not double-spawning), and `IN_FLIGHT_WINDOW` is a + fixed 90 s, deliberately not `disconnect_grace_seconds`. +- **A launcher entry is `LeaseKind::Untracked` unconditionally**, checked ahead of + `nested`/`child`/`spec`. Its lifetime previously depended on invisible state: launcher not running + → live child → `Child` lease → quitting the launcher ended the session; launcher already running → + command forwards and exits inside `SHIM_WINDOW` → `Untracked` → session persists. Steam Big Picture + is a *mode*, not a process (and on a Deck it is always running); Heroic is single-instance + Electron. The real trap was the GameStream path, whose `GsApp` intermediate silently dropped the + field. + +### Library and plugins + +- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2 + `{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation. + `PUT /library/provider/{p}?store=` claims a store; entries then surface as + `:` rather than `custom:`, so entry ids, GameStream app ids, client art + caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is + held the matching built-in scanner is skipped, so the two never double-list. +- `GET/PUT /library/scanners` is now a **sources** endpoint over the same disabled-set file. +- New entry fields: `role: game|launcher`; launch kinds `steam_ui` (`bigpicture|desktop`), + `launcher_ui` (platform-gated, 400 on invalid) and `xbox`. +- **Plugin kit 0.3.0** adds a `./library` subpath: `defineLibraryPlugin` plus ported total parsers — + text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable + SQLite, a registry wrapper that refuses HKCU, path-confinement joins. `GET/PUT /__config` returns + `{schema, value}` and persists raw, so a plugin with settings need not ship an SPA. + +### Platform and packaging + +- **The client's config writer** falls back to an in-place write when the atomic replace is + unavailable, verifies it by reading the bytes back, and records the last persistence failure + centrally so the UI can surface it. Scratch files are now per-process, closing a real collision + between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one + could previously rename its half-written temp over another's target. +- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at + the pacing rate with a 100 ms absolute ceiling rather than compressed into one frame interval. + Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment + escape hatch, and the GameStream-compatible path is untouched. +- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video + stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those + sessions get the next-session clamp only and are excluded from jumbo. The ABR decode-cap latch + likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design. +- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream + revision as the host's own packaged build — bump both together. ~4 MB of app content replaces a + 94 MB external extension, and Flathub is no longer needed at install time. ⚠ `subprojects/vkroots` + is a gamescope **submodule** and flatpak-builder clones submodules by default; declaring it again + as an explicit source breaks the build during extraction. `glm` and `stb` are `.wrap` files, not + submodules, and *do* need explicit sources. +- **Build-container images push to an authenticated registry endpoint**, and `:latest` is reconciled + against the content key on every push to main — an out-of-band tag move is detected and repaired + rather than silently inherited. +- **Windows pad drivers** publish their sequence counters with release ordering (the host was already + loading with acquire and pairing with nothing) and serialize the output-ring publish. The + `/dev/uhid` event ABI, previously transcribed into all five Linux gamepad backends, is consolidated + into one module. + +### Verification status + +Honest about what has and has not been on hardware, because several things in this release have not: + +- **Controller audio has never run on a real DualSense.** Its entire verification is unit tests and + compile checks, and its rumble arbitration rests on an explicitly retracted assumption about + whether the voice coils and the rumble motors are the same actuators. The evidence-based 500 ms + idle window is correct either way, but the underlying exclusivity is unsettled. Android's arbiter + is the evidence-based one; the desktop twin and the coil restore on Android's stop path are owed. + Some Android OEM kernels refuse the isochronous claim outright, which degrades to ordinary rumble. +- The **plugin-UI origin split** is validated against a fake console and a fake plugin, not yet in a + real browser. +- The **packaging default-on changes** have had no installer run or package build. +- **No launcher tile has been clicked on a real host** — the first source that would publish one does + not exist yet. +- Desktop-audio, packet-sizing and iPad-pointer work is build-verified only. +- ⚠ **The FFmpeg-deletion milestone itself has never executed on a GPU.** It was gated on + cross-clippy, 160 tests, a workspace check and an `ffmpeg` count of 0 in the client / 2 in the host. + The software on-glass check, the D3D11 and VAAPI AV1 hardware legs and the field bake were all owed + at merge; later commits closed some of that but not all. The "no FFmpeg" claim is verified by + `cargo tree` and a notices-generator mention count, not by inspecting a shipped binary. +- ⚠ **`pf-vaadec` has never decoded a frame anywhere** — no VAAPI hardware was reachable. It is the + *first* rung on Linux/Intel and unknown vendors; the evidence filter bars it there in favour of + `pf-vkdecode`, but an explicit pin reaches it. +- **openh264 has never run on glass**; the H.264 software rung is unit-tested only. +- **`native-d3d11va` AV1 is deliberately `verified = false`** — one 25 s 4K60 session, no parity. +- **Split arbitration is opt-in and Linux-wired only**; the Windows arm is built and unit-tested but + not on hardware. The 5120×1440@240 Main10 behaviour flip is explicitly unvalidated and is named as + the first thing to re-measure. +- **Software throughput is unmeasured in general** — the CPU rung does 35–39 fps at 4K AV1 against a + 60 fps stream, which is why the backlog flush that triggered the rav1d abort happens at all. +- **The Apple mic fix is a proven root cause, not a verified session.** Its own commits call it "a + strong inference plus one proven logic defect rather than a confirmed fix" and close "awaiting the + reporter's on-device confirmation" — which nothing later in the range records. It also leaves a + known gap: nothing reports whether the uplink actually opened, so the HUD still offers a Mute + Microphone button over a session that may be sending nothing. +- **The Windows audio substrate is, by contrast, well-evidenced on hardware** — repeated "measured on + the target box", a live bisect on a fresh endpoint, and a `micpitch` proof reading 440 Hz in → + 440 Hz out at exact peak. The one thing not evidenced is a real client speaking through the minted + microphone end to end; the pitch proof is probe-driven. +- **The phone-gyro mirror is not recorded as hardware-verified** — remap matrices are pinned by unit + tests in both languages, but there is no "played a game with a clip-on pad" evidence in the tree. +- **The iOS gamepad-UI pale-palette sweep on glass is still owed**, per its own commit. +- ⚠ **The CI runner scripts are hand-installed** (`/usr/local/bin/ci-docker-prune.sh`, + `/usr/local/sbin/ci-docker-reclaim.sh`). Merging does not deploy them — both runner hosts need the + files copied out of `scripts/ci/`, and the missing `192.168.1.58:5011` insecure-registry entry on + one host is routed around, not fixed. diff --git a/docs/releases/README.md b/docs/releases/README.md index b5734346..4004d86f 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -79,9 +79,37 @@ should never be confused or forced to decode internals. (See any recent `vX.Y.Z. changed. 5. **Compatibility line up top, in plain terms:** can they update one side at a time? does their existing setup keep working? No version numbers in the lead. -6. **All protocol / ABI / driver / embedder detail goes in ONE `## Under the hood (for developers)` - section at the very bottom** — the only place internal names and version numbers belong, clearly - optional. The old dense engineering style survives only there. +6. **No protocol / ABI / driver / embedder detail in this file at all.** It goes in the root + `CHANGELOG.md` (see below), and the notes carry a single short `## For developers` section + linking there. Nothing else in `vX.Y.Z.md` may use an internal name. +7. **Open with a `## TL;DR`** — three to six bullets naming only what most readers would be sorry to + miss, each one line. A large release is exactly where a reader gives up, and the TL;DR is what + they read instead of giving up. If something needs the reader to *act*, it belongs here and in + `## Before you update`, not buried in `## Fixed`. + +## The technical half: root `CHANGELOG.md` + +**Why it is separate.** Through v0.24.0 the engineering detail lived in an `## Under the hood (for +developers)` section at the bottom of each release's notes. That worked while releases were small. +It stopped working: v0.25.0 is 300+ commits, and the section had grown long enough to bury the +user-facing half it was appended to — the exact failure the voice rules exist to prevent. The two +audiences also want different shapes. A user reads one release and wants prose; an embedder wants to +diff *across* releases and see when the ABI moved, which is a table, not a paragraph. + +So: `vX.Y.Z.md` is for people who use Punktfunk, `CHANGELOG.md` is for people who build against it, +and neither has to compromise for the other. + +**Format.** Newest release first, one `## vX.Y.Z` section each. Lead with a version table (wire +protocol, C ABI, driver protocol, gamepad channel — every row, marked *unchanged* where it did not +move, because "unchanged" is the answer an embedder most often needs). Then breaking changes, then +whatever else matters: capability bits, new environment variables, wire additions, workspace +members. Internal names are the point here — use them. + +**Linking.** The notes link to the file **at the tag**, not at `main`: +`https://git.unom.io/unom/punktfunk/src/tag/vX.Y.Z/CHANGELOG.md`. A release's notes are frozen; a +link to `main` would silently start describing a later release. + +**Same freeze rule.** Add the release's section in the version-bump commit, alongside the notes. The short annotated-**tag** message stays separate and short (a headline + a paragraph); it is the tag object's message, not this file. diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md index 2e2f8cb7..c9b576b4 100644 --- a/docs/releases/TEMPLATE.md +++ b/docs/releases/TEMPLATE.md @@ -5,16 +5,33 @@ older/newer clients negotiate or fall back, so nobody fears updating.> (everything above the first `##`) is what the Discord #releases embed shows, so make it read as a standalone summary.> -## Highlights +## TL;DR -- **.** -- **.** <…> +- **** +- **** +- **** -## Fixes +## Before you update + + + +## New + +- **.** + +## Improved - **.** <…> -## Platform notes +## Fixed -- **.** +- **.** <…> + +## For developers + +Protocol, ABI, driver and embedder detail — including the version table and any breaking +changes — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/vX.Y.Z/CHANGELOG.md). + + diff --git a/docs/releases/v0.25.0.md b/docs/releases/v0.25.0.md index 88190100..def631be 100644 --- a/docs/releases/v0.25.0.md +++ b/docs/releases/v0.25.0.md @@ -1,108 +1,83 @@ Wire-compatible with 0.24.x — 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; the parts that are new switch themselves on only once both ends have them. -The headline is that a **DualSense plugged in by USB can now play a game's fine-grained haptics — the textured detail in the grips, not just the rumble motors — and its own speaker, streamed from the host**. That needs a Windows host with Steam installed and either the Android app or the desktop session client; everywhere else, nothing changes. +This is the largest release so far — a bit over three hundred changes. The short version: the Linux and Windows apps stopped carrying FFmpeg and now decode on your graphics card directly, two long-standing faults that were quietly throwing away picture quality on NVIDIA hosts are fixed, Windows hosts bring their own sound devices instead of a third-party driver, and controllers got swept end to end. -Behind it, three fronts. **Controllers** were swept end to end: rumble that faded on a Steam Deck, died for good after one hiccup on a phone, or kept buzzing after you quit; adaptive triggers and lightbars left stuck in a game's last state on your desk after the stream ended; player-number lights that never lit on anything but a DualSense — more than twenty separate faults, across every client and both hosts. **Sound** got the same treatment: desktop audio is encoded at roughly double the bitrate, hosts stopped routing the entire game mix through Steam's voice channel on PCs that had it installed, and audio that drifts behind the picture now pulls itself back instead of staying late for the rest of the session. And the **black screen** that some people hit on VPN-shaped networks — a session that connects, reports every gauge healthy, and then shows nothing at all, forever — is finally diagnosed, explained in the log, and healed on its own. Alongside those: a security review closed 37 of 38 findings — a few of which need a moment of your attention, so the list is right below this; HDR finally works on a Steam Deck out of the box; the Windows client stops forgetting your settings when it isn't installed on C:; the library settings become one **Game sources** list with a Launchers row above your games; the Steam Deck plugin is rebuilt as a launcher into the app; holding Select on any controller presses the host's Guide button; and saved settings profiles can be pinned to hosts without a mouse. +## TL;DR + +- **The Linux and Windows apps no longer contain FFmpeg** — they drive your graphics card's own video decoding. Nothing to install, nothing to keep in step with your distro, and your saved decoder preference carries over. +- **AV1 at 4K was shipping half of every frame.** An NVIDIA host sent only the top half of each picture, and had since AV1 was first wired up. Update the host. +- **HDR and 10-bit streams stop leaving half your encoder idle** — the cap that held high-refresh HDR at around 130 fps is gone. Update the host. +- **Windows hosts make their own "Punktfunk Speakers" and "Punktfunk Microphone"** instead of bundling a third-party audio cable. This needs Steam installed on the host, though it never has to run. +- **Controllers, end to end:** a wired DualSense can play a game's fine-grained haptics and its own speaker, and more than twenty separate rumble, trigger and lightbar faults are fixed across every client and both hosts. +- **Your settings could silently reset to defaults** if the file was ever saved by PowerShell. Fixed, and a broken settings file now says so instead of quietly starting over. +- **A few things need you to act** — a new group on Linux, a firewall port for add-on interfaces. They are right below. ## Before you update -A security review closed 37 of 38 findings this release, and a few of them change behaviour in ways you need to know about. Most people need to do nothing — but check this list if any of it applies to you. +Most people need to do nothing. Check this list if any of it applies to you. -- **Linux, if you use the virtual Steam Deck controller: you must join a new group.** The permission that lets the host create that emulated pad used to ride along with the `input` group, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has a group of its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad will stop attaching after this update. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust. -- **Add-on interfaces now load on their own port.** An add-on's own interface used to run on the web console's address, which meant it could act with your logged-in permissions; it now runs on a separate port next to the console's (47993 by default). If you reach your console over a self-signed certificate, your browser needs to trust the new port once — the console shows a card explaining this with a link that opens it in a new tab. If you use a custom firewall or a reverse proxy, open or forward that second port. Fresh installs open it automatically on Windows, and the Linux firewall profiles include it. If the port cannot be opened at all, add-on interfaces switch off and say so rather than quietly moving back. -- **Saving a game with a custom launch command asks for your console password again.** A custom command runs on your machine as you, so it now re-confirms. Ordinary edits — title, artwork, platform, a normal Steam launch — are untouched. -- **Add-ons can no longer set launch or pre-launch commands themselves.** Those two fields run through a shell and are yours alone now; an add-on that tries is refused. If you use a third-party add-on that filled them in, it will need updating by its author, who should use the new "opens a launcher" entry type instead. -- **A new install now runs the add-on runner by default.** Game sources depend on it, and a host without it would show an empty library. Upgrades are untouched — if you deliberately switched it off, it stays off. You can still disable it and keep streaming; you only lose add-on game sources and automation. -- **If you installed on a Steam Deck with the setup script, consider rotating your console password.** It was written to a world-readable file. That is fixed, and the Fedora/RHEL hint that told you to read the password out of the system log — where anyone able to read logs could see it — is gone too. +- **Linux, if you use the virtual Steam Deck controller: join a new group.** That permission used to ride on `input`, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad stops attaching. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust. +- **Add-on interfaces moved to their own port (47993).** An existing firewall rule will not pick it up when you upgrade, and the symptom is a blank panel where the add-on's interface should be. On Linux the package prints the exact command — `sudo ufw app update punktfunk-web && sudo ufw reload`, or a firewalld reload. With Docker, publish `47993` as well. If you reach your console over a self-signed certificate, your browser needs to trust the new port once; the console shows a card with a link that does it. +- **Windows hosts now need Steam installed** for streamed audio — it never has to run. Without it the host streams video only, and picks the drivers up on its own if you install Steam later. Two new devices, "Punktfunk Speakers" and "Punktfunk Microphone", will appear in your sound settings; that is this feature working. If you already have VB-CABLE, leave it — it still works as a fallback and is not removed. +- **Saving a game with a custom launch command asks for your console password again**, and add-ons may no longer set launch commands at all. A third-party add-on that did will need updating by its author. +- **A fresh install now runs the add-on runner by default.** Upgrades are untouched — if you switched it off, it stays off. +- **If you set up a Steam Deck with the install script, consider rotating your console password.** It was written to a world-readable file; that is fixed. ## New -- **Your DualSense's own haptics, carried from the host.** Games that drive the DualSense's fine-grained voice coils — the detailed, textured feedback in the grips, as distinct from the coarse rumble motors — now carry that across the stream to the controller in your hands, and the pad's built-in speaker can be carried with it. It needs all of: a DualSense or DualSense Edge **plugged into your device by USB** (over Bluetooth the pad exposes no audio device to play into, so there is nothing this can do), a **Windows host with Steam installed** (the per-controller audio device is built on Valve's Remote Play streaming-speakers driver), and either the Android app or the desktop session client. Anywhere else — a Linux host, the iPhone/iPad/Mac app, the ordinary Windows or Linux desktop app, a Bluetooth pad — nothing changes at all. A game that uses only ordinary rumble keeps rumbling exactly as it does today. On Android the controls are **Controller haptics** and **Controller speaker** under Controllers, alongside a **Test haptics** button that checks your phone can drive the pad at all without needing a stream running. Expect a new playback device named "DualSense Wireless Controller" to appear in the host's Windows sound settings — that is this feature, it is how games find the controller's speaker, and it will not take over as your default output. -- **One "Game sources" list, and a Launchers row above your games.** The library settings used to show two separate boxes — a list of toggles for the built-in launcher scanners, and a second card for anything an add-on had synced in. They are now one list, where every source (Steam, Lutris, Heroic, Epic, GOG, Xbox, or one from an add-on) is a row with the same controls: on/off, how many games it contributes, a "show only these" filter, its own settings, and a remove option that offers to take its games with it. Add-on sources show a Running or Stopped badge so a dead one is obvious at a glance. Separately, entries can now be marked as opening a launcher rather than a game — Steam Big Picture, Steam desktop, Heroic, Lutris — and those group into a Launchers row above the game grid instead of getting lost among your titles. You can tick that yourself when adding or editing an entry. Update the host and console. -- **Game sources are becoming add-ons, and nothing about that is forced.** Each launcher is being turned into its own add-on, so you install only the ones you use and each gets its own settings. **All six built-in scanners still ship in the host, still switched on, and still find your games with nothing installed** — no one has to install anything to keep what they have. As replacement add-ons become available the console offers to move a source over, one button per source, never all at once and never automatically; a source keeps the same identity either way, so switching does not renumber your games, lose their artwork, or break pinned shortcuts. Half-moved is a perfectly valid state. In 0.25.0 this is groundwork: the replacement add-ons are not published yet, so expect the offer to appear as they arrive rather than on update day. -- **Hold Select to press the host's Guide button.** Hold Select (Back / View) on its own for about a third of a second and the host sees its Guide button go down — and it stays down while you hold, so a longer hold reads as a long-press on the host, which is how a big-screen host opens its Quick Access Menu. A quick tap of Select still goes to the game, and Select as part of a combo — including the leave chord — passes through untouched. It is on by default on iPhone, iPad and Apple TV, where the system keeps the controller's own Home press for itself and this is the only reliable route to the host's overlay. Everywhere else the raw press already reaches the host, so the gesture stays off by default and Select keeps its exact timing. Update the client. -- **Get onto a host by asking, instead of typing a PIN.** From the Steam Deck panel, tapping a locked host now offers **Request access**: the stream opens and waits while whoever is at the host approves your Deck in its console, then the picture comes up by itself. It gives up after about three minutes like any failed connection. Offered only for hosts visible on your network — one you saved by typing an address has no advertised identity to check against, so those still use a PIN, and the sheet says why. Update the plugin; hosts already knew how to approve. -- **Pin a settings profile to a host from a controller.** Every controller-driven settings screen — the Deck and Linux console home, the Apple app's gamepad UI including Apple TV, and the Android app's controller UI including Android TV — gains a **Profiles** section showing each profile and where it is pinned ("Not pinned", "Pinned to 2 hosts"). Open one and press A on a host to pin or unpin. On Apple TV this is the only profile management there has ever been; on Android, pinning previously needed a touchscreen. Creating and editing profiles is still a desktop or phone job. Update the client. -- **Pinned profiles appear as their own cards on the console home.** A pinned profile shows up as an extra card right after its host, subtitled with the profile's name, and one press connects using those settings. A host already bound to a profile now names it next to its address, so you can see which settings a plain press will use. Update the client. -- **A lost audio packet is rebuilt exactly instead of being papered over.** Each audio packet can carry a copy of the one before it, so a single loss is reconstructed bit-for-bit rather than concealed with a synthesized approximation you can hear. It costs no extra delay — the copy rides on a packet that was already arriving in time. Needs 0.25.0 on both ends; with either side older, audio goes over the wire exactly as it did before. -- **Audio quality is now budgeted against your connection.** The higher bitrate and the packet redundancy above are worth having on a roomy link and much too expensive on a narrow one, and audio is not managed by the Automatic bitrate control — whatever it takes comes off the top. The host now picks quality and redundancy together against the session's video bitrate: full quality plus redundancy where there is room, redundancy dropped first as the link narrows, then the quality tier, never below a floor. Update the host. -- **Turn on Sony USB passthrough from a TV.** The DualSense / DualShock USB toggle only ever existed on the touch settings screen, so on an Android TV box there was no way to reach it at all. It now sits on the controller-driven screen beside the Steam Controller toggle. Update the client. -- **Press the host's Steam and Quick Access buttons from the Deck panel.** While a stream is running the panel shows a **Host menus** section with **Steam menu on host** and **Quick access on host**; either one presses that button on the host and closes the Deck's own menu so the host's shows through. Update the plugin and the client. -- **Two new command-line tools.** `punktfunk discover` lists the hosts on your network with their addresses, whether you have already saved them and whether you are paired, with a `--json` mode for scripts. `punktfunk launch --request-access` is the Request access flow above from a terminal, for admitting a headless machine without a PIN. Update the client. -- **Hosts on a jumbo-frame network can opt into much larger video packets.** On a LAN deliberately configured end to end for 9000-byte frames, the host can send roughly six times fewer packets per frame. It is off by default, is only applied after the host has proven the path really carries them and the client has agreed, and it reverts on its own if those packets start disappearing. This is not a general speed-up: on an ordinary network it does nothing. +- **Native GPU video decoding on Linux and Windows.** The desktop apps drive Vulkan Video, Direct3D and VAAPI themselves, with a CPU fallback, and no longer ship a media library at all. Choosing a decoder works exactly as before and your saved preference is migrated. Two real changes: there is no software HEVC decoder any more — none exists that we can ship — so a machine whose graphics card cannot decode HEVC now reconnects on a codec it can finish instead of crawling on the processor; and AV1 is only requested where your card can genuinely decode it, rather than anywhere a processor decoder happened to exist. Update the client. +- **Your DualSense's own haptics and speaker, carried from the host.** The fine-grained feedback in the grips — as distinct from the coarse rumble motors — now crosses the stream to the controller in your hands. Needs a DualSense or DualSense Edge **plugged in by USB** (over Bluetooth the pad exposes no audio device at all), a **Windows host with Steam**, and either the Android app or the desktop session client. Anywhere else nothing changes. A game that only uses ordinary rumble keeps rumbling exactly as before. +- **The desktop console answers a mouse and a touchscreen.** Nothing in it had ever been clickable: the button bar is now live on every screen, Tab changes section, and right-click is Back. Saved host tiles gain a menu on Up — Wake, Copy link, Edit and Forget — so a renamed machine or a mistyped address can be fixed without leaving the console. +- **Hold Select to press the host's Guide button.** Hold it on its own for about a third of a second and the host sees its Guide button go down, and stay down while you hold — so a longer hold reads as a long-press, which is how a big-screen host opens its Quick Access Menu. A quick tap still goes to the game. On by default on iPhone, iPad and Apple TV, where the system keeps the controller's own Home press for itself; off elsewhere, where the raw press already reaches the host. +- **One "Game sources" list, with a Launchers row above your games.** Every source — Steam, Lutris, Heroic, Epic, GOG, Xbox, or one from an add-on — is now a row with the same controls, and add-on sources show a Running or Stopped badge. Entries can be marked as opening a launcher rather than a game, and those group above the grid instead of getting lost among your titles. +- **Get onto a host by asking instead of typing a PIN.** From the Steam Deck panel, tapping a locked host offers **Request access**: the stream waits while someone at the host approves your Deck, then comes up by itself. +- **Pin a settings profile to a host from a controller.** Every controller-driven settings screen gains a Profiles section; on Apple TV this is the only profile management there has ever been. Creating and editing profiles is still a desktop or phone job. +- **A safe-area resolution that keeps the picture out of the notch**, instead of streaming pixels hidden behind the camera housing and under rounded corners. iPhone, iPad and Android. +- **Your phone's motion sensors can act as the gyro for a controller that hasn't got one** — for clip-on pads. Off by default, iPhone/iPad and Android phones. +- **Two new command-line tools**: `punktfunk discover` lists the hosts on your network and which you have saved, and `punktfunk launch --request-access` gets a headless machine admitted without a PIN. Android also gains **Copy link** on a host card, matching the other clients. +- **A lost audio packet is rebuilt exactly** rather than papered over, and hosts on a jumbo-frame network can opt into much larger video packets. Both need 0.25.0 on each end. ## Improved -- **Desktop audio is encoded at roughly double the bitrate.** Streamed sound now runs at 256 kbps in stereo rather than 128 kbps, which costs about one percent of what the video is already using. Because Punktfunk sends very short audio frames to keep latency down, the old rate was leaving real quality on the table — most audibly on music. Update the host; every existing client already plays whatever arrives. -- **The black screen now heals in seconds, mid-stream.** With 0.25.0 on both ends, a host that detects a constrained network path re-sizes the video packets of the session you are already in, a few seconds after diagnosing it — the picture simply appears, without you reconnecting. With a 0.25.0 host and an older client you still get the fix below: the session in progress stays black, but the next connection is sized correctly and works. -- **Hosts you reach over a VPN show as online on the Steam Deck.** The panel's list merges what it finds on the network with the hosts you have saved and probes the saved ones directly, so a box that never advertises itself — over Tailscale, or on another subnet — reads as up instead of unreachable. Rows sort online first, then most recently streamed. -- **Waking a sleeping host from the Deck waits for it properly.** The panel used to send the wake-up and then guess how long to wait before dialling. It now waits for the host to actually answer. -- **Two new troubleshooting sections on audio.** One explains what the host actually captures and why streamed sound can be worse than what you hear on the host itself — naming the Steam Streaming Microphone trap explicitly and showing the log line that identifies it. The other covers audio that lags the picture, why it should now correct itself, and what to check when it does not. -- **The Android stats overlay stops charging your screen's own delay to the stream.** Its headline latency used to include the time Android itself takes to put a finished frame on the panel — a floor no streaming app can undercut, and easily over 30 ms on a 60 Hz phone. That now sits on its own line instead of inside the headline, matching how the iPhone, iPad and Apple TV clients have always reported it. **Your stream is exactly as fast as it was** — the headline number gets smaller because it finally measures only the part Punktfunk controls, which also makes it comparable across devices. The floor is measured on your device rather than assumed, and the full unshaved figures are still in the client's log. Update the client. +- **The controller interface gets a full visual refresh.** Settings split into named sections you flick between, twelve selectable palettes that the whole interface takes its colours from, and screens that slide in place instead of covering each other. On the Steam Deck, TV and handheld interfaces. +- **Desktop audio is encoded at roughly double the bitrate** — 256 kbps stereo rather than 128 — which costs about one percent of what the video already uses and is most audible on music. Update the host; every existing client already plays whatever arrives. +- **The black screen on VPN-shaped networks heals itself in seconds, mid-stream**, with 0.25.0 on both ends, instead of needing a reconnect. +- **The Steam Deck plugin is now a launcher into the app** rather than a second client — see the section below, because some things moved. +- **The Android stats overlay stops charging your screen's own delay to the stream.** Your stream is exactly as fast as it was; the headline number gets smaller because it finally measures only the part Punktfunk controls. +- **Every session now says why it ended**, so quitting a game you launched is no longer reported as trouble, and quitting takes you back to the library you launched it from. ## Fixed -- **A host on a network that carries smaller packets than usual no longer streams a permanent black screen.** Everything small got through — the connection, your input, your sound — while every single video packet was slightly too big for one hop and died silently. The result connected fine, reported zero packet loss on the client, showed every gauge green on the host, and displayed nothing at all, with nothing written to either log to say why. The host now measures what the path to each client can really carry, warns with the actual diagnosis when it cannot carry full-size video, and remembers the measurement so the next connection from that client is sized to fit. The usual cause, and the one the warning names, is a VPN or overlay network adapter claiming the route. Update the host — this works with every client already out there. -- **Audio that falls behind the picture pulls itself back.** Every client kept a small buffer to absorb network jitter, and that buffer could only ever grow: one burst of Wi-Fi interference, one stutter on the host, or simply two devices' clocks running at fractionally different speeds pushed sound permanently behind the video, and the only cure was reconnecting. Android was worst, with no correction at all — it settled at its ceiling and stayed there for the whole session. All four clients now trim the buffer back a few milliseconds at a time under a crossfade, which is inaudible. Update the client; an older one keeps drifting no matter how new the host is. -- **The host stopped pushing your whole desktop mix through Steam's voice channel.** On a PC with Steam installed, the host was capturing Steam's Streaming Microphone device because it is silent on the host — but that device exists to carry voice, and if Windows had it set to mono or below 48 kHz, the entire game mix was squeezed through it before encoding, where no amount of bitrate could bring it back. A silent device now has to prove it can carry full-quality sound before being preferred over real hardware, and if nothing better exists the host says so in the log. Update the host. -- **Sound no longer cuts out over and over when something keeps changing your default playback device.** Some applications re-set the Windows default device every few seconds; each time, the host tore the whole capture down and rebuilt it, which is an audible dropout — one field log shows seven in sixteen seconds. The host now restores the default without dropping the stream, and if it happens repeatedly it stops fighting for a minute and says so once. Update the host. -- **Audio the host dropped internally is no longer silently glued over.** When the encoder fell behind, captured sound was discarded with nothing recording it: you heard a click, and everything after it stayed permanently shifted. Those drops are now counted and warned about, so a quiet host, a broken device and a stream damaging itself no longer look identical. Update the host. -- **Automatic bitrate stops sawtoothing when your device's decoder, not the network, is the limit.** The control loop has a mechanism for learning "this device cannot decode much past here, stop trying", and in practice it never once fired — one recording at 1440p120 shows it swinging between 220 and 450 Mb/s for nine solid minutes without ever learning the lesson. Three separate reasons it was unreachable are fixed, including one where a struggling decoder repeatedly asking for a fresh picture on an otherwise clean link was blamed on the network. Update the client. -- **Rumble stops fading in and out on a Steam Deck.** The Deck's motors need a fresh instruction every 40 ms or the repeat is discarded, and renewals kept colliding with that, stretching the real gap between motor writes to two and a half times what it should be — so sustained rumble came through weak and uneven. Update the client. -- **Rumble survives a hiccup instead of dying for the rest of the session.** On Android a single failure from the phone's vibration service silently killed the thread driving rumble, with nothing to notice or restart it, so rumble was gone until you restarted the app. And on every client, a stop instruction that never reached the controller used to be assumed to have worked — over USB there is no firmware timeout behind that, so a dropped stop left the motors running with nothing scheduled to try again. Update the client. -- **Two DualSenses stop rumbling for each other.** With two connected to an iPhone, iPad, Mac or Apple TV, both could end up driving the same physical controller, so one player's rumble came out of the other player's pad and the two fought over it. Each now drives its own. Very light rumble also stopped vanishing on that path — anything under about half a percent was being rounded away to nothing. Update the client. -- **A controller is handed back to you neutral when the stream ends.** Trigger resistance, lightbar colour and player lights live in the controller's own firmware, so they outlast the stream, the app, and even unplugging. Ending a session while a game held a weapon's trigger resistance left that trigger physically stiff on your desktop afterwards, with the lightbar still showing the game's last colour. Every client now releases both triggers, darkens the lightbar and clears the player lights on the way out — including on the exit paths that previously skipped it and left the pad buzzing after the stream was gone. Update the client. -- **A dropped lightbar or trigger change repairs itself instead of sticking.** These were sent once, when they changed, over packets that can be lost — so one lost packet could strand a controller on the previous weapon's trigger effect, or the last scene's lightbar colour, potentially for the rest of the level. The host now re-sends the current state once a second to repair it. Update the host. -- **A cut-off packet no longer cancels a trigger effect a game is holding.** A truncated adaptive-trigger packet decoded as an empty effect, and an empty effect is exactly what a controller reads as "let go" — so a weapon's resistance could silently vanish mid-fight. That shape is now rejected, while a genuine release still works. -- **Player-number lights work on controllers that are not a DualSense.** Xbox pads, Switch Pro controllers and everything else with player lights ignored the host's player number completely, so nothing lit at all. Update the client. -- **A centred stick reads as centred.** When the host presents your controller to games as a DualSense, DualSense Edge or DualShock 4, both sticks' vertical axes sat one step below true centre — a permanent, very slight downward pull, small enough to hide under most games' deadzones but plainly visible to any game reading the raw axis. Triggers on a controller presented as a Steam Deck pad also topped out just short of a full pull, so anything needing a genuine full press could never fire. Both are now exact. Update the host. -- **Two virtual controllers stop corrupting each other's rumble on a Windows host.** When a game drove two pads hard enough for their updates to overlap, two rumble instructions could be written into the same slot and arrive as one garbled instruction, or one could be skipped outright — and a skipped *stop* is the one that hurts, leaving the pad buzzing until a safety timer noticed the game had gone quiet. Update the host. -- **Delayed rumble effects fire at the right moment on a Linux host.** Games that schedule an effect to start after a short delay — routine for older Windows games running through Proton — had it start early and end early by the same amount, because the delay was read and then never applied. An effect still waiting its turn is also no longer cancelled by the idle safety-off before it has been felt. Update the host. -- **A controller driver that failed to attach no longer stalls the stream while the host works out why.** The diagnosis ran a slow system lookup on the very thread feeding controller input and rumble — up to two seconds per affected pad, at exactly the moment a session was already going wrong. It now runs in the background, and because it is off the critical path it can afford to wait long enough to report what it actually found. Update the host. -- **The Steam Deck keeps its trackpad mouse when a stream starts.** Starting a stream killed the built-in trackpad-as-mouse system-wide, and it only returned seconds later when the controller's own firmware watchdog restored it. Update the client. -- **Controller settings you cannot use no longer look live.** With "Forward controllers" off, the rows beneath it have nothing to act on, but on the Windows app and both controller-driven settings screens they stayed fully interactive — so you could sit there changing settings that did nothing. They are now dimmed until forwarding is back on. On Apple devices, starting a stream with forwarding off also stopped claiming every button's system gesture (which took away your screenshot and Home presses) and stopped powering up the controller's motion sensors for a stream that was not forwarding anything. Update the client. -- **HDR works on a Steam Deck straight out of the box.** Streaming an HDR game to a Deck gave a washed-out, tone-mapped picture with the overlay reporting a fall back to SDR. Punktfunk now ships everything it needs to talk to the Deck's Game Mode display pipeline, so a plain install is all it takes — there is no longer a separate piece to install by hand. Honest about what came before: that manual step was documented only in a comment inside the packaging, and even people who found it still got SDR, because the layer loaded and looked healthy while silently never engaging. One thing is still yours to do: HDR has to be switched on in Steam's own display settings, or nothing on the Deck gets it. Update the Flatpak client. -- **The Windows client saves your settings when it isn't installed on the C: drive.** On a PC set to install new apps to a second drive, the client streamed perfectly and then quietly forgot everything on restart — settings, connection profiles and your saved hosts all came back empty each launch, while the app showed the toggle you had just moved as though it had stuck. Every save was failing silently. Saving now works on those installs, and if the folder genuinely can't be written the client says so in a banner naming it rather than pretending. An update you declined also used to be offered again forever on these installs, for the same reason; that is fixed too. **You will need to set your preferences once more — nothing can be recovered, because it never reached the disk — and this time they will stay.** Update the client. -- **Recovering from a brief freeze no longer makes it worse.** When the host stalled for a moment — some AMD systems do this when a display drops to standby — the very large catch-up frame was pushed out in one burst that overflowed the network buffer and was lost, costing another round trip and another freeze. That frame is now sent at a pace the connection has already proven it can carry. Update the host. -- **A momentary stall no longer pins your stream at a low bitrate for minutes afterwards.** A window in which almost nothing arrived looked, to the quality logic, exactly like your device's decoder giving up — so it recorded a ceiling that was never real and then spent minutes climbing back toward it. Nearly-empty windows are no longer treated as evidence about your decoder. The stream still backs off for genuine trouble; it just stops drawing that conclusion from an interruption. Update the client. -- **The Steam Deck panel shows host names instead of addresses.** A host you saved by typing its address in was listed as that address, printed twice — once as the title and once underneath. Saved hosts that are online now show the name the machine actually advertises, and a name you chose yourself still wins and is never overwritten. Update the plugin. -- **"Recreate shortcuts" on the Deck actually recreates them.** After a plugin reinstall the Punktfunk entry could vanish from your Steam library and never return: the plugin always believed the old entry still existed, so recreating it reported success while doing nothing, and "Open Punktfunk" answered with "Game configuration unavailable". A stale entry is now detected and rebuilt on the next launch. The plugin also lists itself in Decky as "Punktfunk", capitalised properly. Update the plugin. -- **A leftover folder from an uninstalled Sunshine or Apollo is no longer treated as a conflict.** Both uninstallers leave a settings folder behind, and Punktfunk counted any trace at all — a leftover folder, a file on disk, a registered but switched-off background service — as a live clash. Affected machines warned on every start and showed a red card in the web console reading that another streaming server was running, when nothing was. Only a server that is genuinely running, or set to start on its own, counts now; the console names exactly what it saw, and leftovers appear in the full report under a heading saying they need no action. Update the host. -- **A crashed host gives you your screen back.** In Exclusive display mode the host switches your own monitors off for the length of a session and back on when it ends. If the host crashed or was killed mid-session that never happened — the desk simply stayed dark, no timeout brought it back, and the way out was Windows' own display shortcut or a reboot. The host now records which screens it is about to switch off before switching them off, and forces every connected display back on the next time it starts. Recovery happens at that next start, not on a timer: if the host stays down, the screen stays dark until it runs again. Update the host. -- **Camera look survives pressing Escape on an iPad.** Pressing Escape mid-stream made iPadOS hand the pointer back to the system, and Punktfunk never took it back. Clicks kept landing exactly where you aimed, so input looked fine — but the game stopped receiving mouse movement, so camera look was dead for the rest of the session. Clicking back into the video now takes the pointer again, and if the system refuses the first time, the next click tries again. Update the client. -- **"Open log folder" on Windows opens the log folder.** On installed builds it opened your Documents folder instead: the path the client handed to Explorer was correct to write to but did not exist as a real folder, and Explorer quietly fell back. The same wrong path appeared in the startup line naming the log file and in the message shown when a session fails to start. All three now point at the real folder. Update the client. +- **AV1 at 4K shipped half of every frame.** On an NVIDIA host every 4K AV1 frame left with only its top half — present since AV1 was first wired up. NVIDIA's own decoder quietly tolerated it, so it looked fine on some setups and was a black screen on others. The encoder still uses every engine on the card. Update the host; no client can work around it. +- **HDR and 10-bit streams left half the encoder idle.** A rule written from one old measurement disabled split-frame encoding for every 10-bit stream, capping high-refresh HDR at around 130 fps and making fast motion look wrong. Re-measured across two generations of NVIDIA cards, splitting won every test. Update the host. +- **Your settings could silently reset to defaults.** A settings file saved from PowerShell carries an invisible marker that made the app quietly start over — and any other damaged settings, saved-hosts or profiles file did the same without a word. Both now load correctly, or say exactly what is wrong. +- **The microphone works again on iPhone, iPad and Mac.** The clients had stopped sending any microphone audio at all — recorded but never reaching the stream, silently and with no error. Separately, game audio on Apple devices clicked and crackled on Wi-Fi that delivers packets in bursts, where every other client sounded clean on the same host. Update the client. +- **Audio that falls behind the picture pulls itself back**, instead of staying late for the rest of the session with reconnecting as the only cure. Android was worst, with no correction at all. Update the client. +- **The host stopped pushing your whole desktop mix through Steam's voice channel** on PCs with Steam installed, where a mono or low-rate setting squeezed the entire game mix before encoding. Update the host. +- **A host on a network that carries smaller packets than usual no longer streams a permanent black screen** — connected, every gauge green, and nothing on screen, with nothing in either log. The host now measures what the path really carries and warns with the actual diagnosis. Update the host; this works with every client already out there. +- **Host discovery no longer goes permanently deaf.** A host missed on first launch is found without restarting the app — the usual trigger being the phone's local-network permission prompt, which the browse never recovered from — and every client now has a Refresh or Rescan control that really re-scans. Update the client. +- **A game you quit could pin the session as "running" until the host restarted**, with ending it by hand the only escape. Reconnecting could also launch your game a second time, and cost a reconnected session its game-exit detection for good. Update the host. +- **Controllers, top to bottom.** Rumble that faded on a Steam Deck, died for good after one hiccup on a phone, or kept buzzing after you quit; two DualSenses driving each other on Apple devices; adaptive triggers and lightbars left stuck in a game's last state on your desk after the stream ended; player-number lights that never lit on anything but a DualSense; a centred stick that read slightly off; two virtual pads corrupting each other's rumble on a Windows host. More than twenty faults across every client and both hosts. +- **HDR works on a Steam Deck out of the box.** It gave a washed-out, tone-mapped picture before, and the manual step that was supposed to fix it was documented only inside the packaging — and did not work anyway. HDR still has to be switched on in Steam's own display settings. +- **The Windows client saves your settings when it isn't installed on the C: drive.** It streamed fine and forgot everything on restart — settings, profiles and saved hosts — while showing the toggle you had just moved as if it stuck. You will need to set your preferences once more; nothing can be recovered, because it never reached the disk. +- **Camera look survives pressing Escape on an iPad**, instead of handing the pointer back to the system and leaving aim dead for the rest of the session. +- **Streaming a desktop no longer shows a second, frozen cursor** sitting under the one you are moving. Linux and macOS. +- **Automatic bitrate stops overshooting what your device can actually decode**, and a momentary stall no longer pins your stream at a low bitrate for minutes afterwards. Recovering from a brief freeze also stops making things worse. Update both sides. +- **A leftover folder from an uninstalled Sunshine or Apollo is no longer treated as a conflict**, and a host that crashed mid-session gives your screen back the next time it starts. +- **Add-ons that read a launcher's database returned zero games on every Linux host**, indistinguishable from an empty library. Update the add-on. +- **The Steam Deck panel shows host names instead of addresses**, and "recreate shortcuts" actually recreates them. ## If you stream from a Steam Deck -The Decky plugin has been rebuilt as a **launcher**. It no longer contains a second, separate streaming client; it is now a short list of your hosts plus one button into the Punktfunk app, which has the full controller-driven interface. This makes the plugin far smaller and means the Deck stops having two implementations of everything that could disagree with each other — but some things genuinely moved, and one was removed: +The Decky plugin is now a **launcher**: a short list of your hosts plus one button into the Punktfunk app, which has the full controller-driven interface. Some things genuinely moved, and one was removed: -- **Settings moved** to **Open Punktfunk → Settings**. Same rows, same saved values, still fully controller-navigable. -- **Adding, renaming and forgetting hosts moved** to **Open Punktfunk → Add host**. -- **Browsing a host's games moved** to **Open Punktfunk → Library**. -- **Pinned Games has been removed, with nothing to migrate to yet.** The panel's one-tap "Stream *game*" rows are gone: pinning now works on a host and a settings profile rather than on a game. Your old pin file is deliberately left alone on disk so a later release can migrate it, but in 0.25.0 those rows do not appear. -- **The Deck's Steam and `…` buttons now stay with the Deck.** One press used to open both menus at once, the Deck's own covering the stream, because SteamOS reacts to those buttons whatever the app does. Reach the host's menus with hold-Select, or the panel's new **Host menus** buttons. To restore the old behaviour, set **Open Punktfunk → Settings → Steam / guide button** to **Send to host**. -- **The plugin needs the Punktfunk client on the Deck to be 0.22.0 or newer**, because it drives everything through the client. An older one is detected explicitly and the panel offers the update button that fixes it, rather than silently showing an empty list. +- **Settings, adding hosts, and browsing a host's games** all moved behind **Open Punktfunk**. +- **Pinned Games has been removed, with nothing to migrate to yet.** Pinning now works on a host and a settings profile rather than on a game. Your old pin file is deliberately left alone on disk so a later release can migrate it. +- **The Deck's Steam and `…` buttons now stay with the Deck.** One press used to open both menus at once, with the Deck's own covering the stream. Reach the host's menus with hold-Select or the panel's new **Host menus** buttons; to restore the old behaviour, set **Steam / guide button** to **Send to host**. +- **The plugin needs the client on the Deck to be 0.22.0 or newer**, and detects an older one explicitly rather than showing an empty list. -## Under the hood (for developers) +## For developers -- **Wire protocol 2 — unchanged**, despite substantial growth, because every addition is optional or capability-gated. What grew without a bump: an optional trailing `max_shard_payload: u16` on `Hello` (absent/0 = legacy, and it doubles as both the renegotiation capability flag and the jumbo receive ceiling); two new control messages `ShardPayloadChanged` (`0x08`) and `ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` alongside the plain `0xC9`; a controller-audio plane at `0xD1` (`[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`, which is why `0xD2` skipped that value); and `MAX_DATAGRAM_BYTES` 2048 → 9216. -- **C ABI 14 → 16**, in two steps. **15** is unusual: no code changed and no symbol was added with it. It retroactively versions the shared rumble policy engine's C surface — `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the `PUNKTFUNK_RUMBLE_QUIRK_*` bits — which shipped while the constant still read 7 and never got one, so every core since has exported those symbols while advertising a version that did not promise them. That cannot be fixed retroactively, so 15 is declared as the **floor that guarantees** the surface: at or above 15 it is present, below it an embedder must probe for the symbol. **16** adds the controller-audio surface and mirrors its two capability bits into the C ABI. -- **Breaking for C embedders: 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the header. Fixing it is mechanical — add the prefix, the values are identical — and there is **no silent breakage**: the old spellings cease to exist, so it is always an undeclared-identifier error, never a wrong value. That is precisely the failure it removes, since a colliding `#define` does not fail to compile; the preprocessor silently takes the last definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at runtime. Associated constants are untouched — the generator already qualifies those with their type name. Scheduled for a release boundary deliberately; nothing in-tree used the old spellings but one Swift test, updated in the same commit. -- **Four new capability bits, and the video-caps byte did not overflow.** In the handshake's client/host capability bytes: client `0x04` / host `0x20` for the redundant desktop-audio plane ("can decode it" / "is sending it"), and client `0x08` / host `0x40` for controller audio (`CLIENT_CAP_PAD_AUDIO` / `HOST_CAP_PAD_AUDIO`, mirrored into the C ABI as `PUNKTFUNK_*` and asserted equal to their wire twins). The video-caps byte still carries exactly the eight bits it carried at 0.24.0 — no ninth cap, no second byte, so nothing forced an ABI bump from that direction. -- **Unchanged:** virtual-display driver protocol 6 (minimum accepted 3) and the Windows virtual-gamepad channel 3 — `crates/pf-driver-proto` is byte-for-byte identical to v0.24.0. -- **Adaptive-trigger effects are now length-bounded** on both encode and decode against one shared constant, with the header emitting `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value, so the struct layout is byte-identical). A zero-length effect body is rejected rather than decoding as an empty — that is, a release — effect. Out-of-range pad indices are now dropped before either rumble consumer sees them; the reorder gate bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it would use to subscript its own array. The client also clamps the host's rumble lease receive-side at 5 s, where the existing ceiling was sender-side only. -- **Windows pad drivers publish their sequence counters with release ordering** (the host was already loading with acquire and pairing with nothing) and serialize the output-ring publish. The `/dev/uhid` event ABI, previously transcribed verbatim into all five Linux gamepad backends, is consolidated into one module with tests on the two accessors that had already drifted. -- **The controller-audio plane in detail.** `0xD1` carries one Opus frame per datagram behind a 15-byte header, with `PAD_AUDIO_KIND_HAPTICS = 0` (the pad's BACK channel pair — the voice coils — at 5 ms frames) and `PAD_AUDIO_KIND_SPEAKER = 1` (the FRONT pair, 10 ms). Best-effort like every audio plane: loss shows up as a sequence gap concealed by the gap tracker, and silence is a frozen sequence under the same mic-mute discipline, with the host gating at −60 dBFS on a 250 ms hangover. Alongside it, `HidOutput::AudioCtl` is a new `0xCD` kind `0x06` carrying the DualSense output report's volume/routing bytes, change-only and value-deduped — an older client drops it as an unknown kind. A client advertises per-pad intent through two new arrival flags (`1 << 8` haptics, `1 << 9` speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host. **Capability-byte pressure is now worth watching:** `client_caps` has four bits free, but `host_caps` is down to its last one (`0x80`), and `video_caps` remains full from 0.23.0 — the standing "next video cap needs a second byte and an ABI bump" note still stands. -- **The controller-audio host gate is Windows-only and Steam-dependent.** `host_cap()` returns false unconditionally off Windows, and on Windows it still requires provisioning to have published at least one endpoint, which requires Valve's driver. The client only advertises its capability if a setting would actually render something, so a user with both toggles off never causes the host to provision anything. Note a real inconsistency to reconcile: the desktop session client defaults `pad_speaker` to `"pad"` (on) while Android defaults its speaker toggle to off, and the desktop side exposes these as serde-defaulted JSON keys with no settings UI at all. `pad_speaker = "mix"` is a declared TODO that logs once and behaves as `off`. The GameStream/Moonlight path always reports no pad-audio capability. -- **New host environment settings.** Controller audio: `PUNKTFUNK_PAD_AUDIO` (on unless set to `0`), `PUNKTFUNK_PAD_AUDIO_SLOTS` (default 1, max 4 — multi-pad needs an operator to raise it), and `PUNKTFUNK_PAD_AUDIO_STAMPS` (debug bisect hook), plus a `punktfunk-host pad-endpoint ensure|remove|status` devtest command. Audio: `PUNKTFUNK_AUDIO_QUALITY` (`low`/`standard`/`high`, default `high` = stereo 256 kbps; `standard` reproduces the pre-0.25 encoder exactly for an A/B, and a typo warns once rather than silently downgrading), `PUNKTFUNK_AUDIO_REDUNDANCY`, and `PUNKTFUNK_AUDIO_OUTPUT_MODE` (`client_only`/`host_and_client`/`follow_default`, default `client_only`, **Windows host only**). The legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to `host_and_client` and `follow_default`; `follow_default` wins if both are set. Wire: `PUNKTFUNK_WIRE_MTU` (pins on-wire IP MTU for all sessions; a value above 1500 also enables jumbo) and `PUNKTFUNK_JUMBO=1` (fixed 9000-MTU profile). All are documented on the troubleshooting page, not yet in the configuration reference. -- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those sessions get the next-session clamp only, and are excluded from jumbo. The decode-cap latch fix likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design. -- **The Deck plugin's Python backend is now four thin shells over the `punktfunk` CLI** (`discover`, `hosts list --probe --json`, `pair`, `hosts add`); it parses no client data files and re-implements no client rules, and an outdated client reports itself deterministically as exit 5 + `unknown command ""` rather than being inferred from GTK startup noise. Host identity is matched by fingerprint first and address second in exactly one place, so a host that changed DHCP lease still matches its record while a different box inheriting the address does not inherit its pairing. `KnownHosts::read()` was split out of `load()` so `discover` can annotate against the store without minting-and-saving ids, which two parallel invocations could otherwise race. -- **The hold-Select gesture is one state machine** with unit tests in the shared client core, re-implemented to the same rules in the Apple capture layer and Android's router. A tapped Select is delivered on release with its release scheduled 50 ms behind, because a back-to-back down+up can otherwise fold into a single sequenced snapshot and vanish. `punktfunk-session` gained a per-user Unix control socket (`$XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock`) with two verbs, `guide` and `qam` — the one runtime path a flatpak and the outside-the-sandbox Decky backend see identically. -- **Origin isolation for plugin UIs.** A second listener (default `PORT + 1`, `PUNKTFUNK_UI_PLUGIN_PORT`) serves `/plugin-ui/**` and nothing else; the console origin refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different origin (scheme+host+port) so same-origin policy *is* the boundary, same site so the `SameSite=Lax` session cookie still flows. Bind failure disables plugin UIs rather than falling back. `x-pf-listener` is stripped inbound and set by the entry, active ports are republished as `*_PORT_ACTIVE`, the plugin origin's CSP names the console as its only `frame-ancestors`, and the proxy allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`. ⚠ The kit's `postMessage(..., "*")` is now load-bearing — narrowing it to `location.origin` would target the plugin's own origin and drop every message. -- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live route table and **fails the build on any unclassified route** — the "block these" list it replaces let new endpoints through silently. Field authority is tracked separately from route reachability: requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are operator-token only. Art serving gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before* canonicalization so `%2e%2e` cannot hide; roots default to the Windows users base and, new on POSIX, `$HOME` (`PUNKTFUNK_LIBRARY_ART_ROOTS`). Validation also runs at write time, so an unservable path can no longer be persisted. -- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2 `{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation. `PUT /library/provider/{p}?store=` claims a store; entries then surface as `:` rather than `custom:`, so entry ids, GameStream app ids, client art caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is held the matching built-in scanner is skipped, so the two never double-list. `GET/PUT /library/scanners` is now a sources endpoint over the same disabled-set file. New entry fields: `role: game|launcher`, and launch kinds `steam_ui` (`bigpicture|desktop`) and `launcher_ui` (platform-gated, 400 on invalid). Plugin kit 0.3.0 adds a `./library` subpath — `defineLibraryPlugin`, ported total parsers (text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable SQLite, a registry wrapper that refuses HKCU, path-confinement joins), and `GET/PUT /__config` so a plugin with settings need not ship an SPA. -- **Build-container images now push to an authenticated registry endpoint**, and `:latest` is reconciled against the content key on every push to main — an out-of-band tag move is detected and repaired rather than silently inherited. This also fixes a long-standing bug where reverting a CI change left `:latest` pointing at the newer build forever. -- **The client's config writer** falls back to an in-place write when the atomic replace is unavailable, verifies it by reading the bytes back, and records the last persistence failure centrally so the UI can surface it. Scratch files are now per-process, closing a real collision between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one could previously rename its half-written temp over another's target. The update-check bookkeeping was hand-rolling the same dance and now goes through that one writer. -- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at the pacing rate with a 100 ms absolute ceiling instead of being compressed into a single frame interval. Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment escape hatch, and the GameStream-compatible path is untouched. The ABR decode-cap latch now ignores windows that delivered under a quarter of target without erasing a reference a genuine choke had set. -- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream revision as the host's own packaged build, so client and host come from one tree — bump both together. It is ~4 MB of app content in place of a 94 MB external extension users had to fetch themselves, and Flathub is no longer needed at install time. The old search-path override was deliberately dropped so two same-named layers cannot both load. -- **Verification is build-level.** Clippy and test gates on Linux, the Windows runner and macOS; the desktop-audio, packet-sizing and iPad pointer work has not been confirmed on glass in these commits. **Controller audio in particular has never run on a real DualSense** — it is a hardware feature whose entire verification to date is unit tests and compile checks, and its rumble arbitration rests on an explicitly retracted assumption about whether the voice coils and the rumble motors are the same actuators (the evidence-based 500 ms idle window is correct either way, but the underlying exclusivity is unsettled). Android's arbiter is the evidence-based one; the desktop twin and the coil restore on Android's stop path are both still owed. Some Android OEM kernels also refuse the isochronous claim outright, which degrades to ordinary rumble and is reported by the self test. Three more things in this release are reasoned-and-tested rather than observed: the plugin-UI origin split is validated against a fake console and a fake plugin, not yet in a real browser; the packaging default-on changes have had no installer run or package build; and no launcher tile has been clicked on a real host, the first source that would publish one not existing yet. +Protocol, ABI, driver and embedder detail — the version table, all four breaking changes, capability-bit pressure, the new decode crates and every environment variable — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.25.0/CHANGELOG.md). + +Two things there that you should not learn by surprise: **149 previously unprefixed macros in the C header are now `PUNKTFUNK_`-prefixed** (mechanical to fix, and it cannot break silently), and the **C ABI moved 14 → 17** while the wire protocol stayed at 2. diff --git a/docs/releases/whatsnew/v0.25.0.txt b/docs/releases/whatsnew/v0.25.0.txt index ba39e8aa..76e7f973 100644 --- a/docs/releases/whatsnew/v0.25.0.txt +++ b/docs/releases/whatsnew/v0.25.0.txt @@ -1,6 +1,6 @@ -• New: a DualSense plugged in by USB can play the host's fine-grained haptics through the pad itself. Needs a Windows host with Steam installed. +• New: a USB DualSense can play the host's fine-grained haptics through the pad itself. Needs a Windows host with Steam. • Sound that drifts behind the picture now catches itself up instead of staying late all session. +• New: a safe-area resolution keeps the picture clear of the notch and rounded corners. • Rumble no longer dies for the rest of the session after one glitch. • Automatic bitrate stops overshooting what your device can really decode. • Hold Select to reach the host's Guide menu. -• Pin your settings profiles to hosts from the TV interface.