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.

## New: links and shortcuts that open a stream

`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.18–0.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 `Option`s) 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.
