Files
punktfunk/CHANGELOG.md
T
enricobuehler 0f79587dd6
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m51s
ci / bun-nix (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 8m47s
docs(release): the notes claimed a privilege 0.26.0 deliberately does not grant
The user-facing v0.26.0 notes said, of the PyroWave GPU-priority lever:

    "it is now, and the package grants the host the permission that switch needs"

That was true of 0.26.0-1 and is now the opposite of true. Granting CAP_SYS_NICE
made the host unidentifiable to KWin and killed desktop streaming on every KDE
box across all five Linux channels, so 0.26.0-2 revokes it everywhere and must
keep doing so. The lever is wired natively on Linux for the first time — that
part stands — but it is dormant on an ordinary install, and the notes have to
say so rather than advertise a speed-up nobody gets.

CHANGELOG.md was already corrected in #136 (the 0.26.0-2 note under PW1 and the
qualifier on the owed A/B). This is the user-facing half, which #136 did not
touch:

  * the PyroWave bullet now leads with what DID land (two encoder handles, the
    capture buffer headroom) and describes the priority switch as present but
    dormant, with the reason.
  * a new Fixed entry for the KDE breakage itself. Worth telling users even
    though the release was never announced: 0.26.0-1 packages did reach the
    registries, and anyone who pulled one has a desktop session that fails with
    a missing-screencast error surviving a clean reinstall. It also explains the
    dormancy the bullet above now refers to.

Deliberately NOT written as a "Before you update" action: upgrading strips the
capability by itself on every channel, so there is nothing for a reader to do.

Commit count 47 -> 52.

Voice check clean (0 internal-vocabulary hits above "## For developers"); notes
67 lines.
2026-08-09 10:12:58 +02:00

86 KiB
Raw Blame History

Changelog

Protocol, ABI, driver and embedder detail, one section per stable release, newest first.

This is the technical half of a release. The other half — what changed for people who use Punktfunk — is docs/releases/vX.Y.Z.md, and it deliberately contains no internal names. The two were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown long enough to bury the user-facing half it was appended to. See docs/releases/README.md.

If you embed punktfunk-core, package Punktfunk, or write a plugin, this file is for you. Start with the version table of the release you are moving to, then read Breaking changes.


v0.26.0

52 commits since v0.25.0.

Versions

v0.25.0 v0.26.0 Notes
Wire protocol 2 2 unchanged
C ABI 17 17 unchanged — no symbol added, removed or changed
Workspace crate dirs 26 26 unchanged (40 workspace members)
Virtual-display driver protocol 6 6 unchanged (minimum accepted still 3)
Windows virtual-gamepad channel 3 3 unchanged
Plugin index schema 1 1 unchanged
api/openapi.json 0.24.0 0.25.0 tracks API edits, lags one release by convention
gamescope patch level (+pfhdrN) 2 4 3 patches → 6; pkgrel 1 → 2
@punktfunk/host (SDK) 0.1.2 0.1.4
@punktfunk/plugin-kit 0.3.2 0.4.0 the plugin launch kind

crates/pf-driver-proto is byte-for-byte identical to v0.25.0 and to v0.24.0 — if you ship the virtual-display driver or the gamepad channel, the last two releases have not touched you.

⚠ Breaking changes

None. This is a fixes release. Every embedder, packager and plugin that works against v0.25.0 works against v0.26.0 unchanged. Two behaviour changes are worth knowing about anyway, because both make a client advertise less than it used to — see Capability advertisement below.

Capability advertisement

  • VIDEO_CAP_444 is now probed, not asserted. It rode the "Full chroma" setting alone. That was safe while a software HEVC decoder sat underneath it; M8 removed one (there is no permissively licensed HEVC CPU decoder, so software_decodable_codecs() is H264|AV1). The host grants 4:4:4 on HEVC only and answers the resolved chroma in the Welcome before the client builds a decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the whole codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU rung, and the session reconnects on H.264. No AMD silicon has HEVC 4:4:4 decode, so every Steam Deck with that switch on lost HEVC. Per-profile and default-off, which is why it read as intermittent.

    Now gated on hevc_444_hardware_decodable, which asks the driver through the same code the rung uses at construction (VkH265Decoder::probe_stream_support). Both depths are required, not either: with HDR the host may resolve 4:4:4 10-bit, and a device offering YUV444_8 but not YUV444_10 lands in the same hole. Answering from the Vulkan rung alone is exact rather than approximate — it is the only rung in this build that implements 4:4:4 at all (pf_vaadec::profile_for errors on chroma_format_idc 3, pf-dxvadec refuses anything but 4:2:0, the CPU rung is 8-bit 4:2:0).

    ⚠ Deliberately not extended to VIDEO_CAP_10BIT/HDR: all three rungs implement 10-bit 4:2:0, so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it perfectly — a regression against a case never observed.

    The bit arithmetic moved into video::video_caps_for so the part that was wrong is testable without a GPU, a host or a Hello; the test is verified non-vacuous against the planted defect.

Host and client environment variables

Four new, one clarified. Verified new by git grep at the v0.25.0 tag, not assumed — PUNKTFUNK_JUMBO, PUNKTFUNK_WIRE_MTU, PUNKTFUNK_STREAMED_AU, PUNKTFUNK_LIBRARY_ART_ROOTS, PUNKTFUNK_RECOVER_SESSION_CMD, PUNKTFUNK_GAMESCOPE_SDR_NITS, PUNKTFUNK_MAX_FPS and PUNKTFUNK_ON_CONNECT_CMD all already existed.

  • PUNKTFUNK_OVERLAY_MASK (new, client) — controls the Steam-overlay input mask below.
  • PUNKTFUNK_PYROWAVE_CHUNK_KIB (new) and PUNKTFUNK_PYROWAVE_STREAMED_AU (new) — PyroWave AU chunking and the streamed-AU path.
  • PYROWAVE_QUEUE_PRIORITY (existed, but was inert on Linux — see below) — grammar: unset → realtime, ASCII-lowercased, off alone disables, high asks for HIGH only, junk falls back to the ladder rather than to off. ⚠ One env var must not mean two things on two platforms, so the Rust grammar is unit-tested against the C patch's, including where both are deliberately un-clever (neither trims).
  • PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 (new) — widens the set a gamescope session offers in Steam's in-session display settings. The rate the session actually runs at is always included, so it can only add options; junk entries are skipped rather than failing the host. Requires gamescope patch level 3+.
  • PUNKTFUNK_COMPOSITOR (behaviour clarified, not changed) — documented as "which backend to drive", it also silently discarded game_session=dedicated: resolve_compositor gated the dedicated route on !overridden and logged nothing either way. The pin still wins — it is the operator's explicit knob — but it now says so and names itself. Two further holes closed with it: the pin put its backend into available() unconditionally and skipped apply_session_env's XDG_CURRENT_DESKTOP scrub, so pick_compositor could never return None — the one call site of try_recover_session(), which left PUNKTFUNK_RECOVER_SESSION_CMD unreachable behind that arm. Liveness is now read on both paths. needs_live_session() exempts gamescope, which stands up its own session, so pinning it on a headless box stays supported.

Client settings keys

All additive; an older client ignores what it does not know, and a newer value can never trap an older client.

  • gamepad_ui_mode"connected" (default, and exactly what the previous lone Bool meant) or "always". Splits whether the controller UI is offered from when it appears. GamepadUIEnvironment.isActive takes the mode with no default argument on purpose: a call site that forgot it would silently strand everyone who chose Always. An unrecognized value waits for a controller.
  • ui_palette gains oled at index 1, directly after the brand default — keeping PALETTES[0] the unknown-id fallback and the dark-to-pale cycling order intact. Hand-mirrored in three languages (pf-console-ui's library.rs, GamepadPalette.swift, GamepadPalette.kt); each port carries an oled_is_actually_black test that measures the claim (mean cell luminance 0.019 against Violet's 0.254) rather than restating the table.
  • library-hidden.json — per-title hide list, mirroring how library-scanners.json holds disabled sources. Deliberately not stored on the entry: a scanner's and a plugin's titles are rebuilt from scratch on every scan and reconcile, so a flag written onto one would be erased minutes later. Applied in all_games, the single funnel every play surface already goes through (client grid, native clients, the GameStream app list, launch resolution).

gamescope patches

Three → six, and the marker patch moves last so the banner is stamped after the capabilities it advertises.

  • 0003 — headless: advertise the virtual display's mode and refresh rates. CHeadlessConnector returned empty spans from GetModes() and GetValidDynamicRefreshRates() and reported GAMESCOPE_SCREEN_TYPE_INTERNAL, so update_mode_atoms deleted the mode-list atom and wlserver fell through to a one-entry refresh list built from g_nOutputRefresh — which, with --nested-refresh absent, is Init()'s 60 Hz default. That is why a 1920x1080@120 client saw "gamescope only shows 60hz" and Overwatch capped itself to 60 while the stream ran at 120. Now populates both from the resolved mode, reports EXTERNAL, and adds --custom-refresh-rates. gamescope-session-plus has probed for that flag for years; upstream never had it, so the CUSTOM_REFRESH_RATES env it plumbs was a no-op everywhere.

  • 0004 — pipewire: optionally composite the external overlay into the capture stream. That layer is mangoapp. paint_pipewire has never referenced it on any version. Behind --pipewire-composite-external-overlay, off by default.

  • 0006 — never destroy the Vulkan device or output. g_device (CVulkanDevice) and g_output (VulkanOutput_t) were plain globals, so glibc ran their destructors from __run_exit_handlers once main() returned — calling back into an ICD that had already been torn down and unloaded. Faulting address equalling the instruction pointer is the signature. Reproducible with gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true (exit 139, every time). Both globals get storage constructed exactly as before but never destroyed; pinning only the device relocated the fault into ~VulkanOutput_t, hence a shared CNoDestroy<T>.

    +pfhdrN deliberately does not move for 0006. The marker is a capability tier the host probes via gamescope_patch_level() before it spawns; this patch adds no capability, so bumping it would advertise a tier that does not exist. Ships as a pkgrel bump instead.

⚠ gamescope CI legs are best-effort — a broken patch is a missing package, not a red run.

Virtual-display handle ownership (Windows)

The control-device sharing contract was "bare HANDLE copies, never closed for the process lifetime": retired handles were kept alive because pinger/linger threads and capture closures held raw copies whose soundness depended on no-close. An open control handle is exactly what vetoes the PnP disable — and can wedge the pnputil restart — that wake-from-sleep recovery leans on, so every post-wake adapter reload came back REFUSED. reset-pf-vdisplay.ps1 stops the whole host service precisely to get those handles closed; the in-process recovery could not.

Ownership is now Arc all the way out: ensure_device / device_handle / control_device_handle hand out Arc<OwnedHandle> clones, every consumer holds its clone across its IOCTLs (ending the isize smuggling — Arc<OwnedHandle> is Send + Sync), and retiring drops only the manager's reference. DeviceSlot::retired is gone.

Nothing may store a bare control HANDLE again. The whole fix is that the handle closes when the last in-flight user drains.

Presenter — points are not pixels

SDL_GetDesktopDisplayMode reports a mode in screen coordinates and hands the pixels-per-point ratio back separately as pixel_density; m.w/m.h were read raw. KDE advertises a 2560x1600 panel at 150 % as 1707x1067 points with a density of ~1.4997, render_scale::apply even-floors both odd axes, and 1706x1066 went on the wire. Multiplying by the density recovers 2560x1600 to the pixel.

⚠ Inert on X11 and Windows: SDL never sets a density there and SDL_video.c normalizes the unset 0.0 to 1.0. This bug needed a compositor doing fractional scaling.

Second, independent defect: the SDL window was created without HIGH_PIXEL_DENSITY, so the Wayland surface stayed at buffer scale 1 and the swapchain was built at 1707x1067 for KWin to upscale. That one also silently shrank "Match window", which asks the host for size_in_pixels().

Apple audio session

micEnabled and echoCancel both default to true, so the default iOS session is .playAndRecord — and that branch set .defaultToSpeaker. That option is an output override, not a preference, and it outranks an A2DP route. ⚠ Wired headphones beat it, Bluetooth does not, so testing with a cable returns the wrong answer — which is what the comment sitting on it asserted.

Now solved against the route actually given: after activation, if the current output is .builtInReceiver, override to speaker; anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly alone. The override is a property of the current route — iOS drops it on every route change, which is what lets a newly-connected headset win — so it is re-applied per route via an observer, registered only for .playAndRecord, removed in stop() before deactivate, deinit as backstop. Without it, dropping Bluetooth mid-stream lands on the earpiece.

⚠ Deliberately not adding .allowBluetooth: it would make a headset's mic usable but drag the whole route onto HFP/SCO and collapse game audio to narrowband.

Audio jitter policy

JitterPolicy (punktfunk-core/src/audio.rs, used by Linux/Windows/Android) and its mirror in Swift AudioRing. The policy learned exclusively from audible failures on both sides: growth needed three audible underruns; the A/V sync loop re-tested a shallower ring every five quiet seconds and paid an audible starvation event every time it was wrong, forever; and a grown target was never re-banked (growth raises a threshold — only a re-prime deepens the ring), so a bunching link rode the knife edge with the "grown" target sitting inert.

Three mechanisms: near-miss (a read served with less than one protocol frame left over is the same evidence as an underrun, heard by no one — grows one step per window, before the click); shrink probes (every shrink armed for 5 s, undone on the spot if answered by an underrun or near-miss, with a doubling backoff 60 s → 8 min on a failed sync-driven shrink; a surviving probe resets it); hollow re-prime (an underrun while the depth average runs more than a step below target re-primes immediately — the average, not the instant, separates a hollow ring from one late packet, and it is seeded on prime so a fresh ring is never spuriously hollow).

Measured on a ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, 50 ppm skew): ~2000 audible events → 9.

Plugins, SDK and the runner

  • category never shipped. The console correctly keeps category: "library" plugins out of the nav; the host reported no category for them at all. defineLibraryPlugin sets it and sdk/src/ui.ts forwards it — what shipped did not: @punktfunk/host was bumped to 0.1.2 on 2026-07-20 and category landed 2026-08-05 without a bump, so the registry's 0.1.2 is the pre-category build. ⚠ Inert until published. serveUi now reads its own directory entry back and warns once when a requested category did not land.
  • Local art sync failed on a file:// disagreement. local_art_bytes decodes a file:// value before testing containment; validate_art_paths handed the raw value to Path::new. Same defect produced both the unreachable settings and sync (startup) failed: HostRequestError.
  • The runner now carries SDK updates. The copy each installed plugin runs was pinned at install time, so an SDK fix could never reach it.
  • bun publish runs prepare, and prepare needs bun2nix — the SDK could not be published at all. Also fixed: a corrupt committed bun.lock in plugin-kit.
  • Decky client update. flatpak remote-info punktfunk-origin io.unom.Punktfunk names no branch; the remote publishes stable and canary, so the ref is ambiguous and flatpak refuses it — ⚠ one branch being installed does not disambiguate, the ambiguity is on the remote. The call failed on every box, every time, and returned available=False, which the panel rendered as good news. Every query now names the ref in full via _flatpak_ref() (no subprocess), carrying the scope too, so a system-wide install is no longer invisible to a check that hardcoded --user. A check that cannot run now reports client_error.

Packaging

  • The punktfunk group is created everywhere the udev rule needs it. 60-punktfunk.rules chgrp's the usbip vhci attach/detach nodes to a dedicated group (security review 2026-08-05 M-4: writing attach materialises an arbitrary emulated USB device, so it must not ride on input). Four of six install paths shipped that rule in 0.25.0 without creating the group — chgrp failed, nodes stayed root:root 0644, the virtual Deck pad silently never attached, and usermod -aG punktfunk failed outright. Fixed in arch post_upgrade() (only post_install was correct, so every box that reached 0.25.0 by pacman -Syu missed it), nix (users.groups.punktfunk did not exist), the bazzite sysext (a group is host state and cannot ride an image), and the Steam Deck scripts. deb and rpm were correct throughout.
  • punktfunk-gamescope now builds for RPM and apt, not Arch only.
  • Arch release-rebuild prune called a helper that cannot exist in a release rebuild. Together with the FFmpeg 9 repackage this closes the 0.25.0-1 → 0.25.0-2 episode in the pipeline rather than by hand.
  • Steam Deck update.sh / install.sh. The web step ran bun install --frozen-lockfile with no --ignore-scripts, so web's postinstall (bun2nix -o bun.nix) rewrote a tracked file on every update; the SDK step below it had always passed --ignore-scripts, and that asymmetry is the whole bug. Now --ignore-scripts plus an explicit bun run codegen — provably equivalent, since web's prepare is literally "bun run codegen" and src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored. --pull restores web/bun.nix and sdk/bun.nix before pulling, which is lossless by construction. ⚠ Deliberately not git reset --hard: $SRC defaults to the operator's own checkout. Also: web.env secret hygiene — chmod 600 sat inside the create-only branch, so an install set up once and only updated since kept it world-readable. ⚠ packaging/debian/build-web-deb.sh, packaging/arch/PKGBUILD and packaging/rpm/punktfunk.spec still lack --ignore-scripts for web — harmless (throwaway build trees), left as follow-up.

Triage tooling

--probe-decode described a different device from the one that streams. The RADV video-decode opt-in sat after the --list-adapters / --probe-decode / --list-audio / --pair early exits, so the triage tool never had it. Measured on a Deck, same binary back to back: bare --probe-decode printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue family advertises VIDEO_DECODE"; with RADV_PERFTEST=video_decode in the environment, "YES" and "H.264, H.265, AV1, VP9". ⚠ Any Deck triage that consulted it reached the opposite of the truth. Hoisted to the top of run, ahead of every early exit.

PyroWave on Linux — Wave 2

The program's own measurement, from patch 0005's header: encode_gpu_synchronous goes from ~2 ms to 1518 ms at 95 % game load, with the stream frame rate collapsing. PyroWave encodes on the same shader cores a game saturates; NVENC is immune because it has its own ASIC.

  • PW1 — the GPU-priority lever had never fired on Linux. The vendored patch requests an elevated global-priority queue, gated if (!inherit_info) — and only Windows leaves inherit_info null (pyrowave_create_device_by_compat, where Granite builds the device itself). Linux passes its own create-infos, Granite's get_existing_create_info() hands them back, create_device takes the inherit branch, and the whole block is skipped. Now wired natively in open_inner's DeviceHold, ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the open. The extension probe reuses the dev_ext_props already fetched for queue_family_foreign and takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree. Needs CAP_SYS_NICE, which the packaging granted in 0.26.0-1; without it the lever does nothing. 🛑 Corrected in 0.26.0-2: the packaging no longer grants it, and must not. Every channel that did (Arch .install, RPM %caps(), the Bazzite sysext image, the deb postinst, the NixOS security.wrappers entry) broke desktop streaming on KDE outright — field-reported on CachyOS and Bazzite as KWin does not expose zkde_screencast_unstable_v1 to this client. KWin identifies a client by resolving its /proc/<pid>/exe against an installed .desktop, and the kernel refuses that readlink to any reader whose effective set is not a superset of the target's permitted set (cap_ptrace_access_check) — KWin has no capabilities, so a capability-carrying host is unidentifiable and the restricted globals are never advertised. Neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it; only an uncapped process is identifiable. The lever therefore stays wired but unexercised on a stock install (the ladder degrades to default priority), and is opt-in for gamescope-only hosts, which have no such identity check.

  • PW5 — two encoder handles. Encoder::Impl owns exactly one each of wavelet_img_high_res, bucket_buffer, meta_buffer, block_stat_buffer, payload_data, quant_buffer, and Impl::encode opens by discarding them (an image barrier with VK_IMAGE_LAYOUT_UNDEFINED as the old layout, plus three fill_buffer clears). Two encodes submitted to one queue have no execution dependency in Vulkan — submission order orders the start, not the completion — so N+1's DWT would overwrite N's wavelet bands while N's block packing still reads them. Content-dependent and silent. Overlap therefore means two handles alternated, one per slot. ⚠⚠ The landmine: sequence_count also lives on Impl, and it is the 3-bit counter stamped into every block header. Two handles each counting 1,2,3… put 1,1,2,2,3,3… on the wire, and the decoder restarts a frame only when the value changes — so a repeat reads as more blocks of the same frame. Depth is still 1; the handles alternate with one in flight.

  • PW3 — the fence wait moved out of submit. PyroWave was the one backend waiting its fence inside submit.

  • PW7a — the jumbo leg was dead code. quinn caps a peer's MTU-discovery search at min(MtuDiscoveryConfig::upper_bound, the other side's advertised max_udp_payload_size), and EndpointConfig::max_udp_payload_size defaults to 1472. Nothing in the repo had ever touched EndpointConfig, so raising the host's probe ceiling could never make discovery settle above 1472 — and the shipped mid-session grow's settled >= sealed_datagram_bytes(target) gate was unreachable on every path that has ever existed. Two smaller contributors fixed with it: the watcher stopped sampling the moment settled >= 1472, discarding the very climb the proof needs; and a session sealed above the 1500-byte default was never checked against the path at all.

    The advertisement is raised on the client endpoint under the same jumbo_wire_mtu() opt-in, because it is not free: quinn sizes its endpoint receive buffer max_udp_payload_size × max_receive_segments × BATCH_SIZE — on a GRO-capable Linux/Android client that is ~2.9 MiB at the default and ~18 MiB at jumbo (47 KiB → 288 KiB on Apple/Windows). PyroWave is the codec that most wants this: it can never be re-keyed mid-stream (its client parses chunk-aligned AUs in windows of the Welcome value, read once over the C ABI), so it should start at the big shard. At an 8908-byte shard that is ~6× fewer datagrams per frame — ~49k → ~8k pps at 550 Mb/s.

Zero-copy capture

  • The dmabuf latch conflated two causes with different lifetimes. One AtomicBool served both "the encoder repeatedly failed to import what this compositor allocates" (unrecoverable, a driver fact) and "the dmabuf-only capture offer never negotiated" (which can just mean the compositor was mid-restart). Sharing it made the second as permanent as the first: one timeout, and every later session on that host captured CPU frames until the process restarted — including sessions against a different compositor and a different node that had never failed at anything, with nothing said. Now a RawDmabufLatch owning both: import failures stay sticky (unchanged 3-consecutive threshold); negotiation timeouts get a retry budget of 2 — deliberately small, since each failure costs a ~10 s stall the user pays in dead air; a capture that negotiates credits the budget back; and both are keyed to a capture identity (node id + portal bit).
  • The zero-copy path never asked for buffer headroom. build_dmabuf_buffers set SPA_PARAM_BUFFERS_dataType and stopped — no SPA_PARAM_BUFFERS_buffers at all, so the pool depth every zero-copy safety argument rests on was entirely the producer's choice and we never expressed a preference. Now asks for 8 (min 2, max 16) as a Choice Range, deliberately not a fixed count: SPA intersects consumer and producer params, so a fixed 8 against a producer that can only afford 4 empties the intersection and the link stalls in "negotiating" with no error anywhere — ⚠ the exact trap that once cost this codebase the entire Linux cursor channel, when a 256² cursor-meta max failed to intersect Mutter's fixed 384². 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz; 16 is a ceiling, not a request (a 4K 4:4:4 buffer is ~25 MB).
  • A PyroWave session could drop to CPU capture and log nothing. The CPU-fallback warning was gated on backend_is_vaapi, which reads the host-global encoder pref — but a PyroWave session is negotiated per session, so on an NVIDIA/auto host that gate is false and the session fell out of every arm of the negotiation log chain while paying a full-resolution CPU pixel touch every frame. A degraded host and a healthy one produced identical logs. Now asks the per-session question (consumer_kind), widened to every GPU consumer and excluding only the software encoder, whose native input is CPU frames. ⚠ pyrowave_session must outrank backend_is_vaapi, because a PyroWave pref flips backend_is_vaapi on too.

Steam-overlay input masking (Steam Deck)

On a Deck in Gaming Mode the Steam menu and the QAM are driven by the same physical controller the client forwards, so opening either moved the game on the host as well — a second, invisible player. Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's virtual pad and we deliberately forward the real one (the virtual pad has no gyro, trackpads or paddles).

SDL's own gate cannot fire on a Deck. SDL drops presses while a process has windows but no keyboard focus, and it is on by default — but gamescope resolves focus per Xwayland ctx and the client sits alone in its own, so the Steam overlay (which lives in the root ctx) never takes our X focus and no FocusOut is ever generated. Measured on glass: with the QAM open, X input focus inside the client's ctx stayed on its window for the whole 4 s while GAMESCOPE_FOCUSED_APP flipped to 769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app. That pair of atoms is the signal.

overlay_focus watches them on the gamescope root ctx, which is not our own $DISPLAY under --xwayland-count 2 — hence the socket-directory walk and the flatpak filesystem line.

⚠⚠ Masking is deliberately not set_forwarding: that closes the slot and sends GamepadRemove, so the game would see a controller unplug every time somebody opened the QAM. Every slot stays open and only transitions stop, after flushing what the host believes is held (so a stick deflected at overlay-open stops steering instead of freezing at its last value). On the way back, held buttons are adopted rather than replayed — the A that picked a QAM row must not fire in the game as it closes — while axes are re-sent, since a stick has no press to ghost and SDL only speaks on change.

The plugin launch kind

The 2026-08-05 review made launch.kind = "command" operator-only, and a reconcile refuses on the first offending entry — so rom-manager, whose every ROM is <emulator> <args> <rom>, stopped putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured, with the core and flags they chose", so that trick does not generalise.

The entry now carries an opaque key and nothing executable, and the host asks the owning plugin what to run at launch time, over the loopback UI port and per-boot secret it already registered. A stolen plugin token stops being command execution: planting an entry is not enough, because the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to a client, and an emulator that moved is picked up on the next launch rather than leaving a dead tile (same reasoning as xbox resolving its AUMID at launch time).

The host still spawns it, because only the host can put the process where the stream can see it: on Linux that is either gamescope's own argv or a spawn carrying the session's compositor env, and the returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the emulator itself would land it outside both.

Verification status

gamescope 0006 6/6 exit 0 on a release build at the real spawn shape (2752x2064@120 --steam --xwayland-count 1); distro control SIGSEGVs
Decky client update on the Deck against the real install — pre-fix available=False remote='', post-fix available=True remote=ca010668
--probe-decode on a Deck, same binary back to back, with and without the RADV opt-in
Apple audio builds on arm64-apple-ios17.0 (the triple that compiles the #if os(iOS) blocks — a plain swift build is macOS and skips them), arm64-apple-tvos17.0, macOS; 257 Swift tests
Audio jitter 10-minute Wi-Fi power-save simulation, ~2000 → 9 audible events
4:4:4 gate test verified non-vacuous against the planted original defect
Steam Deck scripts bash -n + shellcheck 0.11.0 clean at -S warning; exec bits preserved
Steam-overlay masking on glass on a Deck — atom flip and X-focus non-flip both measured over a 4 s QAM open
PyroWave depth 2 exercised on real hardware without shipping depth 2 (dedicated test, shipped depth stays 1)
PW6 streamed AU the trap is real, and at 2 % loss it costs exactly nothing

Owed on glass: iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a Linux host with CAP_SYS_NICE actually granted — the number this whole wave is aimed at. ⚠ That last one now needs a gamescope-only host, or a hand-granted capability on a box you are not streaming the KDE desktop from: see the 0.26.0-2 correction under PW1 above.


v0.25.0

407 commits since v0.24.0.

Versions

v0.24.0 v0.25.0 Notes
Wire protocol 2 2 unchanged — every addition below is optional or capability-gated
C ABI 14 17 three steps; see below
Workspace crate dirs 22 26 pf-bitstream (+ vendored cros-codecs), pf-vkdecode, pf-dxvadec, pf-vaadec added; pf-ffvk removed
Virtual-display driver protocol 6 6 unchanged (minimum accepted still 3)
Windows virtual-gamepad channel 3 3 unchanged
Plugin index schema 1 1 unchanged
api/openapi.json 0.23.0 0.24.0 tracks API edits, lags one release by convention

crates/pf-driver-proto is byte-for-byte identical to v0.24.0 — if you ship the virtual-display driver or the gamepad channel, nothing in this release touches you.

Why the wire did not move. It grew a lot and still did not break: an optional trailing max_shard_payload: u16 on Hello (absent/0 = legacy, doubling as the renegotiation capability flag and the jumbo receive ceiling); two control messages ShardPayloadChanged (0x08) and ShardPayloadAck (0x09); a redundant desktop-audio datagram tag 0xD2 beside the plain 0xC9; a controller-audio plane at 0xD1; a new 0xCD kind 0x06; arrival flag bits 8/9; and MAX_DATAGRAM_BYTES 2048 → 9216. Old peers never send or read any of it. Bump WIRE_VERSION only when the handshake or planes change incompatibly — riding a C-ABI bump onto the wire once locked every new client out of every deployed host (ABI mismatch: client 3 host 2, observed live).

C ABI 14 → 17

  • v15 — the rumble policy engine's C surface. punktfunk_connection_next_rumble_cmd, punktfunk_connection_set_rumble_quirks, PUNKTFUNK_RUMBLE_QUIRK_*. These symbols are not new: they landed while the constant still read 7 and no bump was made, so every core since has exported them while advertising a version that never promised them. A shipped binary says what it says, so this cannot be corrected retroactively — v15 is the floor that guarantees them. At or above 15 the surface is present; below it, probe for the symbol. No code changed with this bump.
  • v16 — the controller-audio client surface. punktfunk_connection_next_pad_audio (the 0xD1 per-gamepad DualSense haptics/speaker plane), punktfunk_connection_set_pad_audio_caps, and the PUNKTFUNK_CLIENT_CAP_PAD_AUDIO / PUNKTFUNK_HOST_CAP_PAD_AUDIO mirrors.
  • v17 — session end reason. punktfunk_connection_end_reason + the PUNKTFUNK_END_REASON_* vocabulary: after a session ends, ask why — this client closed it, the host's launched game exited (its close carried APP_EXITED_CLOSE_CODE, which the host had been sending for a long time with nothing consuming it), the host ended it cleanly, the host reported a failure, or the connection was lost. Purely a read of state the core already had: no new call is required of an embedder, a client that never calls it is unchanged, and the host sends identical bytes either way.

⚠ Breaking changes

1. 149 unprefixed macros are now PUNKTFUNK_-prefixed (139 #defines renamed in the checked-in header). Names as generic as MAX_PADS, TAG_LEN, ABI_VERSION, WIRE_VERSION, INPUT_MAGIC and the whole BTN_* / AXIS_* family were landing in the namespace of every program that included the header.

What to do: add the prefix. Values are identical; the change is mechanical.

It cannot break silently. The old spellings cease to exist, so this is always an undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A colliding #define does not fail to compile: the preprocessor silently takes the last definition, so an embedder whose own header defined MAX_PADS previously got a wrong value at runtime. Associated constants are untouched; the generator already qualifies those by type name.

2. Linux hosts: the virtual Steam Deck controller moved to its own punktfunk group. The capability rode on input, which every gamepad guide tells users to join — but it can emulate arbitrary USB hardware. Operators must usermod -aG punktfunk "$USER" and re-login or the pad stops attaching. Ordinary virtual gamepads are unaffected.

Known issue in 0.25.0, fixed after it. Four of the six install paths shipped 60-punktfunk.rules — whose RUN+= does chgrp punktfunk on the vhci attach/detach nodes — without ever creating the group, so the chgrp failed, the nodes stayed root-only, and the pad silently never attached. The usermod above also fails outright on those boxes with group 'punktfunk' does not exist. Affected: Arch/CachyOS upgraded rather than freshly installed (post_upgrade called only _ensure_update_group), the NixOS module (no users.groups.punktfunk), the Bazzite sysext (a group is host state and cannot ride an image), and Steam Deck source installs (scripts/steamdeck/install.sh/update.sh handled only input). The deb and rpm scriptlets were correct throughout — they run one %post/postinst on install and upgrade alike. All four now create the group, and the two that know which user runs the host (the Deck scripts and the NixOS module's host.users) add that user to it as well. Workaround on an unpatched box: sudo groupadd --system punktfunk, then the usermod, then re-login.

3. Plugins may no longer set launch.command or the pre-launch command. Both run through a shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that populated them need updating — use the launcher_ui / xbox launch kinds instead.

4. Plugin UIs moved to their own origin on a second listener (default PORT + 1, PUNKTFUNK_UI_PLUGIN_PORT). Reverse proxies and firewalls must forward that port; a self-signed console needs it trusted separately.

Capability bits

Four added, all in the handshake's client/host capability bytes:

Bit Constant Meaning
client 0x04 CLIENT_CAP_AUDIO_RED can decode the redundant desktop-audio plane
host 0x20 HOST_CAP_AUDIO_RED is sending it
client 0x08 CLIENT_CAP_PAD_AUDIO can render controller audio
host 0x40 HOST_CAP_PAD_AUDIO is sending it

Pressure worth watching: client_caps has four bits free; host_caps is down to its last one (0x80); video_caps has been full since 0.23.0 (VIDEO_CAP_MULTI_SLICE = 0x80). The next video capability needs a second byte and an ABI bump — plan for it rather than discovering it.

Wire planes

  • Controller audio, 0xD1[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload], one Opus frame per datagram behind a 15-byte header. PAD_AUDIO_KIND_HAPTICS = 0 is the pad's BACK channel pair (the voice coils) at 5 ms frames; PAD_AUDIO_KIND_SPEAKER = 1 is the FRONT pair at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker, silence is a frozen sequence under the mic-mute discipline, host gating at 60 dBFS with a 250 ms hangover. 0xD2 (redundant desktop audio) deliberately skipped 0xD1 to reserve it for this.
  • HidOutput::AudioCtl0xCD kind 0x06, carrying the DualSense output report's volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind.
  • Arrival flags — bits 8 (haptics) and 9 (speaker), sent only toward a HOST_CAP_PAD_AUDIO host.
  • Adaptive-trigger effects are length-bounded on encode and decode against one shared constant; the header emits uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX] in place of a literal 11 (same value, so the struct layout is byte-identical). A zero-length effect body is now rejected rather than decoding as an empty — that is, a release — effect.
  • Out-of-range pad indices are dropped before either rumble consumer sees them. The reorder gate bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it would use to subscript its own array. The client also clamps the host's rumble lease receive-side at 5 s, where the ceiling had been sender-side only.

Host environment variables

Variable Default Notes
PUNKTFUNK_AUDIO_QUALITY high low/standard/high; high = stereo 256 kbps. standard reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading.
PUNKTFUNK_AUDIO_REDUNDANCY unset = automatic on when the client supports it and the budget allows
PUNKTFUNK_AUDIO_OUTPUT_MODE client_only client_only/host_and_client/follow_default. Windows host only.
PUNKTFUNK_PAD_AUDIO on 0 disables controller audio host-wide
PUNKTFUNK_PAD_AUDIO_SLOTS 1 max 4; multi-pad needs an operator to raise it
PUNKTFUNK_PAD_AUDIO_STAMPS unset debug bisect hook
PUNKTFUNK_WIRE_MTU unset pins on-wire IP MTU for all sessions; above 1500 also enables jumbo
PUNKTFUNK_JUMBO unset (off) fixed 9000-MTU profile
PUNKTFUNK_UI_PLUGIN_PORT PORT + 1 the plugin-UI origin
PUNKTFUNK_LIBRARY_ART_ROOTS platform default art-serving roots; POSIX now defaults to $HOME
PUNKTFUNK_DECODER client values changed: native-vulkan · native-vaapi (Linux) · native-d3d11va (Windows) · software. Legacy vulkan/vaapi/d3d11va still accepted and migrated. Now trimmed — a trailing space used to fall through to auto silently.
PUNKTFUNK_VAAPI_DEVICE client new — pin the VAAPI render node
PUNKTFUNK_DUMP_VIDEO / PUNKTFUNK_AU_DUMP client new — capture exact decoder input / the AU as it arrived from the host
PUNKTFUNK_AU_FAULT=drop|truncate|flip[:period] client new — deliberate decoder-input corruption for recovery testing; native rungs only
PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1 host new — opt-in live split-encode arbitration (Linux-wired)
PUNKTFUNK_NO_AUDIO_MINT host (Win) new — opt out of minted endpoints; restores the name ladder
PUNKTFUNK_GPU_PRIORITY host (Win) removed — superseded by PUNKTFUNK_GPU_PRIORITY_CLASS, a strict superset
PUNKTFUNK_FFMPEG_LOG client removed with the av_log machinery

Legacy PUNKTFUNK_HOST_AUDIO=1 and PUNKTFUNK_KEEP_DEFAULT=1 still work, mapping to host_and_client and follow_default; follow_default wins if both are set. New devtest command: punktfunk-host pad-endpoint ensure|remove|status.

Security

  • Origin isolation. A second listener serves /plugin-ui/** and nothing else; the console origin refuses those paths and the plugin origin refuses everything else, /api/** above all. Different origin (scheme+host+port) so same-origin policy is the boundary; same site so the SameSite=Lax session cookie still flows. Bind failure disables plugin UIs rather than falling back. x-pf-listener is stripped inbound and set by the entry; active ports republish as *_PORT_ACTIVE; the plugin origin's CSP names the console as its only frame-ancestors; the proxy allowlist drops the plugin's Clear-Site-Data, Access-Control-Allow-Origin and Set-Cookie. ⚠ The kit's postMessage(..., "*") is load-bearing — narrowing it to location.origin would target the plugin's own origin and drop every message.
  • Authorization is an allowlist with a build-time gate. plugin_may_access is a list of permitted (method, path) pairs with {} segment matching, enforced by a test that walks the live route table and fails the build on any unclassified route — the block-list it replaces let new endpoints through silently. Field authority is tracked separately from route reachability: requests carry the lane that authorized them, and prep / launch.kind = "command" are operator-token only.
  • Art serving gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC refusal, config-dir exclusion and root checking, with file:// percent-decoded before canonicalization so %2e%2e cannot hide. Validation also runs at write time, so an unservable path can no longer be persisted.

Native decode — FFmpeg is gone from the client

268 files, +129k / 25k. cargo tree -p punktfunk-client-session finds zero ffmpeg. The host keeps libavcodec unconditionally (pf-encode); no host workflow, packaging script or licence file was touched.

Platform v0.24.0 v0.25.0
Linux desktop ffmpeg-next: Vulkan hwcontext (pf-ffvk) → VAAPI → libavcodec sw pf-vkdecode (ash, presenter's own VkDevice, zero-copy) → pf-vaadec (dlopen'd libva, DRM-PRIME dmabuf) → openh264 + rav1d
Windows desktop ffmpeg-next Vulkan → libavcodec D3D11VA half pf-vkdecodepf-dxvadec (plans into ID3D11VideoDecoder) → openh264 + rav1d
Android MediaCodec (never had FFmpeg) unchanged
Apple VideoToolbox (never had FFmpeg) unchanged

Workspace members: added pf-bitstream (+ vendored cros-codecs, compiler-enforced unsafe-free), pf-vkdecode, pf-dxvadec, pf-vaadec; removed pf-ffvk. Deleted: video_vulkan.rs, video_vaapi.rs, video_libav.rs, the libavcodec half of video_d3d11.rs, the av_log machinery, ffmpeg::codec::Id as decoder vocabulary, DecodedImage::VkFrame/::Dmabuf, the ffmpeg-fallback feature, and swscale — and with it the BT.601 default its correction code existed to undo.

Software rung: openh264 = "0.9" (BSD-2) and rav1d = { version = "1", default-features = false, features = ["bitdepth_8"] } (BSD-2). dav1d-sys was rejected because it is system-deps- only and would add a system library plus a .pc to every client package. default-features = false drops asm — rav1d's build.rs panics without nasm, unlike openh264-sys2, which degrades quietly. bitdepth_8 only ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before any byte reaches the decoder.

⚠ HEVC has no CPU floor. An HEVC session that exhausts its hardware rungs tears down and re-dials advertising HEVC-less caps, and the host picks H.264 (last_rung_verdict / NoSoftwareRung). This is a first-class path, not a failure.

Rung × codec × hardware evidence (native_evidence) — the admission filter is driven by this, so an unproven rung yields only to one that is both verified for the codec and usable on the device:

Rung Codecs Evidence
native-vulkan H.264, H.265 Main/Main10/4:4:4 yes — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak
AV1 yes — 250/250 bit-identical on one vendor, no soak
native-d3d11va H.264, H.265 yes — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak
AV1 not proven — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter
native-vaapi H.264, H.265, AV1 NO — has never decoded a frame anywhere; no VAAPI hardware was reachable
software H.264 (openh264), AV1 (rav1d) not proven; openh264 has never run on glass. No HEVC at all.

Vendor order (unchanged): Linux NVIDIA/AMD vk → vaapi → sw; Linux Intel/unknown vaapi → vk → sw; Windows NVIDIA/AMD vk → d3d11va → sw; Windows Intel/unknown d3d11va → vk → sw.

AV1 advertisement now answers from device facts (av1_hardware_decodable: Vulkan DECODE_AV1 queue op, or the Windows D3D11 import path) rather than ffmpeg::decoder::find(AV1), which was true on any build linking libdav1d. Settings migration: stored vulkan/vaapi/d3d11va migrate to native-* at decoder construction and at each dialog's lookup — the second is load-bearing, since an unmatched value renders as "Automatic" and a save would silently rewrite the preference.

The three decode data-loss bugs

AV1 sub-frame truncation — shipped in v0.24.0, host-side. NVENC sub-frame readback has two halves armed by different conditions: build_init_params arms the writer from subframe_on alone, while the chunked reader additionally requires slices >= 2 — and resolve_slices returns 1 for AV1 unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header declaring two tile rows plus a single Tile Group OBU with tg_start = tg_end = 0; libdav1d rejected 835/836 AUs. NVIDIA's hardware decoder accepts it (so Vulkan Video looked healthy at 60 fps); its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half the picture. Fixed by disarming sub-frame for AV1 while leaving split_mode untouched — AV1 keeps every engine. Arming the reader instead is not a drop-in: the reader cuts at bitstreamSizeInBytes on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not. Post-fix 654/654 clean. The test that had pinned the old behaviour as correct is replaced by one pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison nothing made.

HEVC DPB from the level ceiling — new in this release, client-side. dpb_limit computed max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1). HEVC equation A-2 is a ceiling on what an SPS may legally signal, not a statement of need, and it branches on picture size against the level's MaxLumaPs. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields 16 frames / 17 slots — one more than NVIDIA's maxDpbSlots of 16 — so every AU fell outside device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that falls through to the honest answer. Fixed to buffering.min(16): the max() bought no tolerance, since Dpb::needs_bumping already evicts at the signalled depth — it only over-allocated ten surfaces per 1080p session. H.264 escaped by luck (its ceiling lands at 13 for 1080p) and is left alone, because H.264's DPB size genuinely is level-derived absent a VUI bitstream_restriction.

rav1d aborts the process — new in this release, client-side. rav1d 1.1.0 abort()s on any decode error while holding one frame context: the c.fc.len() == 1 branch decodes inline, always finishes in rav1d_decode_frame_exit which unconditionally takes frame_hdr, then on Err re-enters an on_error whose first act is frame_hdr.as_ref().unwrap() on the None it just left. The panic unwinds into dav1d_send_data, which is extern "C"panic_cannot_unwindabort(). No catch_unwind, no rung demotion and no refusal can catch it, and every rav1d_* entry is pub(crate), so no in-process guard is possible. 4K was only where the first error happened — the CPU rung does 3539 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to live, and the next AU referenced undecoded frames. Fixed by opening with n_fc >= 2 and asking dav1d_get_frame_delay what the settings actually bought. Decode now drains past the first EAGAIN, which is why two frame contexts cost no latency (2042 ms/unit at n_fc=2 vs 2153 at n_fc=1). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204 frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as rav1d#1497 with a reproducer. Does not make the CPU rung panic-proof.

Settings loader BOM — shipped in v0.24.0, client-side. .and_then(|s| from_str(&s).ok()) turned every parse failure into Default. Set-Content -Encoding UTF8 writes EF BB BF, serde_json correctly rejects at byte 0, and every setting vanished silently. A shared load_json_or_default now strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately still Default, never an error.

Other decode/encode

  • Intel Arc pNext ordering. vkGetPhysicalDeviceVideoCapabilitiesKHR was called with the codec caps struct chained before VkVideoDecodeCapabilitiesKHR (push_next prepends). Arc/Windows fills those two by position, not by sType, and returned them swapped — we read a level as a capability bitmask. Measured A/B: decode_flags_raw=12 max_level_idc=1 before, decode_flags_raw=1 max_level_idc=12 after. NVIDIA and RADV dispatch by sType, which is why the fleet stayed green. ⚠ This does not yet give Arc Vulkan Video — the refusal only moves down: the device advertises only COINCIDE, and its NV12 coincide entry does not advertise SAMPLED usage, which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint.
  • NVENC split encode. The 10-bit rule sat above the pixel-rate arm and took no codec, so it vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89×split wins at every mode on both architectures, including the configuration the veto came from. New order: env override → pixel-rate arm (now taking max_forced_split_mode(engines), not a hard-coded 2) → HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because the driver honours an over-ask and silently encodes narrower. Also newly logged: HEVC + plain AUTO + sub-frame is silently single-engine — the fleet's default shape, and nothing said so. ⚠ Unvalidated consequence: 5120×1440@240 Main10 now clears the pixel-rate bar and will be forced to split — the exact configuration the old veto came from. PUNKTFUNK_SPLIT_ENCODE=0 is the escape.
  • PyroWave on Windows stamped over the host's GPU scheduling policy. It raised the process WDDM class to HIGH at every session open, while auto_priority_gate already owns that process-wide — starting at HIGH, upgrading to REALTIME once safe, and leaving a monitor that drops back when VRAM tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave stamped HIGH back and orphaned the monitor's decision. Removed rather than reconciled.
  • A pf-vkdecode AV1 use-after-free fix had stabilised the wrong pointerOwnedStdAv1SequenceHeader kept the Std struct inline, so pStdSequenceHeader was a dead stack address; it worked only because NVIDIA happened to retain pColorConfig instead. Std structs are now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265.

A/V sync — it did not previously exist

The host has always stamped pts_ns on every audio datagram. Every client decoded it into AudioPacket / AudioPCM and never read it. Video's pts_ns was used end to end; audio free-ran at whatever depth its jitter ring reached; nothing compared them. The A/V offset was an emergent property of buffer depths — it moved whenever the ring ratcheted under underrun pressure, and it got worse every time video got faster, because a quicker decoder lowers the video leg and leaves audio's where it was. That is why shaving milliseconds off the audio budget had never helped.

Two host defects were prerequisites:

  • pts_ns was stamped at encode time, inside the loop draining an already-accumulated chunk, so every frame of a chunk carried near-identical timestamps describing when we got round to encoding. Now derived from the chunk's arrival instant minus queued-frame duration, re-anchored per chunk.
  • The host did not pace. One capture callback hands over a whole quantum (5 ms honoured, 21.3 ms on a VM, where stock PipeWire raises min-quantum to 1024), drained into back-to-back send_datagram calls — a 45 frame burst then ~21 ms of nothing, which a ring could only absorb by standing a burst period deep. Frames now leave on the audio clock (FRAME_INTERVAL 5 ms, PACE_MAX_SLEEP 10 ms, PACE_REANCHOR 100 ms). Costs no average latency.
audio_e2e = (now + buffered_ahead + clock_offset)  pts_ns
av_offset = audio_e2e  video_e2e            (> 0 ⇒ audio behind the picture)

AvSync EWMAs it (AV_EWMA_TAU_MS = 2000), ignores anything inside AV_DEADBAND_MS = 10, waits AV_MIN_OBSERVATIONS = 100 before a first correction, and refuses rather than clamps beyond AV_SANE_LIMIT_MS = 1000 — a wall-clock step must not steer the ring.

Video is the master, and continuity outranks sync. JitterPolicy::set_sync_target takes only a request, clamped between the existing underrun-driven adaptive floor and the hard cap: a link whose jitter genuinely needs more buffer than the picture is away keeps its buffer, and the residual is reported rather than forced. None/nil reproduces prior behaviour bit-identically, which is how the four rings adopted it one at a time.

Per client: the Rust desktop reference is a new video_e2e_ns atomic beside clock_offset, written by the presenter and read by the audio thread. Android publishes OnFrameRendered — the one place that knows a frame latchedraw, not floor-shaved (the HUD shaves the OS present floor; sound must reach the ear when light reaches the eye), and stays inert below API 33 rather than substituting the release instant, which targets a future vsync 821 ms ahead of glass. Apple publishes its LatencyMeter sample as an expiring level, because that client has a backgrounded keep-alive that keeps audio playing while dropping video decode; its clamp raises the ceiling to the floor rather than min(max(…)), which on a device whose callback quantum alone exceeds the hard cap would otherwise hand back the cap, silently below the continuity floor.

Escape hatches: PUNKTFUNK_NO_AV_SYNC=1 everywhere, plus adb shell setprop debug.punktfunk.no_av_sync 1 on Android (a launcher-started app inherits no environment). Observability: buffer_ms/target_ms had only ever been a tracing::debug! line — and on a Deck the client runs under Steam's reaper with stdout on a pipe nobody can read, so the one number identifying a deep ring was unobtainable on the device reporting the latency. Now on the HUD and in the 1 Hz stats log on every client.

Decode-target aliasing — caught before it shipped

None of this ever shipped. git ls-tree v0.24.0 crates/ has no pf-vkdecode, pf-dxvadec, pf-vaadec or pf-bitstream; v0.24.0's decode rungs were libavcodec. This was a ship-blocker for the new stack, cleared — not a field bug.

Three of the four native rungs released a picture's surface inside the plan→submission conversion, then assigned the decode target a slot. SlotMap::assign returns the lowest free slot — the one just vacated. The submission then named one surface as both decode target and its own reference: CurrPicTextureIndex == RefFrameMapTextureIndex[k] on DXVA, or pSetupReferenceSlot sharing an array layer with pReferenceSlots on Vulkan. Decode into the surface you are predicting from.

  • AV1 / D3D11VA — AV1 applies refresh_frame_flags after decode (7.20), so "read a slot then overwrite it" is the ordinary case: 268 of the vendored vector's 274 frames, first at frame 6.
  • H.264 / both Vulkan and D3D11VAH264Planner snapshots dpb_refs in begin_picture, before 8.2.5 marking and the C.4.5.3 bump, so a picture the sliding window unmarks and the bump evicts lands in both dpb_refs and dpb.removed. Both conditions coincide only in low-delay H.264 — and NVENC guarantees it (max_num_ref_frames = 3 alongside max_dec_frame_buffering = 3, plus max_num_reorder_frames = 0). Result: 297 of every 300 access units of every stream a punktfunk host emits, at every resolution, on both rungs.
  • H.265 is exempt, now measured rather than argued — 0 of 120 aliases, with a counterfactual that moves the snapshot one call earlier and reproduces 115 of 120.
  • VAAPI's exemption was incidental: the precondition is fully present (117 of 120 AUs) but plan_to_va never invents a surface. That held only because three call sites happened to write free_surface() and surface_table() adjacently; acquire_target now returns index, surface and table together so a later edit cannot split them.

Fix is uniform: the plans grow release_after_decode, conversions hand removals back, callers release once the decode op is issued. Costs no slot (SlotMap::new allocates max_dpb_frames + 1). Both rungs hold the Result rather than ?-ing it so the deferred release runs on failure paths — seven exits sat between conversion and release, each of which would have leaked a slot.

Why four gates missed it, all recorded: the conformance vector is structurally blind (level 1.3, no VUI bitstream_restriction ⇒ a 7-frame DPB against 2 reference frames, and it reorders) and passed 250/250 for two milestones; a test had encoded the bug as correct; another assertion was vacuous (it asserted the decode target was never also a reference while handing every picture its own never-reused surface id — distinct integers cannot collide); and it streamed clean"the 2026-08-07 field sessions that looked clean were looking at wrong pixels."

gpu_parity is now 11 legs (not 9 — that note was written mid-PR): each decodes a vendored stream, reads back every output frame's NV12, crops to the display region and SHA-256s in display order against libavcodec goldens, frame count and flush tail included. The three new legs are our own encoder's output rather than conformance vectors — H.264 because the vector is blind to the shape, H.265 because an exemption with no stream behind it is how the H.264 defect survived two milestones, AV1 because the vector is one tile on all 274 frames while our encoder splits 4K into two tile rows, so every tile array the conversions fill had only ever been written at index 0. video_vaapi_native parity is new entirely: 7 legs, bit-identical on RDNA3.

⚠ Promoting D3D11VA AV1 to verified changes rung selection on Windows Intel/unknown vendors, not just a label. VAAPI stays verified = false deliberately — one vendor, never soaked; flipping it would move auto off Vulkan Video on every Linux AMD/Intel client including the Deck.

FFmpeg 9, and the Arch soname trap

pf-encode now builds against FFmpeg 9. The host still links libavcodec unconditionally; the client has none (see above).

pacman is the only one of our packaging formats that does not derive dependencies from ELF DT_NEEDED. rpm auto-generates libavcodec.so.62()(64bit), dpkg-shlibdeps emits libavcodec62, nix pins the closure — but a bare depends=('ffmpeg') let pacman -Syu walk the host across a soname bump with no warning and no conflict. FFmpeg 8 → 9 (2:9.0-5: libavutil .60→.61, libavcodec .62→.63, libavfilter .11→.12, libavdevice .62→.63, libswscale .9→.10) therefore bricked every Arch/CachyOS install: the dynamic loader cannot start the binary, so it is exit 127 before main() in a systemd restart loop, with nothing in the host's own log to explain it. ldd /usr/bin/punktfunk-host | grep "not found" is the one-line diagnosis.

The fix is SONAME deps, not a hand-written version bound: depends=(… 'libavcodec.so' 'libavutil.so' …). Arch's ffmpeg declares matching provides=(libavcodec.so=63-64 …), and makepkg rewrites each bare libfoo.so into libfoo.so=<soname>-<arch> by reading the built binary's DT_NEEDED — so the bound tracks whatever FFmpeg the builder linked against with nothing to maintain across the next bump. A literal ffmpeg<2:9 would go stale on every bump. pacman now refuses the upgrade instead of bricking the install. All seven libs are listed even though --as-needed currently drops two: an unlinked soname is left bare by makepkg and satisfied by any ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.

🛑 The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install punktfunk-host 0.25.0-2 or newer. The soname fix and the FFmpeg-9 build landed as one merge; the release tag was pushed four minutes later, while the CI builder image was still being rebuilt. arch.yml deliberately runs no -Syu ("the image's snapshot IS the build environment"), so the release was linked against FFmpeg 8 and published libavcodec.so=62-64 — a bound no up-to-date Arch box can satisfy. It fails safely (pacman refuses; nothing bricks), but it fails loudly and broadly: pacman prepares one transaction, so an unsatisfiable dependency of ours stopped affected users' entire pacman -Syu. 0.25.0-2 is the identical source rebuilt against FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build time and could not disagree with itself this way.

Two guards now stand where only a convention did. arch.yml compares the builder's libav provides against the live repos before building and -Syus itself if they differ; and no package is published until a pristine---dbpath pacman -U --print resolves it, which asks "would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy it?" — the distinction that let this ship. Keeping ci/arch-ci.Dockerfile current is still the cheap path; the guards are the backstop.

Linux playback filled the buffer ceiling

The PipeWire playback callback sized its writes from the mapped buffer's capacity — PipeWire's quantum limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (pw_buffer.requested). Every cycle queued up to 170 ms of PCM downstream of the ring and taught JitterPolicy that the device drains 170 ms per callback, so the underrun floor (want + one frame) rose above any depth the A/V sync loop could request: sync measured audio ~280 ms late and was then forbidden — by its own continuity rule — from draining it. The first on-glass run of the latency overhaul showed exactly that: audio buffer 272 ms, a/v +284 ms, stable. Now honours requested (capacity remains both the ceiling and the fallback when requested == 0) and logs requested-vs-capacity once per stream. Needs libpipewire ≥ 0.3.49; every ship target clears it.

Windows audio substrate

The host now mints its own devnodes from Valve's INFs (SteamStreamingSpeakers.inf / SteamStreamingMicrophone.inf under {CommonProgramFiles(x86)}\Steam\drivers\Windows10\…) instead of bundling VB-CABLE.

  • Two persistent endpoints, Punktfunk Speakers (client-only loopback sink — the wiring plan parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host stays silent) and Punktfunk Microphone (host writes decoded client voice into the render side; the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker.
  • Identity is the recorded endpoint id, never the name — a minted instance is name-identical to Steam's primaries. Durable marker PunktfunkAudioRole (1 = Speakers, 2 = Mic) under Device Parameters. Name stamping is device-desc + device-name only: a wider stamp set makes AudioEndpointBuilder re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure the endpoint still wires and simply keeps the driver's default name.
  • Format stamps are per-direction. Render gets the PCM16-device / float-mix stereo split; capture gets the device-format key only — mix and host-format keys are render-engine properties, and stamping them onto a capture endpoint breaks its shared-mode graph (IsFormatSupported reports 2ch/48k fine, Initialize then fails 0x88890008).
  • MintedIds is tier-0 in the wiring plan. The mic takes its minted device outright (paired by provider id — a name search cannot distinguish it from the primary); the loopback prefers the minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries → cable → real hardware. PUNKTFUNK_MIC_DEVICE still beats everything.
  • Mic-vs-loopback arbitration: the mic may hold the Streaming Microphone only while the loopback still gets a non-last-resort pick; otherwise the loopback takes it and mic_withheld is set. This fixes a field case where a headless Steam-only host streamed silence.
  • New AudioReadinessFull / AudioOnly / MicOnly / Nothing, logged on every plan change and surfaced at GET /api/v1/statusRuntimeStatus.audio (AudioWiring, Windows-only, absent before the first wiring pass; a status poll triggers no COM work or IPolicyConfig writes). The console Dashboard renders it as an "Audio wiring" card.
  • Requires Steam installed (never running) — without the INFs the host streams video only, and picks the drivers up automatically if Steam is installed later. Opt out entirely with PUNKTFUNK_NO_AUDIO_MINT, which restores the previous name-based ladder exactly.
  • VB-CABLE is no longer bundled but is deliberately NOT uninstalled — it is a third-party shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was considered and rejected: on a box where minting transiently fails, that would let the Steam Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio audible on the host.
  • The minted endpoints survive Punktfunk's uninstall by design (they are plain instances of Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is the devtest punktfunk-host audio-probe cleanup.
  • New devtest: punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup. plan is the field-triage command; micpins maps exclusive+shared IsFormatSupported across {1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins.

Apple audio

  • The microphone was never in the render graph. On the combined (voice-processing) engine — made default a week earlier and never run on a device — the input node carried a tap and no connection, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat, and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence tripwire counts captured frames, so it never fired. Input now runs through a silent sink into the main mixer at outputVolume = 0 (Apple's own voice-processing sample topology). Two more: the tap read the input format before prepare(), and enabling voice processing swaps in the VPIO unit and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the voice-processed engine took the whole uplink down for the session — it now falls back to the split path, because the mic outranks the AEC.
  • No packet-loss concealment on the one client that decodes Opus in core. Linux, Windows and Android all feed an AudioGapTracker and synthesize libopus PLC; the in-core path had the tracker sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane (0xD2) hides single losses, so the survivors were exactly the burstier gaps that most needed concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap).
  • The Apple jitter ring never grew. The shared Rust JitterPolicy has an adaptive target floor; the hand-written Apple mirror mirrored the shed half but not the growth half, pinning its target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than one delivery stall, so the ring re-primed through every stall for the whole session. Now the full note_read mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70), 30 s of quiet steps back, and the write-side hard trim follows the grown target.

Clients

  • Nothing in the desktop console had ever been clickable. SkiaOverlay::handle_event matched only KeyDown and TextInput, so every mouse button, wheel and touch contact fell past the console into the run loop, which routes pointer input exclusively at stream.captureNone while browsing. New Overlay::handle_pointer carries mouse/touch in swapchain pixels; the run loop converts (it owns the window and hence display scale); the console hit-tests the rects it drew last frame. Only direct touch devices are offered — an indirect trackpad already drives the mouse. Widgets act on press, not release, because both carousels scroll the focused item toward centre and what you pressed would slide out from under your finger. Host menu on Up from a saved tile; UpdateHost edits in place (remove-and-re-add would silently drop the fingerprint, learned MAC, pinned cards and profile binding), and ForgetHost arms on first press and fires on second.
  • Discovery went permanently deaf three ways, each needing an app relaunch: a failed resolve was never retried (browseResultsChangedHandler fires only when the result set changes, and a host whose resolve failed is still in the set); a stuck resolve never ended (NWConnection has no timeout, so the throwaway UDP flow could sit in .preparing forever, and a service with a connection in flight was skipped); and an NWBrowser parking in .waiting was ignored — which is exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not revive the browser that was already waiting. A 1 Hz sweep now times out stuck resolves, retries failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every browse report. discovery::Rescan forces a fresh mdns-sd query — the browse otherwise re-queries on a doubling backoff capped at one hour, so a long-lived browse is effectively passive. ⚠ clients/windows/src/discovery.rs is a second copy of the browse that the earlier IPv4 pinning missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a card that failed on every click.
  • Phone gyro mirror, off by default, player 1 / wire pad 0 only, and only while that pad has no motion source of its own. iOS/iPadOS only on Apple (DeviceGyro wraps CMDeviceMotion at ~100 Hz on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source); Android phones with a gyroscope at ~200 Hz with maxReportLatencyUs = 0, since batching is poison for gyro aim. Both rotate from the device's natural frame into the controller frame by interface orientation, and both send one zero-gyro sample on stand-down — the host holds motion as state and re-emits it, so a leftover nonzero angular velocity reads as endless rotation.
  • Safe-area resolution is purely a sizing change — no layout change, no input change; pointer mapping follows for free since both clients derive the picture rect from the live host mode. Full native height, width less left+right safe insets. Portrait settings screens report the housing on top with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status bar never fabricates one). Android adds the rounded-corner radius, which it does not count as cutout. Both even-floor and clamp, because validate_dimensions rejects odd dimensions and an inset subtraction lands odd about half the time.
  • Gamepad UI: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one shared ui_palette key, Violet keeping its explicit sixteen colours so existing installs are an identity transform. Presentation only → device preference, never part of a profile. Palette maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language; every_palette_is_multi_tone fails under 45° hue spread and caught Ember at 35° and Graphite at 3°. Three render-only findings: additive blending blows out over a pale ground, a white scrim at the dark field's strength bleaches the gradient, and white glass over a bright field needs more body.

Session and game lifetime

  • PunktfunkEndReason replaces a single "closed" bit (ABI 17, additive, wire untouched). Five values — local, game exited, host ended, host error, lost — classified by the connection watcher from close codes already on the wire (APP_EXITED_CLOSE_CODE had been sent for a long time with nothing consuming it). Latched before the shutdown flag, because the two are read by different threads and the reason must never arrive second. Exposed as punktfunk_connection_end_reason + is_normal(). Shells fall back to the old wording when there is no verdict (older core, or a close that raced the read).
  • The Steam Running registry hint was an unbounded veto. Honouring it reset the absence window every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented — pinned a lease in running for the life of the host process. The absence timer now runs regardless; past VETO_LIMIT (30 s) with nothing of the game on the box, the session ends anyway and logs at WARN. Extracted as a pure exit_confirmed(gone_for, hint_running) with tests — the watch loop polls a live process table and cannot be unit-tested, which is exactly how the unbounded veto shipped.
  • New launchreg.rs: one record per (client fingerprint, library id), written at launch and independent of the termination policy. The old fingerprint-keyed reclaim only ran under GameOnSessionEnd::Always, so under the default Keep nothing was recorded — and a client retry re-sent Hello::launch verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it (the launcher just focuses the running copy) but a gog:/custom: target genuinely started a second instance over the same save files. The same retry also minted a fresh launch_stamp, so procscan refused to adopt a game older than 2 s and a reconnected session lost game-exit detection for the rest of its life. Identity now flows backwards from the watcher, which publishes the concrete ProcRefs it adopted; liveness is Scanner::alive over that recorded set, re-verified by (pid, start). Tradeoffs: a custom: command with no detection hints stays Unknown forever (trading exit detection for not double-spawning), and IN_FLIGHT_WINDOW is a fixed 90 s, deliberately not disconnect_grace_seconds.
  • A launcher entry is LeaseKind::Untracked unconditionally, checked ahead of nested/child/spec. Its lifetime previously depended on invisible state: launcher not running → live child → Child lease → quitting the launcher ended the session; launcher already running → command forwards and exits inside SHIM_WINDOWUntracked → session persists. Steam Big Picture is a mode, not a process (and on a Deck it is always running); Heroic is single-instance Electron. The real trap was the GameStream path, whose GsApp intermediate silently dropped the field.

Library and plugins

  • Store claims keep identity across the scanner-to-plugin handover. library.json gains a v2 {entries, claims} shape that reads the old bare array unchanged and rewrites on first mutation. PUT /library/provider/{p}?store=<s> claims a store; entries then surface as <store>:<external_id> rather than custom:<id>, so entry ids, GameStream app ids, client art caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is held the matching built-in scanner is skipped, so the two never double-list.
  • GET/PUT /library/scanners is now a sources endpoint over the same disabled-set file.
  • New entry fields: role: game|launcher; launch kinds steam_ui (bigpicture|desktop), launcher_ui (platform-gated, 400 on invalid) and xbox.
  • Plugin kit 0.3.0 adds a ./library subpath: defineLibraryPlugin plus ported total parsers — text VDF/ACF, the binary shortcuts.vdf walker with CRC-32 appid derivation, read-only immutable SQLite, a registry wrapper that refuses HKCU, path-confinement joins. GET/PUT /__config returns {schema, value} and persists raw, so a plugin with settings need not ship an SPA.

Platform and packaging

  • The client's config writer falls back to an in-place write when the atomic replace is unavailable, verifies it by reading the bytes back, and records the last persistence failure centrally so the UI can surface it. Scratch files are now per-process, closing a real collision between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one could previously rename its half-written temp over another's target.
  • Host send pacing gained a pure, unit-tested budget function: oversized frames are budgeted at the pacing rate with a 100 ms absolute ceiling rather than compressed into one frame interval. Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment escape hatch, and the GameStream-compatible path is untouched.
  • Mid-session shard renegotiation is gated off for PyroWave sessions, which parse the video stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those sessions get the next-session clamp only and are excluded from jumbo. The ABR decode-cap latch likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design.
  • The Deck's Vulkan compatibility layer is built from source, pinned to the same upstream revision as the host's own packaged build — bump both together. ~4 MB of app content replaces a 94 MB external extension, and Flathub is no longer needed at install time. ⚠ subprojects/vkroots is a gamescope submodule and flatpak-builder clones submodules by default; declaring it again as an explicit source breaks the build during extraction. glm and stb are .wrap files, not submodules, and do need explicit sources.
  • Build-container images push to an authenticated registry endpoint, and :latest is reconciled against the content key on every push to main — an out-of-band tag move is detected and repaired rather than silently inherited.
  • Windows pad drivers publish their sequence counters with release ordering (the host was already loading with acquire and pairing with nothing) and serialize the output-ring publish. The /dev/uhid event ABI, previously transcribed into all five Linux gamepad backends, is consolidated into one module.

Verification status

Honest about what has and has not been on hardware, because several things in this release have not:

  • Controller audio has never run on a real DualSense. Its entire verification is unit tests and compile checks, and its rumble arbitration rests on an explicitly retracted assumption about whether the voice coils and the rumble motors are the same actuators. The evidence-based 500 ms idle window is correct either way, but the underlying exclusivity is unsettled. Android's arbiter is the evidence-based one; the desktop twin and the coil restore on Android's stop path are owed. Some Android OEM kernels refuse the isochronous claim outright, which degrades to ordinary rumble.
  • The plugin-UI origin split is validated against a fake console and a fake plugin, not yet in a real browser.
  • The packaging default-on changes have had no installer run or package build.
  • No launcher tile has been clicked on a real host — the first source that would publish one does not exist yet.
  • Desktop-audio, packet-sizing and iPad-pointer work is build-verified only.
  • The FFmpeg-deletion milestone itself has never executed on a GPU. It was gated on cross-clippy, 160 tests, a workspace check and an ffmpeg count of 0 in the client / 2 in the host. The software on-glass check, the D3D11 and VAAPI AV1 hardware legs and the field bake were all owed at merge; later commits closed some of that but not all. The "no FFmpeg" claim is verified by cargo tree and a notices-generator mention count, not by inspecting a shipped binary.
  • pf-vaadec has never decoded a frame anywhere — no VAAPI hardware was reachable. It is the first rung on Linux/Intel and unknown vendors; the evidence filter bars it there in favour of pf-vkdecode, but an explicit pin reaches it.
  • openh264 has never run on glass; the H.264 software rung is unit-tested only.
  • native-d3d11va AV1 is deliberately verified = false — one 25 s 4K60 session, no parity.
  • Split arbitration is opt-in and Linux-wired only; the Windows arm is built and unit-tested but not on hardware. The 5120×1440@240 Main10 behaviour flip is explicitly unvalidated and is named as the first thing to re-measure.
  • Software throughput is unmeasured in general — the CPU rung does 3539 fps at 4K AV1 against a 60 fps stream, which is why the backlog flush that triggered the rav1d abort happens at all.
  • The Apple mic fix is a proven root cause, not a verified session. Its own commits call it "a strong inference plus one proven logic defect rather than a confirmed fix" and close "awaiting the reporter's on-device confirmation" — which nothing later in the range records. It also leaves a known gap: nothing reports whether the uplink actually opened, so the HUD still offers a Mute Microphone button over a session that may be sending nothing.
  • The Windows audio substrate is, by contrast, well-evidenced on hardware — repeated "measured on the target box", a live bisect on a fresh endpoint, and a micpitch proof reading 440 Hz in → 440 Hz out at exact peak. The one thing not evidenced is a real client speaking through the minted microphone end to end; the pitch proof is probe-driven.
  • The phone-gyro mirror is not recorded as hardware-verified — remap matrices are pinned by unit tests in both languages, but there is no "played a game with a clip-on pad" evidence in the tree.
  • The iOS gamepad-UI pale-palette sweep on glass is still owed, per its own commit.
  • The CI runner scripts are hand-installed (/usr/local/bin/ci-docker-prune.sh, /usr/local/sbin/ci-docker-reclaim.sh). Merging does not deploy them — both runner hosts need the files copied out of scripts/ci/, and the missing 192.168.1.58:5011 insecure-registry entry on one host is routed around, not fixed.