• v0.20.0
    audit / cargo-audit (push) Successful in 3m41s
    ci / rust-arm64 (push) Successful in 13m12s
    ci / bench (push) Successful in 6m53s
    windows-host / package (push) Successful in 11m47s
    ci / web (push) Successful in 1m2s
    android-screenshots / screenshots (push) Successful in 4m20s
    audit / bun-audit (push) Failing after 32s
    ci / docs-site (push) Successful in 1m28s
    arch / build-publish (push) Successful in 13m7s
    linux-client-screenshots / screenshots (push) Successful in 7m29s
    ci / rust (push) Successful in 33m52s
    android / android (push) Successful in 18m0s
    web-screenshots / screenshots (push) Successful in 3m26s
    apple / swift (push) Successful in 5m17s
    windows-host / winget-source (push) Failing after 29s
    decky / build-publish (push) Successful in 37s
    docker / build-push-arm64cross (push) Successful in 22s
    docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 29s
    docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
    docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
    docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
    docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 29s
    docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
    docker / deploy-docs (push) Successful in 24s
    apple / screenshots (push) Successful in 23m52s
    flatpak / build-publish (push) Failing after 8m46s
    release / apple (push) Successful in 27m35s
    windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
    windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m35s
    deb / build-publish-client-arm64 (push) Successful in 7m49s
    deb / build-publish-host (push) Successful in 13m51s
    deb / build-publish (push) Successful in 15m28s
    rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m31s
    rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m18s
    windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m17s
    windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m16s
    Stable

    enricobuehler released this 2026-07-26 22:33:58 +00:00 | 2 commits to main since this release

    Update whenever it suits you — the app and the machine you stream from can be updated one at a time, old and new work together in either combination, and everything you've already paired stays paired.

    The headline: Punktfunk now knows when your game ends. Quit a game and the stream ends with it — on Windows and Linux, in the Punktfunk app and in Moonlight — instead of dropping you onto someone else's desktop. Turn it around if you want, and ending the stream can close the game too. The console and the tray show what is running and who is about to close it. Underneath that sits the largest sweep of the video encoder and screen capture Punktfunk has had: machines that could never stream at all now do, an idle stream on an AMD or Intel PC has stopped sending megabytes of padding every second, 4K at 120 fps holds its frame rate, HDR from an Intel PC is finally the right brightness, and a stack of pointer, controller and reconnect bugs are gone. Windows also gets its first real update path — install and upgrade the host with winget — and there is now a client for 64-bit ARM Linux machines, which you can pair over SSH without installing a desktop on them.

    New: the stream ends when the game does

    Until now this only worked in one narrow case — a Steam game on a Linux machine running Steam's own Game Mode. Everywhere else the host launched your game and forgot about it, so finishing a game left you looking at a desktop until you disconnected by hand. It now works on Windows and Linux, for games launched through Steam, Epic, GOG, Xbox, Lutris, Heroic or added by hand, and in Moonlight as well as the Punktfunk app.

    Punktfunk waits out the usual hand-off, so a launcher that starts your game and exits is no longer mistaken for the game itself — steam://, epic:// and Playnite launches don't end the session the moment they begin. Where nothing can identify what was launched, the behaviour stays off and the host says so once, rather than guessing.

    New: and optionally, the game ends when the stream does

    Off by default, because closing someone's game is destructive. Switch it on and disconnecting closes the game you launched — useful for a shared machine or a games box you stream to from the sofa.

    It is careful about it:

    • A dropped connection is not a decision. Wi-Fi cutting out gives you a five-minute window to come back; reconnecting reclaims your game and cancels the countdown.
    • Only ever the game this session started. A copy you already had open is never touched.
    • It asks before it insists — the game is asked to close normally, and only forced if it refuses.
    • A display you asked Punktfunk to keep open stays open, regardless of this setting.

    On macOS, which has no game-launching path at all, both controls are shown but disabled — "does nothing here" is worth knowing.

    New: see what's running, and end it from anywhere

    • The console dashboard has a running-game card with box art, above the session card. "End now" ends a live game by stopping its session, or ends one already counting down after a disconnect.
    • The tray shows the running game too, including the countdown for a game whose client walked away — visible at the machine without opening the console.
    • Automation gets an event when a game starts and when it stops, filterable like every other event, so scripts can react to a game rather than to a stream. If you have been polling the host to find out when a game finished, you can stop.
    • The settings for both behaviours sit next to the display keep-alive policy in the console, which is the same question one step out.

    New: richer library entries

    Every library entry can now carry platform, description, developer, publisher, release year, genres, tags, region and player count — the things an emulation or retro library needs and a title-and-poster shape could not hold. The console's add/edit form gains a Details section, poster tiles show a platform badge and the year, and the library can be filtered by platform. Existing libraries, provider plugins and clients keep working untouched; every field is optional.

    Plugin authors: a provider entry can now also say how to recognise its games — install directory, executable, or process name — so titles launched through a provider's own client get the same start-and-end tracking as a Steam or Epic title.

    New: install and update the Windows host with winget

    Windows had no update path at all — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now, after adding Punktfunk's package source once:

    winget install unom.PunktfunkHost
    

    and winget upgrade from then on. The source is ours rather than the public catalogue, and it knows about every release, so you can pin an older version or upgrade from one.

    A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.

    New: a client for 64-bit ARM Linux machines

    Punktfunk now has a client build for 64-bit ARM Linux — the class of small board and mini-PC people put behind a TV — published to the apt repository alongside the Intel/AMD one. It is the full client, on-screen overlay included; your box picks it up automatically with no extra setup. The machine you stream from is still Intel/AMD only, because the hardware video encoders it uses are.

    New: pair a machine that has no desktop, over SSH

    A box that only has a terminal can now enrol itself:

    punktfunk-session --pair <PIN> --connect <host>
    

    It runs the same pairing handshake as the app and prints the same confirmation. Until now the pairing screen only existed inside the desktop app, so setting up a kiosk or a headless media box meant installing a whole desktop on it first — or copying credentials across by hand.

    New: pick which graphics card drives the screen on a display-only box

    On a machine with two graphics cards and no desktop running, Punktfunk's own display client used to pick a card and hope — regularly choosing the one already in use and failing to start, while the card with the actual monitor attached sat idle. You can now pin the card explicitly. And if starting the display still fails, the error names the card it tried and lists what to check, instead of a one-line internal failure.

    Fixed: computers that could never stream now can

    Six separate reasons a machine would connect and then die on its first frame — or refuse to stream at all — each with its own kind of host behind it:

    • Laptops with both Intel and NVIDIA graphics. Punktfunk assumed the first graphics device it found was the NVIDIA one. On these laptops it is the Intel chip, so setup failed with a message about graphics configuration on a machine whose NVIDIA drivers were perfectly healthy. It now looks for the NVIDIA card by identity, and every message on this path names the card it used.
    • Machines whose driver refuses the fast frame handoff now fall back by themselves. Previously that host lost every session on the first frame, and every reconnect repeated it — while the very same machine streamed fine with the fast path switched off by hand. Punktfunk now notices after three refusals and switches that machine to the slower-but-working path on its own, with a log line saying so. One bad session, then a working computer.
    • Older Intel graphics. The shared frame was handed to the driver declared as empty — most drivers work the real size out, older Intel ones answer "out of memory" on every frame instead. It now declares the true size — it is the first thing the driver reads, and it was wrong for every host we have ever handed a frame to; whether it is the whole story on the machine that reported this is not yet confirmed.
    • Linux desktops running gamescope whose graphics drivers are missing the newer built-in video encoder. Punktfunk committed the capture to a format only that encoder can take, and there is deliberately no going back once it has — so the session died at its first frame. It now checks the encoder is really there before committing.
    • Wayland desktops that hand over 24-bit frames. A common frame layout had no handling at all in one of the Linux encoders, so those sessions connected and immediately failed. They are now supported outright rather than refused.
    • PCs with no graphics card. A host encoding in software advertised HEVC and AV1 to Moonlight — which its software encoder cannot produce — so Moonlight picked one and the session died at start-up. It now offers H.264 only, honestly. In the same vein, a resolution beyond what the software encoder supports is now refused at connect, with the reason, instead of connecting successfully and never delivering a single frame.

    Fixed: an idle stream was sending megabytes of nothing

    On an AMD or Intel PC streaming from Linux — a Steam Deck included — a calm picture did not use less bandwidth. It used all of it. Once the stream settled, the driver padded every single frame back up to the full target bitrate with zeros, forever: measured on an AMD laptop chip, 300 frames of a calm desktop at 10 Mbps came to 5.63 MB, of which 98.5% was padding. The same 300 frames now come to 83 KB with no padding at all; on AV1 the padding went from 99.6% to zero.

    That padding was also convincing the automatic quality control that the connection was comfortably full, so it never eased off. If you stream over anything metered, capped, or shared, this is the single biggest change in the release.

    Fixed: 4K at 120 fps holds its frame rate

    A 4K120 stream on a fast local network climbed its way into a hole: the quality control kept raising the bitrate, the encoder quietly ran at a lower rate than it reported, and the stream kept rebuilding itself to chase a number it was never going to hit — landing at around 107 of 120 frames per second with visible stutter.

    • What the encoder actually delivers is now what gets reported — to the pacing, to the web console, and back to your device. The bitrate control no longer climbs away from a phantom number.
    • Punktfunk learns the ceiling of your hardware and stops re-discovering it the hard way — each discovery used to cost a full encoder rebuild mid-stream.
    • At 4K120 both encoder engines on the graphics card are used, instead of the second one sitting idle in exactly the mode it exists for.
    • Your device notices when the host is struggling to encode, not just when the network is, and eases off — on a fast LAN nothing else would ever have brought the rate back down.

    Improved: a stream that stumbles once no longer stays degraded

    When a stream hits sustained trouble, Punktfunk trades a little latency for stability — buffering more, and switching the encoder into a mode that keeps up under load. That trade used to be permanent: one rough patch, however brief, and the session ran at higher latency until you reconnected.

    It now winds back. After a sustained stretch of clean, on-time frames the stream steps back down — first to the low-latency encoder mode, then to minimum buffering — and it retries progressively less often, so a machine that genuinely needs the trade settles into keeping it rather than flapping.

    Fixed: garbled picture on 10-bit PyroWave streams

    Streaming in PyroWave at 10-bit produced complete corruption on the client — scrambled tiles and saturated green, at any bitrate or refresh rate, with no error anywhere and clean network counters. Reported on an AMD host into an AMD laptop client; milder versions of the same fault were possible on NVIDIA clients. The client was reading the decoded picture through the wrong description of its own buffers, which most drivers execute anyway rather than refusing. 8-bit sessions were never affected.

    Fixed: HDR

    • From an Intel PC, HDR is the right brightness. The video was tagged with a peak brightness ten thousand times too low — 0.1 nits where 1000 was meant. TVs and monitors tone-map from exactly that number, so the picture arrived wrong at the far end. Confirmed fixed by reading the tags back out of a real Intel-encoded stream.
    • Moonlight no longer gets an HDR label on an SDR picture. If HDR capture failed to start — a timed-out negotiation, a monitor that left HDR mode at the wrong moment — the host went on telling the client the stream was HDR while capturing and encoding ordinary colour. The client then renders it as HDR: washed out, wrong colours, no error, and every reconnect repeated it until the host was restarted.
    • A graphics card that cannot encode 10-bit no longer rebuilds the encoder on every single frame when HDR content is streamed to it — a full teardown and rebuild per frame.
    • When switching the streamed display into HDR fails, the session survives. The capture correctly falls back to ordinary colour, but the encoder kept insisting on HDR — one of the three Windows encoders then failed every frame forever, and the session ended after burning through its recovery budget. The encoder now follows the pixels it is actually handed, and labels the stream honestly.
    • On a Windows machine with its own dedicated streaming display, a mismatch between what the display was composing and what the encoder expected could leave the session in a permanent three-second reconnect loop.

    Fixed: the mouse pointer

    • On iPad, iPhone and Mac, the text cursor stopped disappearing. A pointer whose new picture had not arrived yet was hidden outright rather than left as it was — routine, because the host announces a new pointer shape the moment it sends it. Reported on glass as "the I-beam never appears over text fields, every other cursor is fine".
    • On Windows, a pointer three times too large is fixed. Windows quietly re-draws its cursors at a new size when display scaling changes, behind an unchanged internal handle — so Punktfunk latched whatever size the pointer had a second after the streamed display appeared, which is exactly when the scale is still settling, and streamed that size for the whole session.
    • Sessions that streamed with no pointer at all now have one. Some encoders cannot draw the pointer into the video; the host now works that out before capture starts and asks the desktop to draw it instead, rather than warning and streaming a pointerless picture.
    • A stray space in a configuration file could make the pointer vanish. A setting written as =0 — with a trailing space, as an editor or shell script leaves behind — was read as the opposite of what it said, switching on a mode that cannot draw the pointer at all.
    • In Steam's Game Mode, the pointer lands where it should, no longer drawn at a fraction of its real position when the game runs at a different resolution than the stream — and it no longer disappears for the rest of a game session when the game opens its own window.
    • AV1 at 1080p is now genuinely 1080p. It was shipping eight rows of duplicated pixels along the bottom edge; playback now reports the true size and the padding is gone rather than cropped back off.
    • A visible pointer no longer costs you frame rate. On a Linux machine with an NVIDIA card running Steam's Game Mode — where the pointer is drawn into every frame, unlike a normal game — a 120 fps session was capped at around 80, with the graphics card barely working. Drawing the pointer was making the encoder wait for the game's own work to finish, on every frame. It doesn't any more.

    Fixed: your controller type setting is honoured again

    Choosing "emulate my controller as a DualShock 4" reached the host and then stopped working the instant the controller actually connected — every pad re-declared itself as what it physically was, so the setting appeared to do nothing. This affected the Punktfunk apps on Apple platforms, Windows, Linux and macOS (Android already did this correctly), and it has been broken since multi-controller support shipped in 0.19.x. Your explicit choice now applies to every controller slot; Automatic still detects each pad individually so a mixed session stays honest. Your controller keeps its own local features either way — a DualSense emulated as a DualShock 4 keeps its lightbar.

    And Windows Device Manager now names each emulated pad correctly — all four types previously showed up as "punktfunk Virtual DualSense", which reads exactly like the setting being ignored.

    Fixed: Windows machines with a dedicated streaming display

    • The dedicated display stays dedicated. Punktfunk switched the physical monitors off, verified it, and never looked again — and on laptops with two graphics chips the internal panel came back moments later on its own. The host still believed it had the screen to itself, so windows and the mouse could land off-stream and the lock screen could appear on the physical panel. It is now re-checked every couple of seconds while the session runs, and put back if something re-lights a display.
    • Choosing a graphics card in the console no longer wedges the host. If your host configuration file pinned an encoder belonging to a different make of graphics card than the one you picked, every session failed at the same point and the client reconnected into the identical wall roughly every ten seconds, with no visible reason. The card you pick now wins, and the console shows the pin with a warning when the two disagree.

    Fixed: streaming from a Linux desktop, session after session

    Four faults that all shared a shape — the first session worked, and something never came back afterwards:

    • Each Moonlight reconnect leaked a screen-recording session with your desktop, until the compositor started refusing new ones — the very "another app is already recording" clash the pooling was there to prevent.
    • One compositor restart could wedge Moonlight video permanently, ten seconds per reconnect attempt, until the host was restarted: a dead capture was put back in the pool and handed out again forever.
    • One failed negotiation switched the whole machine off the fast capture path — every later session, on every encoder, until restart. It is now scoped to the one thing that actually failed.
    • A stalled moment cost you the wrong frames. Under load the capture kept the oldest eight frames and discarded everything newer, so the picture caught up through stale frames instead of jumping to the current one. It now always hands over the freshest frame. A reused capture also no longer opens the next session showing the last frame of the previous one.

    Fixed: Moonlight

    • Moonlight returns to its app list when the host ends the session. Whatever ended it — your game exiting, someone pressing stop in the console, another client taking over — the client simply froze on its last frame and eventually reported the session as a connection failure, because the host tore everything down without ever saying it had. It now says so, and Moonlight lands you back in the app list exactly as quitting does.
    • A wedged encoder no longer ends the stream. If the graphics card's encoder locked up mid-stream, Moonlight clients got a full disconnect and had to reconnect. The Punktfunk app has recovered in place from this for a while; the Moonlight path now does the same, with the same bounded retries and backoff.

    Fixed: launching a game into a fresh Steam session streams from the first second

    On a Linux machine where Punktfunk starts a dedicated Steam session for you — rather than attaching to one already running — launching a game showed you nothing at all, and the attempt was retried from scratch and eventually given up on. The cause was a chicken-and-egg: that display only produces a picture when something on it is drawing, and Steam draws nothing at all until its interface first appears, which takes far longer than the time allowed for a first frame. Sessions that reused an already-running display worked, which pointed away from the real cause for a long time.

    Punktfunk now puts a quiet loading screen up from the first second, so there is always something to stream while Steam boots. The dedicated session also comes up in Big Picture now, instead of the desktop Steam window flashing through your stream on the way to the game.

    Fixed: an idle computer pinned a whole CPU core

    Punktfunk's script and plugin runner burned 100% of one CPU core doing nothing — in exactly the state its own documentation calls inert, with no scripts and no plugins installed. One report had it at 99.9% for two hours on a small laptop, starving the desktop badly enough to blank the screen. Three seconds idle used to cost three seconds of CPU; it now costs a tenth of that, all of it start-up.

    Fixed: Steam Game Mode takeover on Nobara and KDE machines

    The 0.19.2 fix for switching a Linux machine into Steam's Game Mode needed a permission that was only ever a manual, documented step — so on a normal install it always fell back to mirroring, and with a monitor attached you got the desktop's own resolution mirrored instead of a proper takeover. The permission now ships with the packages, so entering Game Mode mid-stream takes the screen over as intended, and everything it changed is put back when you disconnect.

    Punktfunk also now warns in its log when the installed gamescope is too old to support properly, instead of failing in ways that look unrelated.

    Fixed: the Windows installer stops undoing your choices

    • Re-installing or upgrading keeps your settings. Both the Moonlight-compatibility option and the "allow Punktfunk through the firewall on Public networks" option were re-applied from scratch on every run — so an upgrade turned Moonlight support back off for anyone who had switched it on, and quietly closed the Public-network firewall rule for anyone who had opened it. Both are now decided on a fresh install only.
    • An unattended install can't stall on an invisible dialog. The warning about another streaming host being present (Sunshine, Apollo) was a message box that appeared even in silent mode, so an install nobody was watching sat on a dialog nobody could click. It now declines and stops, which is the honest answer for a combination that warning already calls unsupported.
    • It calls itself "Punktfunk Host" in Add/Remove Programs, the Start Menu group and the wizard, instead of the internal name. Install locations are untouched.

    Fixed: the tray icon, and the Windows web console after a fresh install

    • The tray said "Idle" through an entire stream. It only counted Moonlight-style sessions as streaming, and ordinary Punktfunk sessions — the default — never registered. The icon and tooltip now track any live session.
    • "Open web console" no longer disappears from the tray menu. It was built only if a live check of the console happened to answer within two seconds, so a console still starting up simply had no menu entry — with no discoverable way in. It is always there now, and says "(not responding)" when it genuinely is not.
    • No more false "conflicting host" alarm. The tray warned about Sunshine being installed — not running, not listening — on every poll, and on Linux flagged the icon for attention over it. The host still reports genuine conflicts where that belongs.
    • New "Release kept display…" entry when Punktfunk is holding a display open, and the menu entries deep-link to the pairing and display pages instead of all landing on the dashboard.
    • A fresh Windows install left the web console down until the next reboot. The installer started it before the files it needs existed, the launcher exited, nothing retried — and the installer still reported success. It now waits for what it needs, verifies the console really came up, and says so honestly if it did not. The Linux service had the same flaw and would give up permanently after five quick restarts.

    Under the hood (for developers)

    • Nothing changed on the wire or at the API boundary. The streaming protocol stays at version 2, the embeddable core library stays at C ABI 13, and the Windows virtual-display driver protocol stays at 6 — no embedder rebuild, and 0.18/0.19/0.20 hosts and clients mix freely. The library's new GameMeta fields are optional and flat on the wire, so existing library.json files, provider plugins and clients are unaffected; GET /library gains a case-insensitive ?platform= filter beside ?provider=, and the SDK and OpenAPI spec are regenerated. New game.running / game.exited events are filterable like every other kind, and /status reports games[] including a game whose session has gone and which is waiting out its window, with POST /game/end to end it.
    • Session⇄game lifetime. DetectSpec (install dir / exe / process name / appid / env marker) comes from each store's scanner and from an optional provider detect hint; procscan turns it into live pids per OS; gamelease turns pids into a lifetime with four kinds — nested (gamescope owns it), child (host-spawned, own process group), matched (a launcher owns it, recognised by store signals) and untracked. A child exiting successfully within 5 s is a launcher handing off, and re-resolves to matched rather than reporting an exit — that single rule is what keeps steam://, epic:// and playnite:// from ending a session at launch. A pid is adopted only if it started after the launch, and re-verified against its start time immediately before being signalled, so a recycled pid is never touched. Provider-supplied hints never win over the host's own findings, and a blank field is absent rather than match-everything (an empty install dir would prefix-match every process on the box, and this feature ends processes). On Windows: Toolhelp + QueryFullProcessImageNameW + GetProcessTimes, \\?\-normalised case-insensitive path matching, Steam's per-app Running flag used only as a veto (it cannot be trusted to say a game is running, but it is exactly right for refusing to declare one gone), and the terminating thread binds to the input desktop before EnumWindows/WM_CLOSE — the host's own desktop is session 0 and holds none of the user's windows, so without the bind the polite pass finds nothing and every game dies unsaved. Job Objects are deferred with reasoning in the design doc. GameStream gained a session quit flag (RTSP carries no close code, so /cancel, a management stop and a game exit were indistinguishable from a client vanishing), and both planes now resolve a launch through one resolve_launch. Two on-glass runs each found a defect the fixtures could not: on Linux (.41) a Child lease left its waiting phase on the FIRST poll, because a live child counts as the game running — and the shim reclassification lives in that phase, so every Steam launch over the compat plane ended the session ~7 s later when steam handed off and exited; a bare live child now only counts once the shim window has passed, where the store gave us signals to recognise the real game by. On Windows (.173) procscan::launch_stamp's wrapper was gated #[cfg(target_os = "linux")] and answered None everywhere else — and None doesn't fail, it turns the filter OFF, so every process under the install dir became adoptable and a deliberate stop closed a pre-existing copy of the game. The guard added there asserts the reference exists wherever processes can be matched, which is the shape of that failure: silent, and invisible downstream.
    • Vulkan encode moves from CBR to VBR, which is the whole filler story: Vulkan exposes no filler-suppression control (no equivalent of AMF's filler_data=false or NVENC's default-off), so the rate-control mode is the only lever, and CBR under the shipped 1000 ms window overflows the CPB once the initial fill drains (~30 frames at 10 Mbps/60 fps) and pads every frame to the exact rate share thereafter. VkVideoEncodeCapabilitiesKHR::rateControlModes was previously ignored — CBR was hardcoded with no capability check; VBR now installs with average == max plus the house ~1-frame window when advertised, CBR-only drivers keep the loose window (tightening it just starts the stuffing earlier), and drivers advertising neither (ANV per current Mesa) keep the old install with a WARN. PUNKTFUNK_VULKAN_RC=cbr|vbr is the escape hatch and the on-box A/B control. No pacing claim: a burst A/B on the 780M is byte-identical between 1000 ms CBR and 17 ms VBR, so this firmware ignores the window for QP decisions entirely — the payload is filler elimination. ⚠ The first attempt at this was withdrawn after measuring a "36× bandwidth regression"; that measurement's baseline row was an 8-frame artifact, and the stuffing it feared was already live in shipped code. maxBitrate is now read from the same caps struct and clamps open + retarget, and applied_bitrate_bps() reports encoder-side truth pending-first.
    • Phases 3–8 of the pf-encode audit landed here. Highlights not in the body: ensure_cpu_rgb cached its staging image on format alone while sizing it to the source (8× VUID-vkCmdCopyBufferToImage-imageSubresource-07971 with submit returning Ok throughout); reset() re-armed first_frame, which also gated the begin-video-coding rate-control declaration (VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253); AV1 at unaligned modes violated VUID-vkCmdEncodeVideoKHR-flags-10324/-10325 on every frame under RGB-direct, fixed by making the sequence header, DPB setup and reference slots agree at the render size while keeping the EFC fast path; VK_EXT_queue_family_foreign was named as the dmabuf acquires' source family on four sites without ever being enabled (spec-invalid everywhere, tolerated by RADV) and is now enabled when advertised with a core-1.1 substitute otherwise; and a one-frame VUID-...-08254 where a pending retarget was promoted into the session rate before recording, which correlated with the two triggers that fire together (ABR retarget + stall watchdog).
    • NVENC split-encode vs sub-frame is now arbitrated against nvEncodeAPI.h's own doc rather than the audit's one-liner: H.264 hard-disables split (it "is not applicable", so the rejection-retry used to re-open a byte-identical session), HEVC yields sub-frame when we force split for 4K120 throughput — keyed on the FORCED modes only, never != DISABLE, since AUTO is the resolver's fallthrough for every sub-950 Mpix session and the wider key would have disarmed chunked polling fleet-wide — and AV1 is untouched. libav-NVENC open failures are classified by typed errno rather than by an English strerror substring over the whole context chain.
    • The pf-capture sweep (Phases 0–6). Phase 0 put the crate under CI at all (-p pf-capture --all-targets on Windows: its #[cfg(test)] modules were compiled by nothing) and added deny(unsafe_op_in_unsafe_fn) — 119 operations across 16 functions gained SAFETY proofs, because in edition 2021 the existing undocumented_unsafe_blocks deny has nothing to fire on inside an unsafe fn, exempting the hardest FFI in the crate from its own program. Then a truth pass over comments and log strings, then the defect fixes: the portal thread parked on future::pending() so every dropped capturer leaked a thread, a 2-worker tokio runtime, a zbus connection and the compositor-side cast; Capturer::is_alive() now gates re-pooling; the "VAAPI" downgrade latch was really a global zero-copy kill switch fed by a hand-mirrored copy of spawn_pipewire's negotiation decision, now one negotiation_plan resolver consumed by both; the frame channel was sync_channel(8) + try_send (drop-newest) against a trait documenting drop-oldest, replaced by a one-deep overwriting mailbox; four Linux buffer-geometry defects including a self-mmap that ignored spa_data.mapoffset and read the wrong buffer out of a pooled memfd; and on Windows the HDR pin wrote the desired state in place of the observed one (a permanent 3 s reconnect loop in one direction, a silently mismatched ring in the other), plus cursor rect staleness, recreate ordering, LocalAlloc/NT-handle leaks, and an f32_to_f16 that swallowed the rounding carry (1.9998779 → 1.0) and so failed a correct shader. Phase 5 split linux/mod.rs 2,778 → 770 lines; Phase 6 took the suite from 6 to 38 Linux + 19 Windows tests.
    • Cursor blending is negotiated ahead of capture. EncoderCaps::blends_cursor's contract said the host must fall back to capturer-side compositing, and that host half was never built — open_video warned and streamed pointerless (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths). cursor_blend_capable() is the pre-open dispatch mirror; the native plane grants the cursor channel only where the resolved backend composites, SessionPlan::output_format keeps cursor-blend sessions off producer-native NV12, Vulkan RGB-direct yields even when pinned, and the GameStream portal source asks for cursor-as-metadata only when the backend blends (the capturer pool keys on that mode). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback.
    • The GameStream plane can now say a session ended. end_session stops the media threads, and stopping a UDP sender is silence, not a signal — Moonlight holds the ENet control stream for the whole session and sat on its last frame until its own timeout fired. The control loop now watches for its session being cleared out from under it, sends the TERMINATION control message with NVST_DISCONN_SERVER_TERMINATED_CLOSED, then disconnect_laters the peer so the queued message reaches the wire instead of racing teardown. Two details that are silent when wrong, both verified against moonlight-common-c's ControlStream.c rather than recalled: the reason is BIG-endian on the ≥6-byte branch (the short branch is a little-endian u16, GFE's older shape), and the packet type comes from the table the CLIENT chose — encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431), and we advertise 7.1.431, so it is 0x0109, not 0x0100. Deriving that from NonceKind was the error; the nonce scheme describes how the GCM nonce is built and nothing else, and the HDR message could never have caught it because 0x010e is identical in both tables. A test pins APP_VERSION above 7.1.431, since dropping below it silently restores the wrong type. The session's GCM key is cached per tick because ending a session clears the launch state the key lives in.
    • The direct-NVENC cursor blend is stream-ordered. A cursor-bearing frame forced the CPU-synced submit path — a blocking CUDA copy plus a fence-waited Vulkan blend, both exposed to the running game's GPU load — on the assumption that games hide the pointer, which does not hold under gamescope, where the host composites the live pointer into every frame. Reported as an iPad on a 120 fps NVIDIA/gamescope session capped at ~80 fps with repeat_fps 0, zero loss, capture 0 µs, ASIC 15 µs and submit p50 at 10.2 ms — 81% of the loop period. VkSlotBlend now exports a timeline semaphore (VK_KHR_timeline_semaphore + external_semaphore_fd) into CUDA via cuImportExternalSemaphore: the enqueued copy signals it on the encode thread's copy stream, the blend waits and advances it on the Vulkan queue, and a CUDA-side wait orders the encode after the blend on the session's bound IO stream. Per-slot command buffers and descriptor sets keep several ordered blends in flight; drivers without the timeline export keep the CPU-synced path, and any failure degrades to "no cursor", never a dropped frame. The blocking multi-plane copies also pay one stream sync instead of one per plane (NV12 2→1, YUV444 3→1). Verified on an RTX 5070 Ti (driver 610.43.03): 12 on-hardware smokes green.
    • The GameStream HDR SDR-downgrade latch had exactly one consumer (open_portal_monitor, dropping the HDR offer) while the RTSP negotiation consulted only gnome_hdr_monitor_active() — so a set latch meant advertising HDR while capturing and encoding SDR, which the client renders as PQ. Consulted at RTSP honor time rather than folded into host_hdr_capable(), which is the static serverinfo capability.
    • Phase 7 consolidations. One Linux backend resolver (resolve_linux_backend) consumed by dispatch and the five partial hand-copies, matching what Windows has had; the slot-family and range-family RFI recovery policies extracted from three and two hand-copied twins respectively (the taint sweep had already reached AMF/QSV a commit before Vulkan was carved out, so Vulkan shipped without it); the AMF C-ABI mirror moved to amf_sys.rs; vulkan_video.rs's ~820-line construction tail split into vk_build.rs (5,292 → 4,489 lines) — ⚠ with a trap recorded for future splits: an inline use super::X inside a moved fn body silently changes meaning. TrackedEncoder's forwarding completeness is now guarded by a source-text set-equality test, a trap that has bitten three times (set_wire_chunking, set_pipelined, applied_bitrate_bps — a defaulted method the wrapper doesn't forward silently no-ops for every session).
    • PyroWave's GPU selection stays first-usable, by decision. Two selection designs died in adversarial review — matching pf_gpu::selected_gpu() moves the encoder off the iGPU that can import the compositor's dmabufs on an Intel-compositor + NVIDIA-present laptop, and anchoring on the render node picks the idle iGPU on the common AMD-iGPU + NVIDIA-display desktop, because render minors are driver-bind-order artifacts. The correct oracle is which device allocated the capture buffers, which needs per-session producer identity threaded through; until then the open logs one greppable line (picked vendor/device, anchor node and its owner, and the console's selected GPU) with no WARN arm, since the wrong-pick direction inverts between topologies.
    • New tooling. scripts/wincheck.sh type-checks and lints #[cfg(target_os = "windows")] Rust from a Linux box — a generated workspace whose members symlink src at the real crates plus a ~30-line stub punktfunk-core, keeping rustls/ring/opus out of the graph (the in-tree --target x86_64-pc-windows-msvc command dies in audiopus_sys and ring build scripts). Verified non-vacuous against a planted type error. Windows CI now runs pf-capture's tests rather than only type-checking them (18 of 19; the crate has no encoder dependency, so its test binary links against nothing the runner lacks). Also: hdr-p010-selftest silently ignored its size argument (skip(2) where the arm's own optionals start at index 1), so it had only ever validated 64×64 while printing PASS for 1920x1080.
    • aarch64 Linux client. deb.yml cross-builds it on the ordinary amd64 runner via a new punktfunk-rust-ci-arm64cross image; the job hard-fails on a non-AArch64 session binary. skia's aarch64-linux-gnu textlayout+vulkan prebuilt resolves, so ARM ships the full client, OSD included. ci.yml gains an aarch64 clippy leg plus a --no-default-features session build — c_char signedness cuts both ways and neither direction is visible from an x86-only CI (hardcoding i8 fails to compile on ARM; as *mut u8 fails the lint there), and it has already caught two defects. The RPM spec gained %bcond_without host (verified by a real rpmbuild in a native aarch64 Fedora 43 container), the Arch PKGBUILD drops punktfunk-host from pkgname on aarch64, and the flatpak manifest is architecture-generic — but no aarch64 rpm/Arch/flatpak is published yet, and those builds are not cross-compiles.
    • Encode CI coverage. nvenc and vulkan-encode were previously linted by nothing — enc/linux/nvenc_cuda.rs, enc/linux/vulkan_video.rs and the vendored vk_av1_encode / vk_valve_rgb bindings, ~8,150 lines carrying ~70 unsafe blocks, so the crate's own #![deny(clippy::undocumented_unsafe_blocks)] was never enforced on them. Linux now lints and tests at the shipped feature set and Windows gets a -p pf-encode --all-targets leg (clippy rather than tests: MSVC link-imports the NVENC entry points). --all-targets is load-bearing — without it the feature-gated #[cfg(test)] modules are never compiled, which is how ten E0061s rotted undetected in the direct-NVENC test module. VAAPI and the Windows ffmpeg fallback gained unit-pinned decision logic (1,300 and 1,400 lines, 26 and 23 unsafe, zero tests before).
    • The 19 vendored #[repr(C)] Vulkan structs gained const size/alignment/per-field-offset assertions, plus three tests pinning bitfield member order (where a wrong index means the driver reads use_superres where we meant render_and_frame_size_different). Field order was diffed against vulkan_core.h and vulkan_video_codec_av1std_encode.h from Vulkan-Headers main as of 2026-07-25 — no drift.
    • Windows NVENC teardown and session accounting. Completion events are pushed to the teardown list before registration; a retrieve thread that has already blown one full 5 s completion budget drops later drains to 250 ms slices (routine teardown byte-identical — nothing is ever abandoned); and LIVE_SESSION_UNITS is refunded only on proof the driver released the session, with ambiguous failures parking the handle and init_session retrying the destroy under a gate that serialises against session opens.
    • Resource-lifetime work across the Linux encode paths. Every dmabuf-import failure after create_image leaked a VkImage and a dup'd fd — with the sharp edge that a successful vkAllocateMemory transfers fd ownership, so the naive close-on-error is a double close. PyroWave's open_inner had ~20 fallible steps each leaking everything before it, now constructed early with null resources so Drop is the single unwind path. sws_getContext leaked on both of open's early returns — up to ~10 per session on the EINVAL bitrate ladder. The PyroWave NT-handle contract is pinned callee-side (patch 0006), so import_plane's close-on-failure is correct on every path.
    • Measured performance work. The RGB-direct CPU upload builds the padded frame straight into mapped staging memory: 1080p submit p50 2315 → 1725 µs, p99 2819 → 1972 µs on a 780M, with an aligned-mode control run showing no delta. The packed 24-bpp expand moved from a w*h per-pixel loop to swscale's SIMD expanders (8.3M iterations per 4K frame, on the encode thread). Three per-frame costs came off the Windows HDR path, two of them inside the ring slot's keyed-mutex hold — plane RTVs and an immutable constant buffer are lifetime-of-mode facts, and a CCD sdr_white_level_scale query has no business contending the display-config lock while the driver's publisher is blocked. std::env::var came off three per-frame paths — worth recording that the audit ranked this as a hot-path defect and it measured ~122 ns/frame, i.e. 0.003% of a frame budget; the fix is right, the severity was not.
    • Bitrate honesty end to end. Encoder::applied_bitrate_bps() exposes the post-clamp rate; the codec-level ceiling is cached per GPU/config so an overshoot opens at the ceiling instead of re-running a ~6-open binary search plus a rebuild + IDR; SPLIT_FORCE_PIXEL_RATE moved to 950 Mpix/s because 4K120 (995,328,000 px/s) missed the old > 1e9 gate by 0.47% and stayed on AUTO, which never engages at 2160 px height. Client-side, two consecutive identical short acks latch a host rate cap (mode-scoped, re-probed after ~60 s parked clean), and the per-AU 0xCF encode_us feeds the controller through its own window accumulator, baseline-relative. The latency escalation de-escalates on a ~5 s clean run at 120 fps with 1 → 5 → 25 min backoff; PUNKTFUNK_NVENC_ASYNC=1 refuses the wind-back.
    • NV_ENC_ERR_INVALID_VERSION is split on a SESSION_OPENED latch. A version skew is static and cannot come and go inside one process, so a host that streams once per boot and then fails every later session is told to restart the host service, not to reboot. Diagnosis only — that field bug's root cause is still open.
    • A bare gamescope spawn now backgrounds a splash client. gamescope composites — and only on a composite pushes a capture buffer — when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame, far past the 10 s first-frame budget; the native plane's retry ladder then killed the half-booted Steam on every attempt and the GameStream plane died on its single wait. Root-caused on .41 with a raw pw_stream probe: sleep infinity nested → 0 buffers ever, vkcube nested → 60/s immediately. The new hidden gamescope-splash subcommand paints a breathing bar at ~2.5 Hz, so damage arrives from the first second on both planes. In --steam mode gamescope composites only windows whose appid is in the root GAMESCOPECTRL_BASELAYER_APPID list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares STEAM_GAME=769 and seeds the baselayer iff unset — Steam's own rewrite at game launch hands composite focus over with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 opts out. The dedicated launch also uses -gamepadui rather than -silent, since the nested Steam is Big Picture — the identity gamescope's --steam integration is built around — and gamescope's focus rules (game outranks the Steam UI appid) cover what -silent was working around.
    • The DM-stop takeover ships its privilege. libexec/punktfunk/pf-dm-helper (verbs stop|restore) behind io.unom.punktfunk.dm-helper (allow_any, the same mechanism Nobara's os-session-select uses); the helper derives the DM unit from the display-manager.service symlink so callers never name a unit across the privilege boundary. Packaged in rpm/deb/arch; Nix keeps the manual polkit rule because store paths cannot match the probe.
    • Other knobs and internals: PUNKTFUNK_DRM_CARD=<n> pins SDL's KMSDRM device index; PUNKTFUNK_ZEROCOPY_RENDER_NODE overrides the NVIDIA render-node scan; PUNKTFUNK_EXCLUSIVE_REASSERT_MS (default 2 s, 0 disables) paces the Windows exclusive-topology watchdog; PUNKTFUNK_VULKAN_RGB_DIRECT and PUNKTFUNK_PIPEWIRE_NV12 now share pf-host-config's env_on() grammar with four in-crate copies (both previously read a trailing space as force-ON); the libav log-level save/restore around the capability probes is one RAII guard over a shared mutex (four overlapping sites — interleaved save/restore pinned the process at AV_LOG_FATAL permanently); PyroWave's RDO block-index cap moved into validate_dimensions checked against 4:2:0 (8192×8192 = 98304 blocks overflows the 16-bit packing, and the host's only use of the helper actively routed oversized modes into the unguarded branch); the write-only EncoderCaps::supports_hdr_metadata is deleted (both planes send the static HDR grade out-of-band unconditionally and no first-party client parses in-band SEI); and Encoder::flush is documented as deliberately not wired into the production loops. Also, @punktfunk/host's publish workflow is unblocked twice over: bun 1.3 installs a file: dependency by symlinking each top-level file to itself, so package.json arrived as a dangling self-reference and resolution died at the front door — and the step added to repair that was itself named Repair the file: dependency (…), where the unquoted file: reads as a second YAML mapping key, so the workflow had been unparseable (and therefore silently never running) since it landed.
    • winget packaging. Manifests ship in winget-pkgs' own format so upstream submission is later a copy rather than a rewrite, with a release-time generator substituting only version, URL, hash and notes link. The source is self-hosted — the community repo gates on Defender/SmartScreen validation the self-signed installer cert would not clear today — and implements three endpoints (/information, /manifestSearch, /packageManifests/{id}); the reference implementation's other twenty are its admin API for mutating a CosmosDB, and a catalogue generated at release time has nothing to mutate. It runs on unom-1 as a stock bun image with two bind-mounted .mjs files, the same shape as the flatpak server. The catalogue is derived from the RELEASES, not local files, because winget resolves --version and upgrade against the version list — a source that knew only the newest release could neither pin an older one nor show an upgrade path from it. NormalizedPackageNameAndPublisher is declared unsupported on purpose (winget derives it client-side with its own normalization, and a near-miss silently mis-correlates an installed host; ProductCode is exact and Inno gives us one). 28 checks drive the handler directly and CI gates on them, because a wrong response shape does not fail loudly — it just makes winget report "no package found".
    • Installer idempotence. --gamestream and --allow-public-network become fresh-install-only, keyed off whether host.env already exists; service install already read an absent --gamestream as "keep host.env as-is", and the public-network flag becomes tri-state the same way, resolved from the marker the previous install recorded. It is strict on the value: a typo'd =of must not fall through to a marker that may say true, which would turn a mistyped opt-OUT into leaving Public open. The conflicting-host warning moves from MsgBox (which ignores /SUPPRESSMSGBOXES and displays under /VERYSILENT) to SuppressibleMsgBox with an IDNO default.
    Downloads