Wire-compatible with 0.23.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.

This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. The other big one is **Automatic bitrate**, which could decide early in a session that your connection was worth 20 Mb/s and then hold you there for the rest of it: a sweep of that path fixed eleven separate faults, and a session that used to need seventeen minutes to climb out now does it in about sixteen seconds. Alongside those: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all.

## New

- **The Linux and Windows app schedules frames onto your screen instead of throwing them at it.** Until now the desktop app showed each frame the instant it finished decoding, so every frame carried whatever jitter the network and the decoder had just added — the same problem the phone and Apple apps had before their rebuilds. It now has the same two-way choice under **Prioritize**: **Lowest latency** (the default, and what you have today) or **Smoothness**, which holds a small buffer of frames and releases one per screen refresh so an uneven stream plays evenly. The buffer is 1–3 frames, your pick. On screens where the graphics driver holds a queue of pending frames — which is most Windows machines with an AMD card, and any machine under a compositor that does the same — the app now also stops that queue from building up, which is where a whole extra refresh of delay used to come from.

- **V-Sync and "Follow variable refresh rate" are real settings now.** Both rows have existed for a while and neither did anything: the app picked one presentation mode at startup and kept it. **V-Sync** off asks for the tear-capable mode and says in the stats overlay which mode it actually got, because a graphics driver is free to refuse. **Follow variable refresh rate** lets a VRR/FreeSync/G-SYNC display follow the stream's own cadence rather than a fixed grid; where your driver supports the newest tear-free presentation mode it turns itself on, and elsewhere it stays off unless you ask for it, because on those drivers it costs more than it wins. Whether you actually *have* variable refresh is now measured from what your display does rather than believed from what it claims — no platform reports it honestly enough to trust.

- **The Steam Deck plugin covers every setting, in a sidebar that fits on screen.** Nine of the client's settings had a row here and about twenty did not, so a Deck that never sees a desktop could not reach its own decoder, full chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio device. All of it is here now, split across a left rail of categories — the layout SteamOS's own Settings uses — so every page fits without scrolling and nothing is more than one hop away. The categories, their order and the wording match the console's settings screen, because two different orders for one set of settings is how people stop trusting either. Two things are deliberately absent and named as such: which physical controller is player 1, and the remembered window size.

- **The stats overlay has an off switch on the Steam Deck.** Reported from the field: there was no way to turn it off from the plugin, and it defaults to on — so a Deck configured only through this panel streamed with the overlay up and no way here to put it down. The keyboard shortcut and the three-finger tap both existed but neither is discoverable from a couch. The row now sits at the foot of the section, worded the way the console words it.

- **"Forward controllers" — an off switch for setups that hand the pad over another way.** If your controller already reaches the host by USB passthrough (VirtualHere and friends), or is simply plugged into the host, it arrived there twice: once as the real device and once as the virtual pad the client built from the same hands. Games read both, so a stick drifts against the second, centred pad and menus take every input twice. The new per-client setting defaults to on — today's behaviour — and can be set per profile. On Linux and Windows it does more than stop sending: opening a controller is what *claims* it, so with this off the app never opens one at all, leaving the device free for the passthrough tool to bind. The consequence is stated at the setting: the controller escape chord is read off forwarded pads, so it is unavailable while this is off. The Apple and Android apps never claim the pad, so they keep their chords and only stop sending — losing an Apple TV's only way out of a stream would have been the worse bug.

- **Plugin output reaches the web console's log page.** Plugins do not run as children of the host, so nothing they printed ever passed through the host's own logging and the Logs page could not show a single plugin line. The fallback was a terminal on the host box — and on Windows, no log file at all, so a failing plugin was diagnosable only by stopping its task and re-running it by hand. That is exactly what the console exists to avoid, and it left the one question a stuck user asks with no answer. The plugin runner now sends its output to the host, where it joins everything else under one timeline; the Logs page grows a **Host / Plugins** switch beside the level filter, and an empty Plugins view says the thing that is usually actually wrong (the runner isn't running) rather than telling you to adjust the filter.

- **The Windows app can show you where its log lives.** "Check the client log" never said where that was. **Settings ▸ About** grows an **Open log folder** row, and the message you get when a stream fails to start now names the path. It opens the folder rather than the file, so the previous session's rotated log is in reach too.

## Improved

- **The stats overlay's display figure splits into two numbers.** It used to be one number covering everything between "decoded" and "on your screen", which is two very different things stacked: the app's own work, and the wait for the screen to accept the frame. They are now separate, so a high figure diagnoses itself — if the second number dominates it is the refresh rate floor or a queue in the driver, and if the first dominates it is us. The Detailed tier also names the presentation mode actually in use, which answers most "why is my display number a whole refresh" questions on its own.

- **The full-chroma explanation names what it actually needs.** The Windows app's caption said 4:4:4 was "HEVC only, and only where the host can encode it", which sends people hunting through host settings; the web console's explainer was similarly vague. Both now name the real requirement. Host-side, the log line that reports the decision stopped being named after the capture side when it was never about capture — a field report burned real time hunting a capture problem because of it — and a session that asked for full chroma and did not get it now says which end declined.

## Fixed

- **0.23.0 could freeze the picture on Android and on the Linux and Windows app.** 0.23.0 started cutting each frame into pieces and sending them as they were produced. The receiving side then sized every arriving frame at the largest a frame is ever allowed to be — many megabytes — so its budget for frames-in-progress ran out after about three. With normal traffic that meant twelve frames arriving and nine of them thrown away before a single byte could be placed: on any link that reorders packets at all, a loss storm that does not end. Frames never complete, the picture freezes, and the client keeps begging for a fresh one. A second fault in the same path killed one frame in roughly every 1408 outright — about once every twelve seconds at 120 fps, each costing a freeze and a recovery. Only Android and the Linux/Windows desktop app ever took this path, which is why it read as a platform-specific video fault in the field; the Apple apps and the Windows in-process client were never affected. **Updating the client is what fixes it** — a 0.23.0 client still has the bug whatever host it talks to.

- **Automatic bitrate could pin a session at 20 Mb/s for the rest of its life.** Sessions start at 20 Mb/s and climb, and a host refuses a climb while it is briefly behind on encoding. But an ordinary hitch at startup — which arrives while the rate is still at that 20 Mb/s floor — was enough to make the host refuse, and the client cannot tell a momentary refusal apart from an encoder that genuinely cannot go faster: both arrive looking identical. Two of them and the client concluded there was a permanent ceiling. Escaping cost 12.5% per minute, so crossing the gap to what a fast link could actually carry took upwards of twenty minutes, and in practice often never happened — which is why "my bitrate is stuck at 20" has been such a persistent report. Three things changed: the host no longer refuses climbs merely because it is running in its own high-effort mode (that mode exists so it *can* keep up — refusing climbs once it is working refuses the thing that worked), the client re-checks after 12 seconds instead of a minute and backs that off only if the limit proves real, and a request granted in full is taken as proof the limit is gone rather than nudging the guess upward. Measured end to end: a session pinned at 20 Mb/s under a 300 Mb/s connection now reaches 150 Mb/s in about sixteen seconds, where the same case previously needed around seventeen minutes.

- **Automatic bitrate drew permanent conclusions from single moments.** Five more faults of the same shape. The reference points it compares against — network delay, decode time, encode time — could arm off *one* window, and since each is a rolling minimum that one window became the floor; a calm window followed by ordinary motion then read as congestion on a link that was never the problem. Changing resolution or refresh rate re-based only some of what it had learned, so switching *up* a mode was scored against the old mode's easier numbers and cratered the rate instead of raising it. The high-water mark that bounds how far every later climb may step never decayed and was raised by damaged windows — a stall's backlog arriving at once, or a flush's queue — which are exactly the windows that overstate what was delivered. The decoder ceiling latched *at* the rate that had just choked, authorising a climb straight back into the failure, and a network hiccup could be mistaken for a decoder one. And a manual bitrate cap bound only ceilings the speed test had learned, so it did nothing at all if the session already started above it.

- **Your connection was measured including the redundancy sent to protect it.** Two checks compare what actually arrived against what the encoder was asked to produce, and both counted every byte accepted — packet headers, audio, and the extra error-correction data the host adds *in answer to* packet loss. So the measurement rose with the loss it was supposed to detect: at 25% redundancy the check passed while the encoder was emitting barely half its target, and the permanent high-water mark inherited that inflation for good. The signal was weakest on exactly the lossy links it exists for. Only the actual video payload is counted now.

- **A host that re-chose the rate never told the client.** When a host rebuilds its pipeline it can legitimately re-pick an Automatic rate — a 1080p session mirroring a 4K panel needs roughly three times what it negotiated — but that number never reached the client, which kept its own stale copy as the basis for every later step. So a client believing 20 Mb/s while the host encoded 60 would compute its first climb from the stale figure and ask for 40: a request to go *down*, paying for an encoder rebuild to get there. The host now tells the client whenever the applied rate moves, using a message that already meant exactly that and which existing clients already handle arriving unprompted — no wire change, and older clients are unaffected.

- **On KDE hosts, a hidden mouse pointer stayed on screen.** Since 0.22.0 a KDE stream always had a cursor and it never went away again — not in a game, not in Big Picture, not with a controller in hand. The host was blending an arrow onto the picture forever because the signal KDE sends to say "the pointer is not here" was being ignored. It is honoured now, so a game that hides the pointer mid-stream actually hides it. GNOME hosts keep the behaviour they have, because there the same signal means something different and honouring it made the cursor flicker.

- **A KDE host could stream a copy of your monitor instead of its own screen.** KDE remembers display arrangements per set of connected screens, and one of the things it remembers is "this screen mirrors that one". Because the streamed screen carries a stable name, any arrangement that had ever recorded it as a mirror got that re-applied on every later session that reproduced the same set of monitors — which is why it looked so arbitrary: the stream cloned the panel whenever exactly one monitor was live, and behaved normally the moment the others came back. A mirroring screen is not its own desktop; it takes the physical screen's size and viewport instead of the one you negotiated. The streamed screen now says outright that it mirrors nothing, and if it ever finds itself mirroring anyway it says so in the log instead of leaving that as something only you can see.

- **Waking a Windows PC no longer fails the first connection.** A host that had just woken refused connections with a message claiming its virtual-display driver was not installed, on machines where it plainly was. Resuming re-registers that driver while the rest of the wake is still going, and a client reconnecting a second later landed inside the gap. The host checked exactly once, read the gap as a dead driver, and answered a device that was seconds from ready by resetting it — a reset that was then refused, because the host itself was holding the driver open, and reported as a success anyway. It now waits out a driver that is mid-wake instead of resetting it, reports what a reset actually did rather than what the device looks like afterwards, only ever runs one reset when several sessions arrive at once, and says in the log how long it waited and what it saw.

- **AV1 streams quietly decoded in software.** Every AV1 session opened a software decoder no matter what your graphics card could do, because of how the decoder was being looked up by name. Each frame then failed the hardware check and the session walked down its fallback ladder mid-stream — around three seconds of black, with "hardware decode active" already printed and every hardware check green. Decoders are now chosen by what they can actually do. H.264 and HEVC pick exactly what they always did, and every decode log now names the decoder in use, which was the whole diagnosis and no line said it. Separately, a software-decoded HDR stream used to be shown washed out with no warning and an overlay badge claiming a tone-map that never ran; it now warns, and the badge distinguishes the two.

- **A 120 fps session sent 132 frames a second.** The option that runs the virtual display at a multiple of the session's rate promises extra display refreshes without extra frames on the wire, but it only enforced a floor between frames — so content that always had a frame ready settled about ten percent above the rate you negotiated. That is ten percent more bitrate, encode and decode for frames a 120 Hz screen can only drop. The pacing now holds the long-run average at the negotiated rate while keeping the same room for jitter, and a source running at or below the rate is never delayed.

- **On an iPad, Escape handed the mouse back to iPadOS.** iPadOS releases the pointer by itself when you press Escape — its built-in "let me out". But Escape in a stream is a game key, not a request to give the mouse back, so pressing it for an in-game menu silently cost you the capture until you clicked into the video again. An unwanted release is now re-requested, briefly and a bounded number of times. Every deliberate way out (the menu, the two keyboard chords, switching apps) is untouched, and while the re-grab is in flight the local cursor stays hidden and pointer movement is held, so it reads as "Escape did nothing to my mouse" rather than a cursor blinking in and out.

- **The Windows app showed settings something else had already changed.** A field report said a codec setting "changed by itself" between sessions. Nothing writes it back — what they saw was a stale copy. The app read the settings file once at startup, but it is not the file's only writer (the stream itself stores its window size, and the console and the Deck plugin save too), so the page showed values another part of Punktfunk had already replaced — until you touched any row, at which point the value visibly jumped. It re-reads the file on entry now, and on the profile path too. A related one: an older build's save used to *drop* settings a newer client had written, and now carries them through untouched.

- **A Windows host could fight your sound settings.** When no usable playback device was left — a display isolated, the speakers excluded, the microphone holding a virtual device — the host re-ran its whole audio setup every two seconds for as long as it took anyone to notice, including writing your default recording device back each time. That silently undid any recording-device change you made while a stream was up. An impossible arrangement is now recognised as impossible: the host says so once, with the devices it found and why each was rejected, then waits for a device to actually appear or disappear instead of retrying a verdict that cannot change. The default recording device is only asserted when the plan changed or something else moved it.

- **PlayStation Accessories stopped offering a controller update that could never finish.** The emulated DualSense reported a 2021-era firmware version, so Sony's app — and games using their controller library — offered an update that can only ever end in "can't complete the update", since the virtual pad speaks no update protocol. A real pad plugged in directly reads as up to date, which made the prompt look like Punktfunk having corrupted the controller. It now reports a version above anything Sony has shipped, rather than chasing their latest and resurrecting the prompt with every Sony release.

- **Rumble stopped for good after unplugging and plugging a controller back in.** Reconnect a pad mid-session — or have Bluetooth drop it for a moment — and roughly half the time it never rumbled again for the rest of that session. Everything else about the controller kept working, which is what made it look random rather than broken. The host restarts a counter when a controller goes away, and the client uses that counter to throw away force-feedback that arrives out of order; because the client does *not* restart its side, everything sent after the reconnect looked older than what it had already seen, and it dropped the lot until the host counted back past where it left off. How long that took depended on how much the pad had rumbled before you unplugged it, so a quiet session healed at once and a busy one stayed silent. The counter now survives a reconnect, which is what the same code already does for the controller's buttons and sticks.

- **An unplugged controller stayed plugged in as far as the game was concerned.** Unplug a pad mid-session and the game kept seeing a connected controller that never pressed anything again — it simply never went away. If it was your only controller, this happened every time. Tearing the virtual pad down takes a brief settling delay first, so a momentary glitch cannot make a device disappear and reappear, but the second look that finishes the job only ever happened when *another* controller sent something afterwards. With one controller there is nothing left to send it. The teardown now completes on the host's own clock, about a third of a second after the pad goes, whether or not anything else is connected — and a glitch that comes back within the delay still leaves the device alone.

- **The speed test overstated your connection, and Automatic bitrate believed it.** Throughput was worked out by dividing what the client received by how long the *host* spent sending — a window wrong on both ends, since the host's clock stops the moment its send window closes, while the data is still draining through the network toward you. On a gigabit link a test aiming at 2 Gb/s "measured" 1266 Mb/s and set an 886 Mb/s ceiling the link could never carry, permanently, for the whole session. It is now measured over the interval the client actually received across, and video around the test contaminates neither half of the sum. Two guards ride along: a manual bitrate cap now binds no matter what any test concludes, and a decoder that keeps drowning below the link's ceiling has that noticed and remembered, instead of a 30–60 second cycle of climbing back into the same wall and flushing — a 1440p120 case cost a dropped-frame burst every cycle.

- **Plugins on Linux could not reach anything else on the machine.** Reported by a user who could not get the VirtualHere plugin to talk to their VirtualHere client, and the reason was ours: the plugin runner was given its own private temporary directory. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk through that directory. So a plugin would launch a vendor program happily and then never be able to reach the service behind it — while the identical command worked perfectly in the operator's own terminal. No setting could fix it. Plugins now see the real one.

- **Android: a decoder hiccup turned into a burst of broken frames.** When the decoder handed back an input slot it could not actually fill, both the slot and the video in it were dropped on the floor — leaking one of the decoder's input buffers each time, until the pipeline ran out of them entirely and the resulting keyframe storm read as a decode fault rather than the bookkeeping mistake it was. The dropped video also left a hole nothing asked to repair, so the damage was free to reach the screen. Both go back now.

- **Android: the app could pin the wrong refresh rate for a whole session.** Asking a phone for 120 Hz is a request the system may refuse — Smooth Display off, battery saver, thermal limits, an OEM's own governor. The app took the answer on faith and could only ever revise it downward, so a refused request left it aiming at screen refreshes that never arrive, for the rest of the session, with no way back. It now corrects in both directions: instantly toward a faster screen, and toward a slower one after eight consecutive agreeing observations, because one slow sample is a missed callback and eight in a row is a display that really did slow down. Two related fixes: the app now holds back when the system stops confirming that frames reached the screen, instead of feeding a queue that has stopped draining until the decoder stalls; and the timing margin it adapts now widens on frames that actually missed the screen rather than on ordinary pacing, which on a healthy phone had been walking it to its ceiling and re-imposing the delay the 0.23.0 work had just measured away.

- **Windows stutter reports blamed the wrong thing.** The host tries to tell you whether frames stopped arriving because the *game* went quiet — a menu, a loading screen, an ordinary hitch — or because the display path did. Its witness for the display path never worked: it was reading timestamps in one unit and comparing them against another, so it saw zero display activity always, and every quiet stretch was reported as the game going quiet. The category the whole thing exists to catch was unreachable. It reads correctly now, and it can tell "the witness was working and saw nothing" from "the witness was not working", which are opposite conclusions. If you have a stall report from an earlier version, its verdict is not evidence.

## Under the hood (for developers)

- **Versions.** All unchanged from 0.23.0: wire protocol 2, C ABI 14, virtual-display driver protocol 6, Windows virtual-gamepad channel 3. No new negotiated capability bits — `VIDEO_CAP_MULTI_SLICE` (`0x80`) was already the video-caps byte's last free bit and nothing needed the next one. The only C-header change is documentation: `PunktfunkProbeResult::elapsed_ms` now means the client-measured receive interval (see the ABR entry), and the probe clamp comment corrects 3 Gbps → 10 Gbps to match `MAX_PROBE_KBPS`.

- **Slice-streamed reassembly.** Every ordinary access unit on the streamed path is now opened by a sentinel header (the block flush at `MIN_STREAM_BLOCK_SHARDS` guarantees it), and the reassembler was sizing those at `max_frame_bytes` — 8–64 MiB after the QUIC handshake clamp. Each AU therefore allocated and zeroed a multi-megabyte buffer, and `IN_FLIGHT_BUF_FACTOR × max_frame_bytes` was exhausted after ~3 concurrent frames. A sentinel now sizes to its own block extent (a slice sentinel by its wire base, a legacy one by its full-K position) and grows as later blocks or the final block's totals reveal more, with the in-flight budget re-checked on growth. Separately, `flush_block` drained `pending` to empty when the AU length was an exact multiple of the shard payload, leaving `finish_streamed` to seal a final block of one zero-padded filler shard whose derived base overlapped the block flushed a moment earlier — correctly read as a lying header, killing the AU. A flush now retains one whole shard, restoring the invariant `StreamedAu::pending` already documented.

- **Desktop presentation engine.** `pf-presenter` gains `present_pace.rs` (pure state + arithmetic): `FrameStore` (newest-wins slot or smoothing FIFO with preroll-to-capacity, drop-oldest overflow and underflow re-arming the preroll — the Apple/Android semantics, with `qDrop`/`qDry`), `LatchClock` (panel grid from `VK_KHR_present_wait` glass stamps, publishing the host-facing `LatchGrid`), and `PresentGate` (one undisplayed present in flight on FIFO surfaces, 100 ms stale force-open; inert on MAILBOX/IMMEDIATE and without present timing). Settings ride the keys the Apple client already writes into the shared profile catalog — `present_priority` / `smooth_buffer` / `vsync` / `allow_vrr`, now tier-P routed — and `PresentPriority::resolve` mirrors the Android reference exactly, so a profile authored on any client means the same thing everywhere. PyroWave collapses smoothness to latency (its plane-ring retirement assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot).

- **Present-mode ladder and `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.** Mode selection is a preference ladder rather than a constant: V-Sync off → IMMEDIATE, FIFO_RELAXED, then tear-free; V-Sync on + VRR + fullscreen → LATEST_READY first; otherwise MAILBOX then FIFO. The extension postdates ash 0.38's headers (Vulkan 1.3.281), so the mode value, extension name and `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` are hand-declared. **Trap:** the surface advertises the mode even with the extension disabled, and using it on that basis is undefined — the ladder only offers it where the device feature came back true and was enabled. The gate predicate splits in two on purpose: `needs_glass_gate()` is FIFO and FIFO_RELAXED only (gating on a driver that already retires stale images pays the serialisation twice — the ~27 ms an A/B measured), while `vblank_locked()` is the whole FIFO family including LATEST_READY, since the VRR cadence probe's premise still holds there. Measured on .21 (NVIDIA 610.43.03, GNOME/Wayland): `display 2.6 ms (pace 0.6 + latch 2.0)` on the VRR ladder against 13–28 ms for FIFO + gate, and 1.4 ms for MAILBOX. ⚠ Unverified off that box: whether Windows (Vulkan WSI through DXGI) or Intel drivers expose the mode at all — `surface present modes` and `fifo_latest_ready=` are logged unconditionally, so one run on any box settles it. `allow_vrr` is automatic only where the device offers the mode, and stays behind `PUNKTFUNK_VRR_FIFO=1` elsewhere.

- **VRR detection is measured, never queried.** No portable query exists (SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan) and the platforms that do answer have been caught lying. The discriminator is quantization: `CadenceProbe` folds each present delta to its distance from the nearest multiple of the *display mode's* period — not the learned one, which is circular when the stream runs below panel rate — and takes the median. Tri-state: Unknown below 24 deltas, after a display change, and outside a FIFO-family mode (MAILBOX decouples presents from scanout, so its stamps are never grid-quantized; the same panel read `no` on FIFO and `yes` on MAILBOX in one minute).

- **`PanelGrid`, shared.** The panel-period learner is extracted to `punktfunk_core::phase::PanelGrid` and adopted by the Android, iOS and desktop presenters, replacing three copies with the same bug: a seed capped at the display mode's refresh, when the mode is only a *claim*. Narrowing is immediate; widening needs eight consecutive agreeing observations and then takes the narrowest of them. `preferredDisplayModeId` on Android is a request the system may refuse, which is the case that made this a session-length failure.

- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches.

- **ABR sweep — eleven defects.** Wire format and ABI untouched throughout; 34 abr tests plus 2 host tests. Host side: `cadence_degraded` was latched true for as long as the session was *escalated* (adaptive capture depth or pipelined retrieve), independent of whether encode was still missing deadlines — and escalation needs only ~20 net behind-frames, which a startup hitch supplies while ABR is still in slow start at the 20 Mbps default. The rule moves into `encode_behind_cadence`: an escalated session is still judged strictly (any net behind-frame keeps it flagged, where an unescalated one gets the full bucket), but escalation alone no longer flags it. `adopt_built_bitrate` now publishes the rate a rebuilt pipeline actually opened at (`build_pipeline` re-resolves an Automatic rate whenever the source delivers an unnegotiated size — the mirrored-panel case — and the encoder's clamp can land below what control already acked), pushed to the control task as the existing 9-byte `BitrateChanged`. Client side: all three rolling baselines (OWD, decode, encode) now require `BASELINE_MIN_WINDOWS` of evidence via one shared `score_baseline` — the three copies had drifted apart, and `on_ack` clears the encode baseline after every self-requested decrease, re-opening the one-sample hole each time. A mode switch rebases decode and OWD as well as encode, and drops `proven_kbps` with them. `proven_kbps` is raised only by *clean* windows (it never decays and holds permanent authority over climb step size, and the windows that overstate delivered throughput are precisely the damaged ones). `decode_cap_kbps` latches just *under* the choke rate, inside the ±1/8 band the evidence already required, and credits a bare jump-to-live flush only where the decode signal is absent. The two throughput-driven gates are fed data-shard payload counted at the reassembler's routing decision rather than `bytes_received` (which includes headers, FEC parity, probe filler and audio — at 25% FEC the utilization gate passed with the encoder emitting ~55% of target). `PUNKTFUNK_ABR_MAX_MBPS` binds at construction, not only on probe-learned ceilings, and a session starting above its cap steps down to it (no congestion signal will ever find that — the link is fine, the cap is policy). Cap escape re-probes after 12 s, doubling each time the lift is immediately re-learned, and a request granted **in full** at or above the cap drops the cap outright rather than nudging it +12.5%. An ack above the current ceiling raises the ceiling to meet it (`set_ceiling` still only ever raises, still clamps to `PUNKTFUNK_ABR_MAX_MBPS`), so a host-initiated re-target is not immediately stepped back down. Finally, a `SetBitrate` dropped by a full control queue no longer counts toward `MAX_UNACKED` — three of those retired the controller for the session while blaming an "older host".

- **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt.

- **Force-feedback lifecycle.** `native/input.rs` no longer resets `rumble_seq[idx]` on `GamepadRemove`: the client's v2 reorder gate is per-connection with no reset path, so restarting the sender's counter stranded every later envelope behind `seq_newer` until it climbed past the stored value (up to 128 sends). The lease clears that actually end a rumble move into `clear_pad_feedback`, whose signature omits the seq so the arm cannot regress. `PadSlots` splits `sweep` (arm the grace from a state frame) from a new `reap` (complete the unplug, no frame required), and all three backends reap from the periodic pump they already run; `reap` never arms a clock, so it cannot invent an unplug however often it runs. Regression tests drive the unplug through a pump tick rather than the hand-fed second frame the old tests used — that second frame is not something the producer ever sends, and its absence is what hid both defects.

- **ETW present witness.** The consumer was opened without `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, so `ProcessTrace` converted every event's `TimeStamp` to FILETIME regardless of `ClientContext=1` — FILETIME ticks are ~4 orders of magnitude above QPC, so every comparison was false, `etw=none` always printed, and `classify()` convicted every compose-silence hole as CONTENT-SILENCE, making FRAME-GENERATION unreachable. Two comments asserted the wrong contract and now state the real one. `summary()` and `window_counts()` merge into one `window_report()` (one ring lock, one anchor — they could previously disagree about the same hole); `present_history`/`queue_history` now mean witness *liveness* within a 5 s lookback ending at the hole's start rather than "an event ever sat in the ring", and the static ring is cleared per session. Conviction thresholds untouched. **Any stall verdict from before this fix is void.**

- **Hardware decoder selection.** `avcodec_find_decoder(id)` returns the registry's first decoder for the id and upstream orders the native AV1 decoder *last* on purpose ("hwaccel hooks only, so prefer external decoders"), so all three hardware backends were opening libdav1d — which ignores `hw_device_ctx` and never calls `get_format`. `find_hw_decoder` walks `av_codec_iterate` and takes the first decoder whose `avcodec_get_hw_config` advertises the backend's surface via `HW_DEVICE_CTX`, so a build without a usable hardware decoder fails at open in milliseconds and the ladder runs there. Registry order still wins among capable decoders; the software path keeps the id lookup deliberately (libdav1d is the fastest CPU AV1, and the native decoder has no software path).

- **KWin mirroring.** `OutputConfigurationStore` persists `replicationSource` per *setup* (the exact connected-output set, matched by EDID/connector), and our virtual output carries a stable name by design — so a stored mirror entry re-applies on every session reproducing that monitor set. `applyMirroring` overrides scale and render offset to the source's, and the protocol states that a mirroring output may not be in the output order, so the primary assertion silently stops meaning anything too. The topology config now includes `set_replication_source(ours, "")`, gated on output-management v13 where the request appeared (wayland-rs does not range-check requests; an out-of-range opcode kills the connection). `extend`/`auto` issue no topology calls by design, so they get `clear_replication_source`, which enumerates and applies only when our output really is mirroring. The device's `replication_source` event is now read and warned on.

- **KWin cursor visibility.** Two producer contracts meet on `SPA_META_Cursor` id 0. KWin rewrites cursor meta on every enqueued buffer and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream — covering both a globally hidden cursor and a client null-cursor surface — so there id 0 *is* the hide. Mutter only rewrites meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames, and honouring those flickered the cursor off between hovers. A flag rides from the backend that created the output through `capture_virtual_output` into the parser's `CursorState`; the portal-monitor path stays on the stale-meta contract (only Mutter's HDR mirror routes through it today).

- **Plugin runner and logging.** `punktfunk-scripting.service` drops `PrivateTmp=yes` and adds `/tmp` to `ReadWritePaths` (which `ProtectSystem=strict` would otherwise make read-only) — VirtualHere's client IPC is the `/tmp/vhclient` + `/tmp/vhclient_response` FIFO pair, and X11 is `/tmp/.X11-unix`. The runner tees stdout to `POST /api/v1/plugins/logs`, joining the host's ring under one cursor with target `plugin:<name>`; stdout stays authoritative, the queue is bounded and drops oldest (then reports how many), the shipper backs off when the host is away and re-sends a batch the host refused. `plugin_may_access` is an exclusion list, so `/plugins/logs` is reachable today only because it does not match `/ui-credential` — now asserted directly, since a change there would silence plugin logs with no other symptom. Two shipper bugs fixed with tests that fail against the previous code: the recursion guard was held across the whole `await fetch` and `enqueue` checked it (so every line logged while a POST was open was dropped — worst exactly when the host is slow), and an explicit `flush()` hit the same guard and returned having sent nothing, which is the shutdown path where the last lines say whether shutdown was clean.

- **Gamepad claiming and the new setting.** `forward_gamepads` is tier-P (profile-routable), default on. On Linux and Windows it prevents the session opening any pad slot and enables no Valve HIDAPI drivers, because opening a controller is what claims the device node and a claimed device cannot be bound by a passthrough tool. Menu navigation is unaffected (the launcher still opens the active pad, and a session supersedes menu mode either way). Apple and Android claim nothing, so they gate only the wire sends; Android does stop its DualSense and Steam Controller 2 USB captures, which do claim.

- **Virtual DualSense firmware version.** The feature report `0x20` update version moves `0x0154` → `0x0999`, above anything Sony has shipped, in both blobs (host uhid and the Windows driver; the DualSense Edge shares them). The old value existed to keep the kernel and SDL on the flag0 `COMPATIBLE_VIBRATION` convention, but `parse_ds_output` has since learned `COMPATIBLE_VIBRATION2` (firmware ≥ 2.24), so writers that read the version now use the v2 flag; both conventions land in the same rumble plane.

- **Windows client settings store.** `Settings` gains unknown-key passthrough, matching the contract `SettingsOverlay::extra` already gave profiles — additive, empty on every existing store, and an empty map serialises to nothing so no file churns. The page re-bases on the file at entry, and the profile-scope commit arm reloads before cloning (it was diffing overlay absorption against stale globals). `save()` was already temp+rename.

- **Environment.** `PUNKTFUNK_PRESENT_MODE` gains explicit `mailbox` and `fifo_relaxed` arms (both previously folded into the default with every typo) and falls back to the settings rather than to mailbox on an unknown name. New: `PUNKTFUNK_PRESENTER=arrival` (disables the whole desktop pacing engine for a field A/B without a rebuild), `PUNKTFUNK_PRESENT_DEBUG`, `PUNKTFUNK_VRR_FIFO=1`.

- **Android release channel.** A `vX.Y.Z` tag now publishes to Play **production at 100%**, not alpha — production access came through 2026-08-01. Canary is unchanged on `internal`, and its run-number version codes always outrank production so testers keep the newer build. Play's "What's new" gets its own file, `docs/releases/whatsnew/vX.Y.Z.txt`, capped at 500 *characters* per language (`•` is three bytes in UTF-8, so a byte count can reject a legal file). A tag without that file fails the android job at step 1, before the build: Play does not show an empty "What's new" when the file is missing — it carries the previous release's text onto the new version, which is the same shape as the v0.22.3 notes announcing a feature that tag never contained. The gate also rejects a file byte-identical to another release's. `android-promote.yml` is the lever for promoting a tested build, halting a rollout, or rolling production back onto an older version code, with `dry_run` defaulting to true.
