diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..248160ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,699 @@ +# Changelog + +Protocol, ABI, driver and embedder detail, one section per stable release, newest first. + +This is the **technical** half of a release. The other half — what changed for people who *use* +Punktfunk — is `docs/releases/vX.Y.Z.md`, and it deliberately contains no internal names. The two +were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown +long enough to bury the user-facing half it was appended to. See `docs/releases/README.md`. + +If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start +with the version table of the release you are moving to, then read **Breaking changes**. + +--- + +## v0.25.0 + +407 commits since v0.24.0. + +### Versions + +| | v0.24.0 | v0.25.0 | Notes | +|---|---|---|---| +| Wire protocol | 2 | **2** | unchanged — every addition below is optional or capability-gated | +| C ABI | 14 | **17** | three steps; see below | +| Workspace crate dirs | 22 | **26** | `pf-bitstream` (+ vendored `cros-codecs`), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec` added; `pf-ffvk` removed | +| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) | +| Windows virtual-gamepad channel | 3 | **3** | unchanged | +| Plugin index schema | 1 | **1** | unchanged | +| `api/openapi.json` | 0.23.0 | **0.24.0** | tracks API edits, lags one release by convention | + +`crates/pf-driver-proto` is byte-for-byte identical to v0.24.0 — if you ship the virtual-display +driver or the gamepad channel, nothing in this release touches you. + +**Why the wire did not move.** It grew a lot and still did not break: an optional trailing +`max_shard_payload: u16` on `Hello` (absent/0 = legacy, doubling as the renegotiation capability +flag and the jumbo receive ceiling); two control messages `ShardPayloadChanged` (`0x08`) and +`ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` beside the plain `0xC9`; a +controller-audio plane at `0xD1`; a new `0xCD` kind `0x06`; arrival flag bits 8/9; and +`MAX_DATAGRAM_BYTES` 2048 → 9216. Old peers never send or read any of it. Bump `WIRE_VERSION` only +when the handshake or planes change *incompatibly* — riding a C-ABI bump onto the wire once locked +every new client out of every deployed host (`ABI mismatch: client 3 host 2`, observed live). + +### C ABI 14 → 17 + +- **v15 — the rumble policy engine's C surface.** `punktfunk_connection_next_rumble_cmd`, + `punktfunk_connection_set_rumble_quirks`, `PUNKTFUNK_RUMBLE_QUIRK_*`. These symbols are **not + new**: they landed while the constant still read 7 and no bump was made, so every core since has + exported them while advertising a version that never promised them. A shipped binary says what it + says, so this cannot be corrected retroactively — **v15 is the floor that guarantees them.** At or + above 15 the surface is present; below it, probe for the symbol. No code changed with this bump. +- **v16 — the controller-audio client surface.** `punktfunk_connection_next_pad_audio` (the `0xD1` + per-gamepad DualSense haptics/speaker plane), `punktfunk_connection_set_pad_audio_caps`, and the + `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. +- **v17 — session end reason.** `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` + vocabulary: after a session ends, ask *why* — this client closed it, the host's launched game + exited (its close carried `APP_EXITED_CLOSE_CODE`, which the host had been sending for a long time + with nothing consuming it), the host ended it cleanly, the host reported a failure, or the + connection was lost. Purely a read of state the core already had: **no new call is required of an + embedder**, a client that never calls it is unchanged, and the host sends identical bytes either + way. + +### ⚠ Breaking changes + +**1. 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in +header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and +the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the +header. + +*What to do:* add the prefix. Values are identical; the change is mechanical. + +*It cannot break silently.* The old spellings cease to exist, so this is always an +undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A +colliding `#define` does **not** fail to compile: the preprocessor silently takes the last +definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at +runtime. Associated constants are untouched; the generator already qualifies those by type name. + +**2. Linux hosts: the virtual Steam Deck controller moved to its own `punktfunk` group.** The +capability rode on `input`, which every gamepad guide tells users to join — but it can emulate +arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops +attaching. Ordinary virtual gamepads are unaffected. + +**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a +shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that +populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead. + +**4. Plugin UIs moved to their own origin** on a second listener (default `PORT + 1`, +`PUNKTFUNK_UI_PLUGIN_PORT`). Reverse proxies and firewalls must forward that port; a self-signed +console needs it trusted separately. + +### Capability bits + +Four added, all in the handshake's client/host capability bytes: + +| Bit | Constant | Meaning | +|---|---|---| +| client `0x04` | `CLIENT_CAP_AUDIO_RED` | can decode the redundant desktop-audio plane | +| host `0x20` | `HOST_CAP_AUDIO_RED` | is sending it | +| client `0x08` | `CLIENT_CAP_PAD_AUDIO` | can render controller audio | +| host `0x40` | `HOST_CAP_PAD_AUDIO` | is sending it | + +⚠ **Pressure worth watching:** `client_caps` has four bits free; **`host_caps` is down to its last +one (`0x80`)**; `video_caps` has been full since 0.23.0 (`VIDEO_CAP_MULTI_SLICE = 0x80`). The next +video capability needs a second byte *and* an ABI bump — plan for it rather than discovering it. + +### Wire planes + +- **Controller audio, `0xD1`** — `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`, + one Opus frame per datagram behind a 15-byte header. `PAD_AUDIO_KIND_HAPTICS = 0` is the pad's + BACK channel pair (the voice coils) at 5 ms frames; `PAD_AUDIO_KIND_SPEAKER = 1` is the FRONT pair + at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker, + silence is a frozen sequence under the mic-mute discipline, host gating at −60 dBFS with a 250 ms + hangover. `0xD2` (redundant desktop audio) deliberately skipped `0xD1` to reserve it for this. +- **`HidOutput::AudioCtl`** — `0xCD` kind `0x06`, carrying the DualSense output report's + volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind. +- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host. +- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant; + the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value, + so the struct layout is byte-identical). A zero-length effect body is now rejected rather than + decoding as an empty — that is, a *release* — effect. +- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate + bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it + would use to subscript its own array. The client also clamps the host's rumble lease receive-side + at 5 s, where the ceiling had been sender-side only. + +### Host environment variables + +| Variable | Default | Notes | +|---|---|---| +| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. | +| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows | +| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` | `client_only`/`host_and_client`/`follow_default`. **Windows host only.** | +| `PUNKTFUNK_PAD_AUDIO` | on | `0` disables controller audio host-wide | +| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1` | max 4; multi-pad needs an operator to raise it | +| `PUNKTFUNK_PAD_AUDIO_STAMPS` | unset | debug bisect hook | +| `PUNKTFUNK_WIRE_MTU` | unset | pins on-wire IP MTU for all sessions; above 1500 also enables jumbo | +| `PUNKTFUNK_JUMBO` | unset (off) | fixed 9000-MTU profile | +| `PUNKTFUNK_UI_PLUGIN_PORT` | `PORT + 1` | the plugin-UI origin | +| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` | +| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. | +| `PUNKTFUNK_VAAPI_DEVICE` | client | **new** — pin the VAAPI render node | +| `PUNKTFUNK_DUMP_VIDEO` / `PUNKTFUNK_AU_DUMP` | client | **new** — capture exact decoder input / the AU as it arrived from the host | +| `PUNKTFUNK_AU_FAULT=drop\|truncate\|flip[:period]` | client | **new** — deliberate decoder-input corruption for recovery testing; native rungs only | +| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1` | host | **new** — opt-in live split-encode arbitration (Linux-wired) | +| `PUNKTFUNK_NO_AUDIO_MINT` | host (Win) | **new** — opt out of minted endpoints; restores the name ladder | +| `PUNKTFUNK_GPU_PRIORITY` | host (Win) | **removed** — superseded by `PUNKTFUNK_GPU_PRIORITY_CLASS`, a strict superset | +| `PUNKTFUNK_FFMPEG_LOG` | client | **removed** with the av_log machinery | + +Legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to +`host_and_client` and `follow_default`; `follow_default` wins if both are set. New devtest command: +`punktfunk-host pad-endpoint ensure|remove|status`. + +### Security + +- **Origin isolation.** A second listener serves `/plugin-ui/**` and nothing else; the console origin + refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different + origin (scheme+host+port) so same-origin policy *is* the boundary; same site so the `SameSite=Lax` + session cookie still flows. Bind failure disables plugin UIs rather than falling back. + `x-pf-listener` is stripped inbound and set by the entry; active ports republish as + `*_PORT_ACTIVE`; the plugin origin's CSP names the console as its only `frame-ancestors`; the proxy + allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`. + ⚠ The kit's `postMessage(..., "*")` is **load-bearing** — narrowing it to `location.origin` would + target the plugin's own origin and drop every message. +- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of + permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live + route table and **fails the build on any unclassified route** — the block-list it replaces let new + endpoints through silently. Field authority is tracked separately from route reachability: + requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are + operator-token only. +- **Art serving** gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC + refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before* + canonicalization so `%2e%2e` cannot hide. Validation also runs at write time, so an unservable path + can no longer be persisted. + +### Native decode — FFmpeg is gone from the client + +268 files, +129k / −25k. `cargo tree -p punktfunk-client-session` finds zero `ffmpeg`. **The host +keeps `libavcodec` unconditionally** (pf-encode); no host workflow, packaging script or licence file +was touched. + +| Platform | v0.24.0 | v0.25.0 | +|---|---|---| +| Linux desktop | ffmpeg-next: Vulkan hwcontext (`pf-ffvk`) → VAAPI → libavcodec sw | `pf-vkdecode` (ash, presenter's own `VkDevice`, zero-copy) → `pf-vaadec` (dlopen'd libva, DRM-PRIME dmabuf) → `openh264` + `rav1d` | +| Windows desktop | ffmpeg-next Vulkan → libavcodec D3D11VA half | `pf-vkdecode` → `pf-dxvadec` (plans into `ID3D11VideoDecoder`) → `openh264` + `rav1d` | +| Android | MediaCodec (never had FFmpeg) | unchanged | +| Apple | VideoToolbox (never had FFmpeg) | unchanged | + +**Workspace members:** added `pf-bitstream` (+ vendored `cros-codecs`, compiler-enforced +`unsafe`-free), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec`; removed `pf-ffvk`. **Deleted:** +`video_vulkan.rs`, `video_vaapi.rs`, `video_libav.rs`, the libavcodec half of `video_d3d11.rs`, the +`av_log` machinery, `ffmpeg::codec::Id` as decoder vocabulary, `DecodedImage::VkFrame`/`::Dmabuf`, +the `ffmpeg-fallback` feature, and swscale — and with it the BT.601 default its correction code +existed to undo. + +**Software rung:** `openh264 = "0.9"` (BSD-2) and `rav1d = { version = "1", default-features = +false, features = ["bitdepth_8"] }` (BSD-2). `dav1d-sys` was rejected because it is `system-deps`- +only and would add a system library plus a `.pc` to every client package. `default-features = false` +drops `asm` — rav1d's `build.rs` *panics* without nasm, unlike openh264-sys2, which degrades quietly. +**`bitdepth_8` only** ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before +any byte reaches the decoder. + +**⚠ HEVC has no CPU floor.** An HEVC session that exhausts its hardware rungs tears down and re-dials +advertising HEVC-less caps, and the host picks H.264 (`last_rung_verdict` / `NoSoftwareRung`). This is +a first-class path, not a failure. + +**Rung × codec × hardware evidence** (`native_evidence`) — the admission filter is driven by this, so +an unproven rung yields only to one that is both verified for the codec and usable on the device: + +| Rung | Codecs | Evidence | +|---|---|---| +| `native-vulkan` | H.264, H.265 Main/Main10/4:4:4 | **yes** — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak | +| | AV1 | **yes** — 250/250 bit-identical on one vendor, no soak | +| `native-d3d11va` | H.264, H.265 | **yes** — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak | +| | AV1 | **not proven** — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter | +| `native-vaapi` | H.264, H.265, AV1 | **NO — has never decoded a frame anywhere**; no VAAPI hardware was reachable | +| `software` | H.264 (openh264), AV1 (rav1d) | **not proven**; openh264 has never run on glass. No HEVC at all. | + +Vendor order (unchanged): Linux NVIDIA/AMD `vk → vaapi → sw`; Linux Intel/unknown +`vaapi → vk → sw`; Windows NVIDIA/AMD `vk → d3d11va → sw`; Windows Intel/unknown +`d3d11va → vk → sw`. + +**AV1 advertisement** now answers from device facts (`av1_hardware_decodable`: Vulkan `DECODE_AV1` +queue op, or the Windows D3D11 import path) rather than `ffmpeg::decoder::find(AV1)`, which was true +on any build linking libdav1d. **Settings migration:** stored `vulkan`/`vaapi`/`d3d11va` migrate to +`native-*` at decoder construction *and* at each dialog's lookup — the second is load-bearing, since +an unmatched value renders as "Automatic" and a save would silently rewrite the preference. + +### The three decode data-loss bugs + +**AV1 sub-frame truncation — shipped in v0.24.0, host-side.** NVENC sub-frame readback has two halves +armed by *different* conditions: `build_init_params` arms the writer from `subframe_on` alone, while +the chunked reader additionally requires `slices >= 2` — and `resolve_slices` returns `1` for AV1 +unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to +publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header +declaring two tile rows plus a single Tile Group OBU with `tg_start = tg_end = 0`; libdav1d rejected +**835/836** AUs. NVIDIA's *hardware* decoder accepts it (so Vulkan Video looked healthy at 60 fps); +its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half +the picture. Fixed by disarming sub-frame for AV1 while leaving `split_mode` untouched — AV1 keeps +every engine. Arming the reader instead is *not* a drop-in: the reader cuts at +`bitstreamSizeInBytes` on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not. +Post-fix 654/654 clean. The test that had pinned the old behaviour as *correct* is replaced by one +pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison +nothing made. + +**HEVC DPB from the level ceiling — new in this release, client-side.** `dpb_limit` computed +`max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1)`. HEVC equation A-2 is a **ceiling on +what an SPS may legally signal**, not a statement of need, and it branches on picture size against +the *level's* `MaxLumaPs`. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds +L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields +16 frames / **17 slots** — one more than NVIDIA's `maxDpbSlots` of 16 — so every AU fell outside +device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and +there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that +falls through to the honest answer. Fixed to `buffering.min(16)`: the `max()` bought no tolerance, +since `Dpb::needs_bumping` already evicts at the signalled depth — it only over-allocated ten +surfaces per 1080p session. **H.264 escaped by luck** (its ceiling lands at 13 for 1080p) and is left +alone, because H.264's DPB size genuinely *is* level-derived absent a VUI `bitstream_restriction`. + +**rav1d aborts the process — new in this release, client-side.** rav1d 1.1.0 `abort()`s on *any* +decode error while holding one frame context: the `c.fc.len() == 1` branch decodes inline, always +finishes in `rav1d_decode_frame_exit` which unconditionally takes `frame_hdr`, then on `Err` re-enters +an `on_error` whose first act is `frame_hdr.as_ref().unwrap()` on the `None` it just left. The panic +unwinds into `dav1d_send_data`, which is `extern "C"` ⇒ `panic_cannot_unwind` ⇒ `abort()`. **No +`catch_unwind`, no rung demotion and no refusal can catch it**, and every `rav1d_*` entry is +`pub(crate)`, so no in-process guard is possible. 4K was only *where* the first error happened — the +CPU rung does 35–39 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to +live, and the next AU referenced undecoded frames. Fixed by opening with `n_fc >= 2` and asking +`dav1d_get_frame_delay` what the settings actually bought. Decode now drains **past** the first +`EAGAIN`, which is why two frame contexts cost no latency (20–42 ms/unit at `n_fc=2` vs 21–53 at +`n_fc=1`). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204 +frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as **rav1d#1497** +with a reproducer. Does **not** make the CPU rung panic-proof. + +**Settings loader BOM — shipped in v0.24.0, client-side.** `.and_then(|s| from_str(&s).ok())` turned +every parse failure into `Default`. `Set-Content -Encoding UTF8` writes `EF BB BF`, serde_json +correctly rejects at byte 0, and every setting vanished silently. A shared `load_json_or_default` now +strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a +BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately +still `Default`, never an error. + +### Other decode/encode + +- **Intel Arc pNext ordering.** `vkGetPhysicalDeviceVideoCapabilitiesKHR` was called with the codec + caps struct chained *before* `VkVideoDecodeCapabilitiesKHR` (`push_next` prepends). Arc/Windows + fills those two **by position, not by sType**, and returned them swapped — we read a level as a + capability bitmask. Measured A/B: `decode_flags_raw=12 max_level_idc=1` before, + `decode_flags_raw=1 max_level_idc=12` after. NVIDIA and RADV dispatch by sType, which is why the + fleet stayed green. ⚠ **This does not yet give Arc Vulkan Video** — the refusal only moves down: the + device advertises only COINCIDE, and its NV12 coincide entry does not advertise `SAMPLED` usage, + which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint. +- **NVENC split encode.** The 10-bit rule sat *above* the pixel-rate arm and took no codec, so it + vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an + HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and + Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89× — **split wins at every mode on both + architectures, including the configuration the veto came from.** New order: env override → + pixel-rate arm (now taking `max_forced_split_mode(engines)`, not a hard-coded 2) → + HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because **the driver + honours an over-ask and silently encodes narrower**. Also newly logged: HEVC + plain AUTO + + sub-frame is **silently single-engine** — the fleet's default shape, and nothing said so. + ⚠ **Unvalidated consequence:** 5120×1440@240 Main10 now clears the pixel-rate bar and *will* be + forced to split — the exact configuration the old veto came from. `PUNKTFUNK_SPLIT_ENCODE=0` is the + escape. +- **PyroWave on Windows stamped over the host's GPU scheduling policy.** It raised the process WDDM + class to HIGH at every session open, while `auto_priority_gate` already owns that process-wide — + starting at HIGH, *upgrading* to REALTIME once safe, and leaving a monitor that drops back when VRAM + tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave + stamped HIGH back and **orphaned the monitor's decision**. Removed rather than reconciled. +- **A `pf-vkdecode` AV1 use-after-free fix had stabilised the wrong pointer** — + `OwnedStdAv1SequenceHeader` kept the Std struct *inline*, so `pStdSequenceHeader` was a dead stack + address; it worked only because NVIDIA happened to retain `pColorConfig` instead. Std structs are + now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters + assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265. + +### A/V sync — it did not previously exist + +The host has always stamped `pts_ns` on every audio datagram. **Every client decoded it into +`AudioPacket` / `AudioPCM` and never read it.** Video's `pts_ns` was used end to end; audio free-ran +at whatever depth its jitter ring reached; nothing compared them. The A/V offset was an emergent +property of buffer depths — it moved whenever the ring ratcheted under underrun pressure, and it got +**worse every time video got faster**, because a quicker decoder lowers the video leg and leaves +audio's where it was. That is why shaving milliseconds off the audio budget had never helped. + +Two host defects were prerequisites: +- **`pts_ns` was stamped at encode time**, inside the loop draining an already-accumulated chunk, so + every frame of a chunk carried near-identical timestamps describing *when we got round to + encoding*. Now derived from the chunk's arrival instant minus queued-frame duration, re-anchored + per chunk. +- **The host did not pace.** One capture callback hands over a whole quantum (5 ms honoured, **21.3 ms + on a VM**, where stock PipeWire raises `min-quantum` to 1024), drained into back-to-back + `send_datagram` calls — a 4–5 frame burst then ~21 ms of nothing, which a ring could only absorb by + standing a burst period deep. Frames now leave on the audio clock (`FRAME_INTERVAL` 5 ms, + `PACE_MAX_SLEEP` 10 ms, `PACE_REANCHOR` 100 ms). Costs no average latency. + +``` +audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns +av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture) +``` + +`AvSync` EWMAs it (`AV_EWMA_TAU_MS = 2000`), ignores anything inside `AV_DEADBAND_MS = 10`, waits +`AV_MIN_OBSERVATIONS = 100` before a first correction, and **refuses rather than clamps** beyond +`AV_SANE_LIMIT_MS = 1000` — a wall-clock step must not steer the ring. + +⭐ **Video is the master, and continuity outranks sync.** `JitterPolicy::set_sync_target` takes only a +*request*, clamped between the existing underrun-driven adaptive floor and the hard cap: a link whose +jitter genuinely needs more buffer than the picture is away keeps its buffer, and the residual is +reported rather than forced. `None`/`nil` reproduces prior behaviour bit-identically, which is how +the four rings adopted it one at a time. + +Per client: the Rust desktop reference is a new `video_e2e_ns` atomic beside `clock_offset`, written +by the presenter and read by the audio thread. **Android** publishes `OnFrameRendered` — the one +place that knows a frame *latched* — **raw, not floor-shaved** (the HUD shaves the OS present floor; +sound must reach the ear when light reaches the eye), and stays inert below API 33 rather than +substituting the release instant, which targets a future vsync 8–21 ms ahead of glass. **Apple** +publishes its `LatencyMeter` sample as an *expiring level*, because that client has a backgrounded +keep-alive that keeps audio playing while dropping video decode; its clamp raises the ceiling to the +floor rather than `min(max(…))`, which on a device whose callback quantum alone exceeds the hard cap +would otherwise hand back the cap, silently below the continuity floor. + +Escape hatches: `PUNKTFUNK_NO_AV_SYNC=1` everywhere, plus +`adb shell setprop debug.punktfunk.no_av_sync 1` on Android (a launcher-started app inherits no +environment). Observability: `buffer_ms`/`target_ms` had only ever been a `tracing::debug!` line — +and on a Deck the client runs under Steam's `reaper` with stdout on a pipe nobody can read, so the +one number identifying a deep ring was unobtainable *on the device reporting the latency*. Now on the +HUD and in the 1 Hz stats log on every client. + +### Decode-target aliasing — caught before it shipped + +⚠ **None of this ever shipped.** `git ls-tree v0.24.0 crates/` has no `pf-vkdecode`, `pf-dxvadec`, +`pf-vaadec` or `pf-bitstream`; v0.24.0's decode rungs were libavcodec. This was a ship-blocker for +the new stack, cleared — not a field bug. + +Three of the four native rungs released a picture's surface **inside the plan→submission +conversion**, then assigned the decode target a slot. `SlotMap::assign` returns the *lowest free +slot* — the one just vacated. The submission then named one surface as both decode target and its own +reference: `CurrPicTextureIndex == RefFrameMapTextureIndex[k]` on DXVA, or `pSetupReferenceSlot` +sharing an array layer with `pReferenceSlots` on Vulkan. **Decode into the surface you are predicting +from.** + +- **AV1 / D3D11VA** — AV1 applies `refresh_frame_flags` *after* decode (7.20), so "read a slot then + overwrite it" is the ordinary case: **268 of the vendored vector's 274 frames**, first at frame 6. +- **H.264 / both Vulkan and D3D11VA** — `H264Planner` snapshots `dpb_refs` in `begin_picture`, before + 8.2.5 marking and the C.4.5.3 bump, so a picture the sliding window unmarks and the bump evicts + lands in *both* `dpb_refs` and `dpb.removed`. Both conditions coincide only in low-delay H.264 — + and NVENC guarantees it (`max_num_ref_frames = 3` alongside `max_dec_frame_buffering = 3`, plus + `max_num_reorder_frames = 0`). Result: **297 of every 300 access units of every stream a punktfunk + host emits**, at every resolution, on both rungs. +- **H.265 is exempt, now measured rather than argued** — 0 of 120 aliases, with a counterfactual that + moves the snapshot one call earlier and reproduces 115 of 120. +- **VAAPI's exemption was incidental**: the precondition is fully present (117 of 120 AUs) but + `plan_to_va` never invents a surface. That held only because three call sites happened to write + `free_surface()` and `surface_table()` adjacently; `acquire_target` now returns index, surface and + table together so a later edit cannot split them. + +Fix is uniform: the plans grow `release_after_decode`, conversions hand removals back, callers +release once the decode op is issued. Costs no slot (`SlotMap::new` allocates `max_dpb_frames + 1`). +Both rungs hold the `Result` rather than `?`-ing it so the deferred release runs on failure paths — +seven exits sat between conversion and release, each of which would have leaked a slot. + +**Why four gates missed it**, all recorded: the conformance vector is *structurally blind* (level 1.3, +no VUI `bitstream_restriction` ⇒ a 7-frame DPB against 2 reference frames, and it reorders) and +passed 250/250 for two milestones; **a test had encoded the bug as correct**; another assertion was +*vacuous* (it asserted the decode target was never also a reference while handing every picture its +own never-reused surface id — distinct integers cannot collide); and **it streamed clean** — *"the +2026-08-07 field sessions that looked clean were looking at wrong pixels."* + +`gpu_parity` is now **11 legs** (not 9 — that note was written mid-PR): each decodes a vendored stream, +reads back every output frame's NV12, crops to the display region and SHA-256s in *display order* +against libavcodec goldens, frame count and flush tail included. The three new legs are our own +encoder's output rather than conformance vectors — H.264 because the vector is blind to the shape, +H.265 because an exemption with no stream behind it is how the H.264 defect survived two milestones, +AV1 because the vector is one tile on all 274 frames while our encoder splits 4K into two tile rows, +so every tile array the conversions fill had only ever been written at index 0. `video_vaapi_native` +parity is new entirely: 7 legs, bit-identical on RDNA3. + +⚠ Promoting D3D11VA AV1 to `verified` **changes rung selection** on Windows Intel/unknown vendors, not +just a label. VAAPI stays `verified = false` deliberately — one vendor, never soaked; flipping it +would move `auto` off Vulkan Video on every Linux AMD/Intel client including the Deck. + +### FFmpeg 9, and the Arch soname trap + +`pf-encode` now builds against **FFmpeg 9**. The host still links libavcodec unconditionally; the +client has none (see above). + +⚠ **`pacman` is the only one of our packaging formats that does not derive dependencies from ELF +`DT_NEEDED`.** rpm auto-generates `libavcodec.so.62()(64bit)`, `dpkg-shlibdeps` emits `libavcodec62`, +nix pins the closure — but a bare `depends=('ffmpeg')` let `pacman -Syu` walk the host across a +soname bump with no warning and no conflict. FFmpeg 8 → 9 (`2:9.0-5`: libavutil .60→.61, libavcodec +.62→.63, libavfilter .11→.12, libavdevice .62→.63, libswscale .9→.10) therefore **bricked every +Arch/CachyOS install**: the dynamic loader cannot start the binary, so it is **exit 127 before +`main()`** in a systemd restart loop, with nothing in the host's own log to explain it. +`ldd /usr/bin/punktfunk-host | grep "not found"` is the one-line diagnosis. + +⭐ The fix is **SONAME deps, not a hand-written version bound**: `depends=(… 'libavcodec.so' +'libavutil.so' …)`. Arch's ffmpeg declares matching `provides=(libavcodec.so=63-64 …)`, and makepkg +rewrites each bare `libfoo.so` into `libfoo.so=-` by reading the built binary's +`DT_NEEDED` — so the bound tracks whatever FFmpeg the builder linked against with nothing to +maintain across the next bump. A literal `ffmpeg<2:9` would go stale on every bump. pacman now +refuses the upgrade instead of bricking the install. All seven libs are listed even though +`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any +ffmpeg, so listing it costs nothing and a future link picks up the bound automatically. + +### Linux playback filled the buffer ceiling + +The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's +quantum limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (`pw_buffer.requested`). +Every cycle queued up to 170 ms of PCM downstream of the ring **and** taught `JitterPolicy` that the +device drains 170 ms per callback, so the underrun floor (want + one frame) rose above any depth the +A/V sync loop could request: sync measured audio ~280 ms late and was then forbidden — **by its own +continuity rule** — from draining it. The first on-glass run of the latency overhaul showed exactly +that: `audio buffer 272 ms, a/v +284 ms`, stable. Now honours `requested` (capacity remains both the +ceiling and the fallback when `requested == 0`) and logs requested-vs-capacity once per stream. +Needs libpipewire ≥ 0.3.49; every ship target clears it. + +### Windows audio substrate + +The host now mints its **own** devnodes from Valve's INFs (`SteamStreamingSpeakers.inf` / +`SteamStreamingMicrophone.inf` under `{CommonProgramFiles(x86)}\Steam\drivers\Windows10\…`) instead +of bundling VB-CABLE. + +- **Two persistent endpoints**, `Punktfunk Speakers` (client-only loopback sink — the wiring plan + parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host + stays silent) and `Punktfunk Microphone` (host writes decoded client voice into the render side; + the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker. +- **Identity is the recorded endpoint id, never the name** — a minted instance is name-identical to + Steam's primaries. Durable marker `PunktfunkAudioRole` (1 = Speakers, 2 = Mic) under Device + Parameters. Name stamping is device-desc + device-name **only**: a wider stamp set makes + `AudioEndpointBuilder` re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure + the endpoint still wires and simply keeps the driver's default name. +- **Format stamps are per-direction.** Render gets the PCM16-device / float-mix stereo split; capture + gets the **device-format key only** — mix and host-format keys are render-engine properties, and + stamping them onto a capture endpoint breaks its shared-mode graph (`IsFormatSupported` reports + 2ch/48k fine, `Initialize` then fails `0x88890008`). +- **`MintedIds` is tier-0 in the wiring plan.** The mic takes its minted device outright (paired by + provider id — a name search cannot distinguish it from the primary); the loopback prefers the + minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries + → cable → real hardware. `PUNKTFUNK_MIC_DEVICE` still beats everything. +- **Mic-vs-loopback arbitration**: the mic may hold the Streaming Microphone only while the loopback + still gets a non-last-resort pick; otherwise the loopback takes it and `mic_withheld` is set. This + fixes a field case where a headless Steam-only host streamed **silence**. +- **New `AudioReadiness`** — `Full` / `AudioOnly` / `MicOnly` / `Nothing`, logged on every plan + change and surfaced at `GET /api/v1/status` → `RuntimeStatus.audio` (`AudioWiring`, Windows-only, + absent before the first wiring pass; a status poll triggers no COM work or `IPolicyConfig` writes). + The console Dashboard renders it as an "Audio wiring" card. +- **Requires Steam installed** (never running) — without the INFs the host streams video only, and + picks the drivers up automatically if Steam is installed later. Opt out entirely with + `PUNKTFUNK_NO_AUDIO_MINT`, which restores the previous name-based ladder exactly. +- ⚠ **VB-CABLE is no longer bundled but is deliberately NOT uninstalled** — it is a third-party + shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was + considered and rejected: on a box where minting transiently fails, that would let the Steam + Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio + audible on the host. +- ⚠ **The minted endpoints survive Punktfunk's uninstall by design** (they are plain instances of + Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is + the devtest `punktfunk-host audio-probe cleanup`. +- New devtest: `punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup`. + `plan` is the field-triage command; `micpins` maps exclusive+shared `IsFormatSupported` across + {1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins. + +### Apple audio + +- **The microphone was never in the render graph.** On the combined (voice-processing) engine — made + default a week earlier and never run on a device — the input node carried a tap and **no + connection**, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat, + and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence + tripwire counts *captured* frames, so it never fired. Input now runs through a silent sink into the + main mixer at `outputVolume = 0` (Apple's own voice-processing sample topology). Two more: the tap + read the input format **before** `prepare()`, and enabling voice processing swaps in the VPIO unit + and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the + voice-processed engine took the whole uplink down for the session — it now falls back to the split + path, because **the mic outranks the AEC**. +- **No packet-loss concealment on the one client that decodes Opus in core.** Linux, Windows and + Android all feed an `AudioGapTracker` and synthesize libopus PLC; the in-core path had the tracker + sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms + frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane + (`0xD2`) hides single losses, so the survivors were exactly the burstier gaps that most needed + concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a + DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full + concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap). +- **The Apple jitter ring never grew.** The shared Rust `JitterPolicy` has an adaptive target floor; + the hand-written Apple mirror mirrored the *shed* half but not the *growth* half, pinning its + target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than + one delivery stall, so the ring re-primed through every stall for the whole session. Now the full + `note_read` mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70), + 30 s of quiet steps back, and the write-side hard trim follows the grown target. + +### Clients + +- **Nothing in the desktop console had ever been clickable.** `SkiaOverlay::handle_event` matched + only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console + into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while + browsing. New `Overlay::handle_pointer` carries mouse/touch in swapchain pixels; the run loop + converts (it owns the window and hence display scale); the console hit-tests the rects it drew last + frame. Only **direct** touch devices are offered — an indirect trackpad already drives the mouse. + Widgets act on **press**, not release, because both carousels scroll the focused item toward centre + and what you pressed would slide out from under your finger. Host menu on Up from a saved tile; + `UpdateHost` edits **in place** (remove-and-re-add would silently drop the fingerprint, learned MAC, + pinned cards and profile binding), and `ForgetHost` arms on first press and fires on second. +- **Discovery went permanently deaf three ways**, each needing an app relaunch: a failed resolve was + never retried (`browseResultsChangedHandler` fires only when the result *set* changes, and a host + whose resolve failed is still in the set); a stuck resolve never ended (`NWConnection` has no + timeout, so the throwaway UDP flow could sit in `.preparing` forever, and a service with a + connection in flight was skipped); and an `NWBrowser` parking in `.waiting` was ignored — **which is + exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not + revive the browser that was already waiting.** A 1 Hz sweep now times out stuck resolves, retries + failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every + browse report. `discovery::Rescan` forces a fresh mdns-sd query — the browse otherwise re-queries on + a doubling backoff **capped at one hour**, so a long-lived browse is effectively passive. ⚠ + `clients/windows/src/discovery.rs` is a **second copy** of the browse that the earlier IPv4 pinning + missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a + card that failed on every click. +- **Phone gyro mirror**, off by default, player 1 / wire pad 0 only, and only while that pad has no + motion source of its own. iOS/iPadOS only on Apple (`DeviceGyro` wraps `CMDeviceMotion` at ~100 Hz + on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source); + Android phones with a gyroscope at ~200 Hz with `maxReportLatencyUs = 0`, since batching is poison + for gyro aim. Both rotate from the device's natural frame into the controller frame by interface + orientation, and both send **one zero-gyro sample on stand-down** — the host holds motion as state + and re-emits it, so a leftover nonzero angular velocity reads as endless rotation. +- **Safe-area resolution** is purely a *sizing* change — no layout change, no input change; pointer + mapping follows for free since both clients derive the picture rect from the live host mode. Full + native height, width less left+right safe insets. Portrait settings screens report the housing on + `top` with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status + bar never fabricates one). Android adds the rounded-corner radius, which it does not count as + cutout. Both even-floor and clamp, because `validate_dimensions` rejects odd dimensions and an inset + subtraction lands odd about half the time. +- **Gamepad UI**: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input + on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one + shared `ui_palette` key, Violet keeping its explicit sixteen colours so existing installs are an + identity transform. Presentation only → **device preference, never part of a profile**. Palette + maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language; + `every_palette_is_multi_tone` fails under 45° hue spread and caught Ember at 35° and Graphite at 3°. + Three render-only findings: additive blending blows out over a pale ground, a white scrim at the + dark field's strength bleaches the gradient, and white glass over a bright field needs more body. + +### Session and game lifetime + +- **`PunktfunkEndReason` replaces a single "closed" bit** (ABI 17, additive, wire untouched). Five + values — local, game exited, host ended, host error, lost — classified by the connection watcher + from close codes already on the wire (`APP_EXITED_CLOSE_CODE` had been sent for a long time with + nothing consuming it). **Latched before the shutdown flag**, because the two are read by different + threads and the reason must never arrive second. Exposed as `punktfunk_connection_end_reason` + + `is_normal()`. Shells fall back to the old wording when there is no verdict (older core, or a close + that raced the read). +- **The Steam `Running` registry hint was an unbounded veto.** Honouring it reset the absence window + every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented — + pinned a lease in `running` for the life of the host process. The absence timer now runs + regardless; past `VETO_LIMIT` (30 s) with nothing of the game on the box, the session ends anyway + and logs at WARN. Extracted as a pure `exit_confirmed(gone_for, hint_running)` with tests — the + watch loop polls a live process table and cannot be unit-tested, which is exactly how the + unbounded veto shipped. +- **New `launchreg.rs`: one record per `(client fingerprint, library id)`**, written at launch and + independent of the termination policy. The old fingerprint-keyed reclaim only ran under + `GameOnSessionEnd::Always`, so under the default `Keep` nothing was recorded — and a client retry + re-sent `Hello::launch` verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it + (the launcher just focuses the running copy) but a `gog:`/`custom:` target genuinely started a + second instance over the same save files. The same retry also minted a fresh `launch_stamp`, so + procscan refused to adopt a game older than 2 s and **a reconnected session lost game-exit + detection for the rest of its life.** Identity now flows backwards from the watcher, which + publishes the concrete `ProcRef`s it adopted; liveness is `Scanner::alive` over that recorded set, + re-verified by `(pid, start)`. Tradeoffs: a `custom:` command with no detection hints stays + `Unknown` forever (trading exit detection for not double-spawning), and `IN_FLIGHT_WINDOW` is a + fixed 90 s, deliberately not `disconnect_grace_seconds`. +- **A launcher entry is `LeaseKind::Untracked` unconditionally**, checked ahead of + `nested`/`child`/`spec`. Its lifetime previously depended on invisible state: launcher not running + → live child → `Child` lease → quitting the launcher ended the session; launcher already running → + command forwards and exits inside `SHIM_WINDOW` → `Untracked` → session persists. Steam Big Picture + is a *mode*, not a process (and on a Deck it is always running); Heroic is single-instance + Electron. The real trap was the GameStream path, whose `GsApp` intermediate silently dropped the + field. + +### Library and plugins + +- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2 + `{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation. + `PUT /library/provider/{p}?store=` 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/Cargo.lock b/Cargo.lock index 798a1a54..b77afa8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,7 +994,7 @@ dependencies = [ [[package]] name = "cursor-probe" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "pf-capture", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "display-disturb" -version = "0.24.0" +version = "0.25.0" dependencies = [ "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -2358,7 +2358,7 @@ dependencies = [ [[package]] name = "latency-probe" -version = "0.24.0" +version = "0.25.0" [[package]] name = "lazy_static" @@ -2463,7 +2463,7 @@ dependencies = [ [[package]] name = "libvpl-sys" -version = "0.24.0" +version = "0.25.0" dependencies = [ "bindgen", "cmake", @@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loss-harness" -version = "0.24.0" +version = "0.25.0" dependencies = [ "punktfunk-core", ] @@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pf-bitstream" -version = "0.24.0" +version = "0.25.0" dependencies = [ "cros-codecs", "tracing", @@ -2996,7 +2996,7 @@ dependencies = [ [[package]] name = "pf-capture" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ashpd", @@ -3017,7 +3017,7 @@ dependencies = [ [[package]] name = "pf-client-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ash", @@ -3051,7 +3051,7 @@ dependencies = [ [[package]] name = "pf-clipboard" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ashpd", @@ -3069,7 +3069,7 @@ dependencies = [ [[package]] name = "pf-console-ui" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ash", @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "pf-dxvadec" -version = "0.24.0" +version = "0.25.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3100,7 +3100,7 @@ dependencies = [ [[package]] name = "pf-encode" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ash", @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "pf-frame" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "libc", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "pf-gpu" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "pf-host-config", @@ -3150,11 +3150,11 @@ dependencies = [ [[package]] name = "pf-host-config" -version = "0.24.0" +version = "0.25.0" [[package]] name = "pf-inject" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ashpd", @@ -3183,14 +3183,14 @@ dependencies = [ [[package]] name = "pf-paths" -version = "0.24.0" +version = "0.25.0" dependencies = [ "tracing", ] [[package]] name = "pf-presenter" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ash", @@ -3205,7 +3205,7 @@ dependencies = [ [[package]] name = "pf-update" -version = "0.24.0" +version = "0.25.0" dependencies = [ "serde", "serde_json", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "pf-update-check" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "base64", @@ -3225,7 +3225,7 @@ dependencies = [ [[package]] name = "pf-vaadec" -version = "0.24.0" +version = "0.25.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3234,7 +3234,7 @@ dependencies = [ [[package]] name = "pf-vdisplay" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ashpd", @@ -3267,7 +3267,7 @@ dependencies = [ [[package]] name = "pf-vkdecode" -version = "0.24.0" +version = "0.25.0" dependencies = [ "ash", "cros-codecs", @@ -3278,7 +3278,7 @@ dependencies = [ [[package]] name = "pf-win-display" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "pf-paths", @@ -3290,7 +3290,7 @@ dependencies = [ [[package]] name = "pf-zerocopy" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ash", @@ -3513,7 +3513,7 @@ dependencies = [ [[package]] name = "punktfunk-cli" -version = "0.24.0" +version = "0.25.0" dependencies = [ "pf-client-core", "punktfunk-core", @@ -3524,7 +3524,7 @@ dependencies = [ [[package]] name = "punktfunk-client-android" -version = "0.24.0" +version = "0.25.0" dependencies = [ "android_logger", "jni", @@ -3542,7 +3542,7 @@ dependencies = [ [[package]] name = "punktfunk-client-linux" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "async-channel", @@ -3559,7 +3559,7 @@ dependencies = [ [[package]] name = "punktfunk-client-session" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "pf-client-core", @@ -3574,7 +3574,7 @@ dependencies = [ [[package]] name = "punktfunk-client-windows" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-channel", "mdns-sd", @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "punktfunk-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "aes-gcm", "bytes", @@ -3625,7 +3625,7 @@ dependencies = [ [[package]] name = "punktfunk-host" -version = "0.24.0" +version = "0.25.0" dependencies = [ "aes", "aes-gcm", @@ -3710,7 +3710,7 @@ dependencies = [ [[package]] name = "punktfunk-probe" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "mdns-sd", @@ -3724,7 +3724,7 @@ dependencies = [ [[package]] name = "punktfunk-tray" -version = "0.24.0" +version = "0.25.0" dependencies = [ "anyhow", "ksni", @@ -3747,7 +3747,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyrowave-sys" -version = "0.24.0" +version = "0.25.0" dependencies = [ "bindgen", "cmake", diff --git a/Cargo.toml b/Cargo.toml index 4a6fde25..d574c7ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ exclude = [ ndk = { path = "clients/android/native/vendor/ndk" } [workspace.package] -version = "0.24.0" +version = "0.25.0" edition = "2021" rust-version = "1.82" license = "MIT OR Apache-2.0" 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 new file mode 100644 index 00000000..4a58fec7 --- /dev/null +++ b/docs/releases/v0.25.0.md @@ -0,0 +1,90 @@ +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. + +This is the largest release so far — close to four 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. + +## 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. +- **Sound was never actually lined up with the picture.** Hosts have always marked audio with the moment it was captured, and no app had ever read that mark — so how far behind the picture your sound played was whatever buffering happened to settle on, and it got *worse* every time video got faster. All four apps now measure the gap and close it. +- **A few things need you to act** — a new group on Linux, a firewall port for add-on interfaces, and a change to gyro aim sensitivity. They are right below. + +## Before you update + +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: 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. +- **If you play with motion controls, your aim sensitivity will change.** The gyro pipeline was wrong at every stage and is now measured against a real controller, so the numbers moved: a controller presented to games as a DualShock 4 was reporting motion **forty times too fast**, and a PlayStation pad plugged into an Android phone was reporting about **30% short**. If you turned a game's sensitivity down or up to cope, set it back. The Android case is the one people plausibly tuned around — that aim now needs a *higher* in-game sensitivity than you are used to. + +## New + +- **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. Every decoding path is now checked frame by frame against a reference decoder, so a picture that streams cleanly while being subtly wrong cannot pass unnoticed — which is exactly what was caught during development. On Windows with Intel graphics, AV1 decodes through Direct3D rather than Vulkan, which is far quicker on those cards. 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 + +- **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. +- **The stats overlay finally shows the audio side** — how much sound is queued ahead of your speakers, and how far that puts it from the picture. Both numbers, because a deep buffer on a rough connection is correct and only the gap tells you the difference. On every client; previously neither existed anywhere you could see. +- **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 + +- **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. +- **Sound is now placed against the picture instead of wherever buffering left it.** Hosts have always marked audio with the moment it was captured, and no app had ever read that mark — so lip-sync was an accident of how deep the audio buffer happened to sit, and it got *worse* as the picture got faster, because a quicker decoder shortens the video side and leaves sound exactly where it was. That is why shaving milliseconds off the audio path had never helped. Every app now measures the gap and steers the buffer to close it — but never so far that sound starts breaking up: on a jittery connection it keeps the buffer and leaves a small gap rather than take the dropout. Hosts also release audio at an even pace instead of in bursts, which mattered most on virtual machines. And on Linux the app was handing the sound system up to 170 ms of audio every cycle — filling the buffer's ceiling rather than what the system had actually asked for — which by itself put sound about a quarter-second behind and then blocked the new sync from draining it, because doing so would have risked breaking up. That is fixed, and it is what the first on-glass run of this work turned up. Update both sides. +- **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. +- **A routine system update can no longer brick the host on Arch and CachyOS.** FFmpeg 9 renamed every one of its libraries, and because the package asked only for "FFmpeg" rather than the versions it was built against, an ordinary update walked the host onto libraries it could not load — so it failed to start at all, before it could log anything, and retried forever. The package now states what it actually needs, so the update is refused rather than silently breaking your host, and it is built against FFmpeg 9. Update the host. +- **Add-ons that read a launcher's database returned zero games on every Linux host**, indistinguishable from an empty library. On Windows, an add-on listing a launcher's registry entries got nothing back for the same silent reason. Update the add-on. +- **Motion controls were wrong end to end, and are now measured against a real controller.** A pad presented to games as a DualShock 4 reported gyro forty times too fast; Apple clients sent acceleration upside-down and rotation on the wrong axes, so a turn arrived as a roll; a PlayStation pad on Android reported about 30% short. A controller that stopped sending motion — app backgrounded, pad swapped — also left the virtual pad spinning forever, and one that had never sent any claimed to be in free fall, which some games read as a wild tilt. See the note above about sensitivity. Update both sides; they are independent. +- **A Bluetooth controller's gyro now reaches the host from an Android phone.** Previously only a PlayStation pad plugged in by USB had working motion there — a DualSense, DualShock 4, Switch Pro or 8BitDo on Bluetooth had buttons and sticks but a silently dead sensor. Android 12 and newer. And when your controller has a gyro but the session's virtual pad has nowhere to put it, the client now says so on screen and names the setting that fixes it. +- **The game library works on any host address on iPhone, iPad, Mac and Apple TV.** It only ever loaded on a home network: over Tailscale, WireGuard, or to a host with a public address, the library came up empty or errored — while streaming to that very same address worked perfectly, which is what made it so confusing. Cover art is also cached on disk now instead of being re-fetched every time. Update the client; the host is unchanged. +- **The web console asks its own questions.** Sixteen grey browser confirmation boxes on destructive actions are replaced by the console's own dialogs, and the drop-downs, checkboxes and text areas in the hook editor and library forms are no longer unstyled controls at near-zero contrast. A virtual display can also be placed left of or above the origin again — typing a minus sign used to erase it before you could type the digits. +- **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 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, 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. + +## For developers + +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 new file mode 100644 index 00000000..76e7f973 --- /dev/null +++ b/docs/releases/whatsnew/v0.25.0.txt @@ -0,0 +1,6 @@ +• 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.