Files
punktfunk/docs/releases/v0.22.0.md
T
enricobuehlerandClaude Opus 5 b2cf4e908c
audit / bun-audit (sdk) (push) Successful in 17s
audit / bun-audit (web) (push) Successful in 17s
audit / docs-site-audit (push) Successful in 17s
audit / pnpm-audit (push) Successful in 11s
audit / bun-audit (plugin-kit) (push) Successful in 3m15s
audit / cargo-audit (push) Successful in 3m24s
android-screenshots / screenshots (push) Successful in 3m40s
apple / swift (push) Successful in 5m0s
audit / license-gate (push) Successful in 7m24s
decky / build-publish (push) Successful in 37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
arch / build-publish (push) Successful in 19m5s
linux-client-screenshots / screenshots (push) Successful in 10m39s
sbom / sbom (push) Successful in 1m16s
docker / deploy-docs (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
windows-host / package (push) Successful in 14m34s
windows-host / winget-source (push) Successful in 17s
android / android (push) Successful in 10m16s
web-screenshots / screenshots (push) Successful in 10m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
deb / build-publish (push) Successful in 13m51s
deb / build-publish-host (push) Successful in 5m27s
deb / build-publish-client-arm64 (push) Successful in 4m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 27m37s
release / apple (push) Successful in 28m45s
apple / screenshots (push) Successful in 21m19s
ci / docs-site (push) Successful in 1m4s
ci / web (push) Successful in 2m46s
ci / rust-arm64 (push) Successful in 3m41s
ci / rust (push) Successful in 3m54s
flatpak / build-publish (push) Successful in 4m24s
chore(release): bump workspace version to 0.22.0
256 commits since v0.21.0. Minor, not patch: the headline is settings profiles —
named bundles of overrides bound per host, landing on all five clients at once
(Linux, Windows, Apple, Android) with one settings surface editing either layer,
marked-and-resettable override rows, per-profile colours, host bindings, one-off
"Connect with", and pinned host+profile cards. With them: the punktfunk:// link
grammar (one parser, one 44-case vector file run by the Rust, Swift and Kotlin
suites), double-clickable shortcuts, and `punktfunk` — one headless front-end
over the brain layer, which wakes a sleeping host the way a card click does.

Also: opt-in HDR on the gamescope path plus the cursor-in-the-node patch that
makes those sessions zero-copy; Vulkan Video 10-bit so AMD/Intel HDR keeps the
good path; a zero-copy NVENC HDR leg; host OS detection with marks on every
client and the console; PUNKTFUNK_HOST_NAME, PUNKTFUNK_MAX_FPS and
PUNKTFUNK_VDISPLAY_HZ_MULT; monitor enumeration on Windows; and the release-
integrity work (per-asset SHA256 sidecars, a signed sysext feed, one stable
driver publisher identity, fail-closed signing guards on a v* tag).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows virtual-
display driver protocol at 6 — 0.18-0.22 hosts and clients keep mixing freely.
The Windows virtual-GAMEPAD channel protocol goes 2 -> 3 and fails closed both
ways: it carries the fix for a LocalService principal being able to take over a
pad's shared input section and forge HID input into the interactive desktop, so
the host and its drivers must ship together. The installer ships both.

Additive elsewhere: an advisory mDNS `os=` TXT key, HostInfo.os/os_name,
MonitorsResponse.pin_supported, an eighth field on the Android JNI discovery
record, and appended-last StoredHost fields per the frozen app-widget contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:39:58 +02:00

43 KiB
Raw Blame History

Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working. One exception, on Windows only: the host and its virtual-controller drivers now have to match each other. Installing or updating the host the normal way takes care of both; if you ever end up with a mismatched pair, controllers stop attaching until the drivers are updated too.

The headline is settings profiles. Until now every client setting was global, so the 4K@120 you picked for the desktop upstairs followed you to the retro box in the basement. You can now make named profiles — Game, Work, Couch — and bind one to each host, and they arrive on every client at once: Linux, Windows, Mac, iPhone, iPad, Apple TV and Android. Alongside them: punktfunk:// links and double-clickable shortcuts that open a stream on a host you already trust, a new punktfunk command for scripts and plugins, and — on Linux — real HDR when you stream Steam's Gaming Mode.

New: settings profiles — your settings, per host

A profile is a named bundle of the settings you want to be different, and only those. Anything you don't touch keeps following your defaults, so fixing a global setting once fixes it everywhere; anything you do touch stays put even if you later change the default. The only way back to inheriting is an explicit Reset, which every changed row offers.

There is one settings screen, not two. A switcher at the top swaps the whole screen between Default settings and one profile — same categories, same rows, same explanations — and every row shows the value that is actually in effect. Rows a profile changes are marked, so which settings a profile touches is legible without reading it against your defaults. Rows that are facts about this device rather than about Game-vs-Work — which decoder, which speakers, which controller is forwarded, auto-wake — simply aren't offered in a profile.

On the host side, three separate things you can do with a profile:

  • Bind one to a host. The card wears a chip naming it, so what a plain click will do is visible without opening anything.
  • Connect with one, just this once. A one-off from the card's menu that never changes the binding — connecting with Work today doesn't mean Work tomorrow.
  • Pin a host + profile as its own card. Desktop · Work sits in the grid next to Desktop, one click away. It's a shortcut, not a second host, so pairing, Wake-on-LAN and renames stay on the primary card. On Apple TV and Android TV the same pins are tiles, which is what makes profiles usable where menus are not.

Profiles get a colour you pick when you name them, and it follows them everywhere the profile is named — the switcher, the card chips, the in-stream overlay. Deleting a profile never breaks anything: hosts bound to it fall back to your defaults and its pinned cards stop appearing.

Two long-standing annoyances go away with this. The speed test now writes its result into the layer the host you tested actually reads its bitrate from — measuring the slow box downstairs used to quietly re-tune your desktop — and every button says where it will write before you press it. And Android finally has a speed test at all, along with a per-host clipboard switch and full parity with the settings the other clients had.

punktfunk:// links now work on every client. A browser prompt, a wiki link, an xdg-open, a Playnite entry, a Stream Deck macro or a shortcut on your desktop can open a stream on a host this device already trusts — optionally launching a specific game and using a specific profile.

Host and pinned cards can copy their own link, and on Linux and Windows they can write a double-clickable shortcut straight to your applications folder or desktop. The link carries the host's stable id and its address and fingerprint, so a shortcut keeps working after the host moves to a new address or your client is reinstalled.

A link can only ever do what clicking one of your own cards could do, minus trust decisions. It carries references to things that already exist on this device — never values, so no web page can dictate your resolution, bitrate or codec. It can never pair with or trust a host on its own: a link naming a host you don't know opens the ordinary PIN ceremony, under your eyes. A link that names a profile you don't have, or a fingerprint that contradicts what you already pinned, is refused by name rather than quietly connecting with the wrong thing. And a link arriving while you're already streaming never cuts that stream off.

New: the punktfunk command

One command-line front end over the same brain the apps use, for scripts, plugins and headless boxes:

punktfunk pair | hosts list/add/forget | wake | library | launch | open
          reachable | speed-test | profiles list | reset

Because it runs the same connect plan a card click runs, launch and open wake a sleeping host and wait for it — the older shell flag never did; it fired a packet and dialled into the void. Exit codes are distinct enough to branch on without parsing prose, and anything that genuinely needs a person (pairing, reset) refuses rather than hanging a CI job on an invisible prompt. It's installed by the deb, rpm, Arch, nix, flatpak and Windows packages.

New: HDR when you stream Steam's Gaming Mode on Linux (opt-in)

Streaming Steam Gaming Mode from a Linux box has always been SDR — not because the encoder couldn't do better, but because gamescope hands its picture to Punktfunk already tone-mapped down to 8-bit. That gap is now closed, with a small companion package: install punktfunk-gamescope (gamescope plus a patch that adds the 10-bit BT.2020 PQ formats to its capture output — offered upstream) and set PUNKTFUNK_GAMESCOPE_HDR=1, and games render in real HDR while the stream carries HDR10 to an HDR-capable client.

It sits beside your system's gamescope rather than replacing it — your own Gaming Mode is untouched — and Punktfunk only uses it for the sessions it starts itself. On Bazzite it rides in the Punktfunk sysext; there's an Arch package that also installs on a Steam Deck, a NixOS option, and a build script for everything else. punktfunk-host hdr-probe tells you exactly which pieces are in place. Verified end to end on Bazzite and on SteamOS 3.8.16.

Opt-in for this release while it soaks: without the knob, or without the extra package, the gamescope path streams SDR exactly as before. The same package also lets gamescope draw the mouse pointer into the stream itself, which removes a full-frame conversion pass the host used to pay every frame just to add a cursor.

AMD and Intel hosts benefit from this work even outside gamescope: an HDR stream now stays on the faster encode path instead of dropping to a slower one that also lost loss-recovery, and NVIDIA hosts keep an HDR capture on the zero-copy path rather than falling back to the CPU.

New: smaller things worth knowing about

  • Host cards show which system the host runs. A small mark for Windows, macOS, Steam Deck, Bazzite, Arch, Fedora and the rest, on every client and in the web console — and a plain Tux for a distribution nothing recognizes. On the cards and tiles it takes the place of the host's initial, which never said anything the name beside it didn't already say. Hosts that predate this, or run something we ship no mark for, keep their letter, so a mixed row still reads as one set.
  • Name your host whatever you like. PUNKTFUNK_HOST_NAME=Living Room renames it everywhere a human sees it, in Punktfunk's clients and in Moonlight, without renaming the machine. Spaces and accents are fine.
  • Every connect introduces the device by name. An access request now arrives as "MacBook Pro wants to connect" rather than as a fingerprint fragment, and approving one no longer saves that placeholder forever.
  • The Windows tray fits Windows 11 — dark menu, crisp at any scaling, icons — and pops a notification naming the device and mode when a stream starts.
  • The Windows console can list the machine's real monitors. It previously showed nothing and explained itself with Linux troubleshooting advice. Note that streaming one of them is still Linux-only; the picker now says so plainly instead of saving a setting that did nothing.
  • Full chroma (4:4:4) is now a switch on the Linux and Windows clients, not just on Apple — it's what makes small text and thin lines crisp, so it's a good thing to turn on in a "Work" profile.
  • A frame limiter for the game, not for the stream. PUNKTFUNK_MAX_FPS caps how fast the game renders while the stream keeps its full rate, so the GPU time goes to capture and encode instead — and on a laptop or handheld, to less heat and more battery.
  • A smoother virtual display. PUNKTFUNK_VDISPLAY_HZ_MULT=2 runs the virtual display at twice the session's rate without putting one extra frame on the wire, which halves the worst-case wait for a freshly finished frame. Opt-in, since it costs the compositor the extra work.
  • Apple's About page is worth opening, and the host grid can be sorted and grouped — by name, date added or last connected, and grouped by profile or status.
  • Every download now ships a checksum next to it on the release page, so sha256sum -c is all it takes to verify one.
  • The stream's on-screen stats scale with your display, instead of rendering at half size on a HiDPI laptop.
  • The Linux app finally has its own icon in the launcher, taskbar and window switcher on deb, rpm, Arch and nix installs — they had all been shipping a generic monitor glyph.

Improved

  • Android's settings read like every other client's. Same categories, same sub-sections, one-line explanations instead of desktop paragraphs, and an About page that names the app and its version.
  • The Windows app's shell got a round of real polish: proper nested menus on host tiles, one native control for the profile switcher instead of three glued together, sheets that close on Escape or a click outside, and a host editor that is a centred sheet rather than a tile whose controls could end up below the fold.
  • The Linux client's settings rows behave. A button that appears when you change something no longer slides the control you just clicked out from under the pointer, long explanations stop squeezing the value next to them, and undoing one override changes that one row in place instead of closing and reopening the whole dialog.
  • Host cards in a row are the same height again on Android, whether or not they carry a profile chip or a long trust label.
  • Unsaved display settings in the web console are visible and recoverable. The Custom block's Save button sat below the fold, so people edited, navigated away and lost the lot. There's now a badge in the card header, a highlight on the block, and a sticky bar that stays with you — plus a warning if you try to leave or overwrite pending edits.
  • The web console counts paired devices correctly. Punktfunk's own clients pair on a different plane than Moonlight, and only Moonlight's was being counted — so a perfectly normal setup showed "0 paired".
  • The Steam Deck plugin works with a natively installed client, not only the flatpak. Pairing, the library and launching all failed on a Deck whose client came from a sysext, a package or a nix profile.

Fixed

  • Bazzite hosts couldn't update at all. Every install on the stable channel had started refusing the update feed, correctly — the publisher had been signing a redirect page instead of the actual file list, and had also been quietly dropping older entries from that list for months. Both are fixed and the live feeds are repaired.
  • Android: the picture was stretched whenever the stream didn't match the screen's shape. Streaming a 16:10 desktop to a 20:9 phone, or anything to a tablet in the wrong orientation, filled the panel and distorted everything in it. The video is now sized to the stream's own proportions, centred, with black bars for the remainder — and touch, multi-touch and pen input land on the picture rather than on the panel, so a tap goes where you aimed it. One deliberate consequence: a trackpad swipe that starts on a black bar no longer registers, because input landing on the picture is the rule that has to win.
  • Android: menus were laid out wrong after every stream. Content shoved to one side, rows sliding under the status bar. Coming back from a stream that ended while the app was in the background left the app using the stream's full-screen measurements for the rest of its life.
  • Android: a black screen with a perfectly healthy overlay. A box handed the stream mid-picture never received a full frame to start from, and nothing asked for one. Now it asks. A session that receives no video at all also says so in the log, which is what makes the remaining reports diagnosable.
  • Android: a setting changed on the wrong layer. Switching to a profile and changing a row wrote the change to your defaults instead, and switching back sent the next edit into the profile — which read as "the default settings can't be changed any more".
  • Android: opening a link could end the stream it was meant to leave alone.
  • Mouse side buttons and iPad keyboards. On Android, back/forward were dead on mice that report them the way Bluetooth mice and TV boxes tend to. On iPad, every mouse button past the first two clicked left on the host, holding a key deleted exactly one character, and scrolling ignored the system's Natural Scrolling setting.
  • The mouse pointer was missing from GNOME streams. Two separate causes, both found on real hardware: the pointer was never moved onto the screen being streamed, and current GNOME versions never draw a pointer into a virtual stream even when asked to. Punktfunk now draws it itself for those sessions.
  • KDE Plasma: asking for the streamed display to be primary did nothing. The desktop stayed on the physical monitor while the log claimed success. Current Plasma ignores the request Punktfunk was making; it now sets display order the way Plasma's own tools do, and reads the answer back instead of echoing its own request.
  • A mirrored monitor streamed soft and stuttery. Mirroring a 4K panel to a client asking for 1080p encoded four times the pixels at the 1080p bitrate. An Automatic bitrate now follows the pixels actually being encoded.
  • Pinning a monitor to stream broke Steam Gaming Mode on the same box. The pin is host-wide, so booting into a Game Mode session with no monitors to mirror made the host refuse to stream at all instead of streaming normally.
  • Streams looked washed out on some TVs. Three encode paths shipped video with no colour information at all. Punktfunk's own clients guess right, so this went unnoticed; TV decoders guess from resolution, and an LG webOS panel read a 4K SDR stream as wide-gamut.
  • NVIDIA hosts offered codecs their GPU can't encode. An older card advertised HEVC, and a client that believed it got about fifteen seconds of blank video and a disconnect. Both platforms now ask the driver what it actually supports.
  • The Linux client demanded a sound-server replacement it never used. On Arch it was a hard dependency that conflicts with PulseAudio, so anyone running real PulseAudio could not install the client at all; the deb and rpm proposed the same swap more politely. Neither the client nor the host speaks that protocol — only games do, and real PulseAudio serves them fine.
  • The tray crashed at every launch on Debian and Ubuntu. A build-time detail, fixed where four other packagings already had it right.
  • Streaming on sway or Hyprland destroyed your desktop-portal configuration — the whole file, on first connect, silently. Punktfunk now changes the one line it needs and leaves everything else byte-for-byte, with a one-time backup.
  • Windows: several ways to end a session left the desk dark. Recovery paths that turn your panels back on were gated behind the wrong condition, so a failed step meant nothing ever turned them back on. Related: Punktfunk's own virtual display was being counted among your real monitors, which disabled the very last-resort "never leave the desk dark" backstop.
  • Windows: a mid-stream resolution change could leave a phantom monitor behind, or silently not change the refresh rate it reported changing.
  • Windows: a low-privilege local program could hijack a virtual controller's input channel and forge input into your desktop. The host now asks Windows itself which process is serving the device instead of trusting a value any local program could write. This is why the host and drivers must now match.
  • Deleting a profile crashed the Windows app, and pinning appeared to do nothing because the switch was below the fold.
  • Linux: the About dialog silently dropped its third-party licence notices, printing 16,000 lines of errors instead.
  • Apple: the licence list stranded you with no way back on iPad, the app icon drew with square corners, profile colours never appeared in menus, and the app described itself as "free software" on a page you reach after paying for it.
  • nix build .#punktfunk-web had been broken since a lockfile refresh, and needed a manual hash round-trip on a Linux machine to fix. It no longer has a hash that can go stale.
  • Fedora and Windows releases can no longer ship unsigned. Both had a silent fallback: a rotated or missing signing key would have published packages that every user's updater rejects, or a release signed with a throwaway certificate nobody can pin. On a release tag both now fail the build instead.
  • Steam Gaming Mode sessions now prove Punktfunk's settings reached them. A session that ignored them produced a stream that was correct in every respect except that it had no mouse pointer, and nothing in the logs said so.

Under the hood (for developers)

  • One protocol number moves. Streaming protocol stays at 2, the embeddable C ABI at 13, and the Windows virtual-display driver protocol at 6 — 0.180.22 hosts and clients keep mixing freely. The Windows virtual-gamepad channel protocol goes 2 → 3 and fails closed in both directions by design, with the existing "update host + drivers together" diagnostic; the installer ships both, so only a hand-assembled pair can mismatch. pf-dualsense is renamed pf-gamepad (package identity only — crate, INF/CAT/DLL, UMDF service, log file, env var); the four hardware IDs, the bootstrap mailbox name and PAD_MAGIC are wire contract and unchanged, and driver install --gamepad retires the pre-rename store package by matching pf_dualsense.dll rather than the hardware ids.
  • The gamepad channel's trust root moved from a mailbox to the device stack. The host duplicated each pad's shared DATA section into the driver's WUDFHost using a driver_pid read from a LocalService-writable bootstrap mailbox, gated only on the target's image being %SystemRoot%\System32\WUDFHost.exe — which is world-executable, so a LocalService principal (notably the de-privileged plugin runner) could spawn its own suspended WUDFHost, publish that pid and be handed SECTION_MAP_READ|WRITE on a live section: forged HID input into the interactive desktop, and for pf-mouse a real absolute pointer. The pid now comes from the devnode the host SwDeviceCreate'd, looked up by the instance id PnP handed back, so the kernel does the routing. Three transports, because HidD_GetIndexedString is not forwarded to a UMDF HID minidriver at all and a private device interface cannot be opened on a devnode hidclass owns IRP_MJ_CREATE for: a private IOCTL for pf-xusb, the HID serial string for pf-mouse, and HID feature report 0x85 for pf-gamepad (no report-descriptor change — the captured descriptors already declared it).
  • Profiles are a sparse override overlay, resolved once. pf-client-core::profiles holds SettingsOverlay (sparse Options) and a catalog in its own client-profiles.json — deliberately not the settings file, which has five whole-file load-modify-save writers with no merge. Resolution has one implementation, trust::effective_settings(): overlay(profile).apply(global) where profile = one-off ?? host binding ?? none. absorb serves per-control shells (compare the effective settings before and after one control fired; the comparison is against what the control was showing, so a value equal to today's global still records a pin) and clear is the only removal, keyed by the overlay's own field names with resolution aliasing the width/height/match-window tri-state. KnownHost gains profile_id, pinned_profiles and a lazily minted stable id; upsert now preserves user-set state against refreshes that carry none of it; all three client stores write temp+rename. Apple mirrors the model field-for-field with unknown-key carry-through, appending profileID/pinnedProfileIDs/osChain/addedAt last because that JSON is a frozen app↔widget contract; Android re-keys its host store from addr:port to a minted UUID in one migration pass tested against a verbatim pre-migration blob.
  • punktfunk:// is one grammar with one vector file. punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…], with a 2 KB cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is a refusal, not a U+FFFD), control characters refused after decoding, and fp= held to 64 hex. pair parses and is refused so a link can never start a trust ceremony; pf:// parses as an input alias and is never emitted or registered. Resolution is stable id → unique host name → addr[:port], with host=+fp= as the reinstall recovery path. clients/shared/deeplink-vectors.json is the cross-language contract — 44 cases including every refusal code — run by the Rust, Swift and Kotlin suites from the source tree, so three parsers cannot drift into three security postures.
  • A brain layer now sits under the front-ends. ConnectPlan is a resolved intent with one constructor per door (card click, CLI verb, URL); ConnectPlan::resolve is pure, which is what lets the URL router be tested without a config directory. plan_from_link holds the deep-link security rules once instead of per shell. WakeWait is Apple's HostWaker cadence as a pure step function (packet at 0 s and every 6 s, presence polled every second, 90 s budget, then a park rather than an error). Session spawn, its argv and its stdout contract moved here too. punktfunk-session --resolved-spec <path> is a new spec mode in which the renderer performs zero store reads — it had been re-deriving effective settings, the clipboard decision and the profile name inside the thing that draws pixels — and the match-window write-back is now reported on stdout for the spawner to persist rather than being a sixth concurrent writer of the settings file. punktfunk-session --pair prints a deprecation notice and forwards; Decky's --list-hosts/--reachable flags remain a frozen compat contract.
  • The os= advert is additive on two carriers. The host detects its OS once per process and emits an icon-friendly specificity chain, generic → specific: windows, macos, linux[/<family>][/<id>] (e.g. linux/fedora/bazzite, linux/arch/steamos), the middle token being the first recognized ID_LIKE ancestor and the leaf ID verbatim, sanitized to TXT-safe [a-z0-9._-]. Clients walk it most-specific-first, so an unknown distro degrades to its family's mark and finally to Tux with zero distro→parent knowledge on the client. Carried by a new advisory mDNS os= TXT key (same trust posture as mac) and by HostInfo.os + HostInfo.os_name on the mgmt API; GameStream serverinfo and the QUIC Welcome are untouched. Android's JNI discovery record appends os as its eighth -field, append-only and pinned by tests in both directions. assets/os-icons/ holds the ten master SVGs each platform derives from (Font Awesome Free brands CC BY 4.0 + Simple Icons CC0, folded into THIRD-PARTY-NOTICES.txt).
  • Vulkan Video learned 10-bit, and stopped guessing what it can do. An HDR session opens a Main10 profile with 10-bit component depths, a G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16 picture + DPB, and an SPS carrying bit_depth_*_minus8 = 2 with the BT.2020/PQ CICP triplet; AV1 carries high_bitdepth plus the matching sequence-header OBU bits, which sit before the CICP bytes, so getting them wrong puts every following field one bit out of phase (the new test reads the packed bits back). rgb2yuv10.comp is a pure BT.2020 NCL 3×3 matrix — the samples arrive already PQ-encoded, so there is no transfer function to apply and applying one would be wrong — writing into the high bits of R16/RG16 scratch planes merely size-compatible with the picture's. probe_encode_support became VulkanEncodeCaps { supported, eight_bit, ten_bit }, answered by vkGetPhysicalDeviceVideoCapabilitiesKHR against the very profile chain the session open builds, so an incapable device routes to VAAPI before burning a failed open; can_encode_10bit now reports the union of VAAPI's and Vulkan Video's answers rather than VAAPI's alone.
  • NVIDIA gained a zero-copy HDR leg and an honest codec advertisement. A packed 10-bit frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC ARGB10/ABGR10, letting NVENC do the BT.2020 conversion itself: no host CSC pass, no depth loss. HDR never routes through the tiled EGL de-tile blit (it renders into an 8-bit GL_RGBA8 texture), and a host without the direct-SDK backend keeps the CPU path, since libav's HDR route swscales into a P010 hardware frame a packed-10-bit CUDA buffer cannot fill. Separately, both platforms now probe nvEncGetEncodeGUIDs on one throwaway direct-SDK session instead of advertising a static H.264|HEVC|AV1 superset, wired into host_wire_caps and the GameStream serverinfo mask; it fails open, so it can only ever narrow. The HEVC 4:4:4 answer rides the same session rather than a libav hevc_nvenc FREXT probe — that open is the prime suspect for the field bug where one probe wedges NVENC process-wide with NV_ENC_ERR_INVALID_VERSION.
  • Four encode paths were emitting no colour description. Vulkan Video HEVC built an SPS with no VUI at all (the default backend for AMD/Intel Linux hosts), Vulkan AV1 packed color_description_present_flag = 0, openh264 wrote nothing, and the libav-NVENC Linux path excluded packed-RGB 4:2:0 on the belief that NVENC writes its own VUI (libavcodec derives it from the AVCodecContext colour fields). All four now signal BT.709 limited, which is what every host CSC actually produces. Two tests parse the real emitted bitstream rather than re-asserting constants. GameStream's ServerCodecModeSupport gains SCM_AV1_MAIN10, each 10-bit bit gated on that codec's own can_encode_10bit and the SDR baseline already advertising it; host_hdr_capable became codec-agnostic and the RTSP honor degrades a session whose negotiated codec can't carry 10 bits.
  • D3D11 multithread protection is now enabled before libav sees the device. libav turns it on in d3d11va_device_create; we take d3d11va_device_init because we hand it the capturer's existing ID3D11Device, and nothing enabled it on our side. Two consequences, one shipping for a long time: av_hwdevice_ctx_create_derived(QSV ← D3D11VA) was rejected by MFX as MFX_ERR_UNDEFINED_BEHAVIOR (-16) (reported as the uninformative "Error setting child device handle"), and AMF — the default Windows zero-copy path — was running with libav's d3d11va_default_lock, whose ID3D11Multithread::Enter/Leave are documented no-ops while protection is off, so the lock serialising our capture thread against its encode thread had never serialised anything. Measured on Intel UHD 750: protection is the only variable that matters, and it read back was=false every time. QSV still defaults off.
  • gamescope carries three patches and a monotonic marker. The PipeWire node additionally offers xRGB_210LE/xBGR_210LE with mandatory SMPTE ST.2084 + BT.2020 props (spelled out numerically — PipeWire 1.4.11 on Fedora 43 has no SPA_VIDEO_TRANSFER_SMPTE2084), paint_pipewire() composites into them with the HDR screenshot LUT and EOTF_PQ, and --pipewire-composite-cursor paints the pointer with the same MouseCursor::paint the scanout composite uses. New formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit. The --version banner stamps +pfhdr<N> as a patch level, because punktfunk fixes a session's bit depth in the Welcome before the display exists and PQ frames on an 8-bit encoder are a deliberate hard error — so the capability answer must be a static property of the binary that will be spawned, never an optimistic negotiation. Level 1 = HDR formats, level 2 = the cursor flag; cursor_blend_for and the gamescope_cursor resolver consult it through one helper because the reader without the blend is a wasted X11 connection and the blend without the reader is a stream with no pointer. The build forces force_fallback_for=libliftoff,vkroots,wlroots — Fedora 44's builddep pulls in wlroots-devel, and meson then links it shared, producing a binary that starts only inside the build container. For the same reason the C++ runtime is linked statically: the Arch container builds against gcc 16.1.1 while SteamOS 3.8.16 ships libstdc++ 3.4.34, so the pacman package died at --version with GLIBCXX_3.4.35 not found on the gamescope backend's most important platform. It is safe here because gamescope links no shared C++ library at all — its NEEDED list is all C, and glslang/SPIRV are build-time only — so no C++ ABI crosses a shared boundary; the cost is ~1 MB. The flags are appended to LDFLAGS rather than passed as -Dcpp_link_args, which would replace meson's environment-derived value and silently drop makepkg's -z relro/-z now/--as-needed, and the build now asserts no libstdc++ in NEEDED, since a static runtime is otherwise invisible in a passing build and surfaces only as a binary that will not start somewhere else. Both managed spawn modes (gamescope-session-plus via GAMESCOPE_BIN+PF_HDR_ARGS, SteamOS via a PATH shim) now read the running compositor's /proc/<pid>/cmdline once its node appears and refuse the session on a missing flag, latching the capability off for the process so the retry converges on a correct SDR host-composited session; it fails open at every ambiguity.
  • The cursor decision is now source- and session-scoped. capturer_supports_hdr_for(compositor) replaced the flat false; the HDR-negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa); the keep-alive reuse key gained hdr, since gamescope cannot turn HDR on live. cursor_blend_for grew a no-channel arm: the capture-latched console client never advertises CLIENT_CAP_CURSOR, so its session asked Mutter to embed the pointer — a fiction since Mutter 48 removed hw-cursor inhibition, where the software overlay is suppressed stage-globally whenever any physical head realizes a HW cursor, and cursor-only motion schedules no re-record (mutter#4939). Probed on Mutter 50.3: embedded + relative motion froze the frame counter while SPA_META_Cursor positions kept flowing. Those sessions now take cursor-as-metadata + host composite permanently; embedded survives only as the can't-blend fallback. The stream loop also parks the seat pointer at the streamed surface's centre through the session's own input pipeline, retried on a schedule because the first park can land on a still-cold EIS connection — a pointer-lock client sends only relative deltas, so nothing else ever moves the pointer into a freshly created virtual output.
  • libei absolute-coordinate resolution gained a scale rung. A display scale s shrinks an output's EI region to logical pixels (Mutter advertises 853×533 for a 1280×800 output at 1.5), so the exact-size rung missed every scaled output and absolute input fell through to regions.first(). A new rung between exact and first requires one consistent factor (1..=4, fractional included) to map region onto mode on both axes, with per-axis rounding slack.
  • GET /display/monitors answers on Windows. monitors::list was a per-compositor dispatch whose non-Linux arm bailed, and detect() wasn't cfg-gated, so a Windows host returned an empty list plus a verbatim Linux error string about PUNKTFUNK_COMPOSITOR. target_inventory() already walked the CCD database and now reports the geometry it had in hand. Two fields are reported honestly rather than invented: scale is always 1.0 (Windows scaling is per-application per-monitor DPI, not a compositor-global logical scale, so the geometry is pixels) and an inactive head gets zeroed geometry, because CCD mode indices are only valid for active paths. MonitorsResponse.pin_supported is a new capability reported by the build that would have to honor a pin — per-monitor capture is Linux/portal only, since pf-capture's sole Windows entry point is open_idd_push from our own IddCx display and DXGI Desktop Duplication was deliberately removed — and a non-Linux capture_monitor in the whole-object PUT is coerced away with a log line rather than 400'd, so a stored pin self-heals instead of failing every later settings save. It defaults to true when absent, so an older host isn't retroactively locked out.
  • The pf-vdisplay sweep landed its contained half. A gamescope AB/BA lock inversion between a connect and the restore worker; observe_session_instance holding LAST_INSTANCE across invalidate_backend and a 10 s systemctl shell-out; admit holding the live-session table across budget checks that block on the manager state lock (itself held across DDC round trips and 3 s activation ladders); GameStream never registering its display, so both Windows budgets were blind to it; and ensure_exclusive_watch panicking on a failed thread spawn while holding both exclusive_watch and state, poisoning the two locks the manager runs on. Mutter's create timeout used to drop its stop flag without setting it, leaving a thread that had already made the virtual output primary parked forever holding the D-Bus connection that is the monitor's lifetime — under the default topology that orphan applies a sole-monitor APPLY_TEMPORARY config Mutter only reverts once the virtual monitor disappears, with no in-process recovery. The gamescope sub-mode travels as a GamescopeRoute return value carried on the backend instance instead of being published into PUNKTFUNK_GAMESCOPE_NODE/_SESSION and read back with the lock released in between; ENV_LOCK now covers detection's readers too, sampled into one EnvProbe (glibc setenv can realloc environ and free the old string, and the session watcher reads those five keys every second). Helper processes are enrolled in a Job object, since Child::kill is one TerminateProcess and every Windows helper is reached through a shell — the hanging process is a grandchild, which is why a green cargo test -p pf-vdisplay still failed its CI job by orphaning a 60-second ping.exe that held the build step's stdout pipe.
  • query_active_config treats zero active paths as an answer. Windows rejects a zero-count QueryDisplayConfig rather than returning an empty set, so "every panel off, a KVM switched away, a headless box between adapter and first monitor" came back as "the query failed" — which is exactly the teardown-gate condition whose recovery legs exist to stop the operator's panels being left dark. Measured on an RTX 4090 / Win11 26200 with the TV powered off: numPaths = 0, then 0x57 ERROR_INVALID_PARAMETER from a console session (0x5 ERROR_ACCESS_DENIED from session 0). Related: targets carrying our own EDID manufacturer id (PNK, matched on the monitor device path in both # and \ spellings) are now classified external_physical = false, so restore_displays_ccd's last-resort force_extend_topology can actually fire — it never could, because the restore runs before the virtual is REMOVEd and our own display kept lit >= 1.
  • KWin's set_primary_output handler is literally // intentionally ignored. Output order is driven by per-output set_priority (management ≥ 3; we bind up to v22 and never called it) — exclusive topology only ever looked right because disabling every other output leaves KWin nothing else to promote. Ours now takes priority 1 with every other enabled output renumbered uniquely behind it, and primary_taken (which echoed the request) became primary_verified, read back after one sync barrier.
  • The unsafe-proof program finished. clippy::undocumented_unsafe_blocks cannot see an unsafe operation sitting directly in an unsafe fn body, so unsafe_op_in_unsafe_fn is denied workspace-wide and the three previously exempt crates (punktfunk-core 167 items, pf-presenter 123, pf-client-core 91) now deny both — with the recurring shapes stated once per crate (the C ABI contract in abi.rs, the Vulkan contract in pf-presenter's lib.rs, split into CREATE/RECORD/DESTROY because only DESTROY carries a real precondition) rather than 141 restatements of a signature. Eleven hand-written #[repr(C)] mirrors of external C structs — the sharpest remaining memory-safety risk, since a wrong field offset compiles and doesn't reliably crash — are now const _: () = assert!(..) layout-checked on every build: AVCUDADeviceContext, the AVD3D11VADeviceContext/AVD3D11VAFramesContext pair (duplicated verbatim in two crates that can't depend on each other), the six cuda.h structs, Darwin's msghdr_x, and IPolicyConfigVtbl, which mirrors an undocumented COM interface called by slot index through a ten-entry unnamed _reserved gap. ⚠️ A Linux-only survey of a cross-platform crate undercounts by whatever cfg hides — here by 3× — so every crate had to be re-verified on Windows after being "done".
  • Supply chain and release integrity. Per-release SBOM, full-tree cargo/bun audits and a real license gate; every release asset now gets a <asset>.sha256 sidecar written in upsert_asset (sidecars rather than one SHA256SUMS, because eight workflows attach to the same release object concurrently and a shared manifest would be a read-modify-write race — and the PowerShell twin writes LF with no BOM, since GNU sha256sum folds a trailing CR into the filename). The Bazzite sysext feed carries SHA256SUMS.asc, a detached OpenPGP signature verified against a baked-in packages@unom.io key (AF245C506F4E4763, the same key that signs the RPMs) — a key fetched from the feed you're authenticating authenticates nothing — with PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through and a --seal mode that re-signs an existing manifest without rebuilding an image. The publisher's bug is worth recording: the registry answers a file GET with a 303 to presigned storage and curl -f does not treat 3xx as an error, so two un--L'd reads "succeeded" holding <a href="…">See Other</a>.--seal signed that HTML page (whose presigned URL expires in 300 s, so the .asc covered bytes that exist nowhere), and the publish merge kept it while grep -v dropped every prior image line. Both reads now go through one read_manifest that follows redirects and keeps only well-formed <sha256> <filename> lines. The Windows drivers gained one stable publisher identity (thumbprint 4B8493E7CD565758D335F8F4F05C5A7261A13E02, RSA 3072, valid to 2036) delivered via DRIVER_CERT_PFX_B64; driver install purges stale CN=punktfunk-driver certs before adding and driver uninstall removes them entirely, closing the leak where every upgrade added two more self-signed machine roots that nothing ever removed. The decoded .pfx is now deleted after the last signing step and from a script-scope trap. Fail-closed guards on refs/tags/v* cover the MSIX cert, the host installer cert and the RPM GPG key. This does not authenticate the driver download — a self-signed leaf is its own root, so the installer must trust it for PnP to proceed; attestation signing remains the real fix.
  • New knobs. PUNKTFUNK_HOST_NAME overrides the GameStream serverinfo <hostname> element and the mDNS service instance name; dns_label() sanitizes the A-record target separately and passes an already-legal name through byte-for-byte, because mdns-sd rejects the whole ServiceInfo on an illegal target (which would take discovery down rather than merely look wrong), and the display name loses . since clients derive the name from the first label. PUNKTFUNK_MAX_FPS becomes gamescope's --nested-refresh on all three sessions we own, landing on PF_HZ alone and not CUSTOM_REFRESH_RATES so the advertised mode stays the client's; the attach path has no lever and is untouched. PUNKTFUNK_VDISPLAY_HZ_MULT multiplies the display's rate while the pacing rate becomes the session's rate floored by the achieved one — the same value as before whenever the knob is unset. PUNKTFUNK_OSD_SCALE scales the stream chrome, which now reads SDL's window display scale per frame and quantizes it into the damage key, re-deriving the typeface at the scaled size rather than transforming the canvas.
  • Capture-stall attribution v1. The driver's shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC, last-acquire QPC, full-width offered counter) that the host samples between fresh frames to attribute each stall as worker-stalled / compose-silence / delivery-leg, version-safe in both directions (gated on the host-stamped header version; a zero heartbeat reads as a pre-telemetry driver). A refcounted micro-probe engine (per-adapter fence round-trip, DwmGetCompositionTimingInfo tick, watchdogged DwmFlush, D3DKMTGetScanLine, a CPU jitter sentinel) samples on detached threads, and an event-id-filtered real-time ETW session on Microsoft-Windows-DxgKrnl rides every stall line as a DDI bracket summary. Degrades to etw=unavailable without admin; absence is stated, never guessed. Also: a capture's meta now records the encoder and GPU that produced it, read once per capture from pf_gpu::active().
  • CI restructure. The five builder images moved to a LAN registry under content keys (a hash of the ci/ tree), built only when the key has no manifest, and releases pin them by copying the key manifest to a vX.Y.Z tag via the registry API — no rebuild, no bytes moved. sccache backs every Rust job through one S3 bucket with CARGO_INCREMENTAL=0; Android and Arch got baked images (Android was downloading ~3 GB of SDK/NDK per run, Arch ~1 GB of pacman); Apple got sccache plus a pinned DerivedData root. Path filters stop docs-only pushes lighting up the whole fleet, concurrency groups let a newer push supersede a queued run, and the report-only bench job moved to its own nightly workflow. The nix flake's two bun fixed-output derivations were replaced with bun2nix 2.1.2, so there is no aggregate hash left to go stale.
  • Two packaging traps recorded. scripts/ci/gitea-release.sh is sourced under POSIX sh (dash) on the deb and decky attach steps, so no bash-isms; and the tray must be built in its own cargo invocation, because cargo feature unification hands it a tokio-flavoured zbus with no tokio runtime anywhere near it when co-built with the host.