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
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
flatpak / build-publish (push) Successful in 6m4s
117 commits since v0.19.2. Minor rather than patch: the headline is the session⇄game lifetime binding on both planes and both platforms — the stream ends when the game does, and optionally the reverse — plus library metadata, winget packaging for the Windows host, a 64-bit ARM Linux client, headless `--pair` enrolment, DRM card selection for the compositor-less presenter, and the ABR/latency de-escalation work, which changes runtime behaviour on every session. Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows virtual-display driver protocol at 6, so 0.18/0.19/0.20 hosts and clients mix freely and no embedder rebuild is required. The new GameMeta library fields are optional and flat on the wire. Notes authored ahead of the tag per docs/releases/README.md, so the release is born with a body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
49 KiB
Markdown
204 lines
49 KiB
Markdown
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_later`s 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 `E0061`s 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.
|