0.28.0 #203

Merged
enricobuehler merged 1 commits from worktree-release-0280 into main 2026-08-13 16:20:04 +00:00
8 changed files with 1564 additions and 68 deletions
+521 -2
View File
@@ -12,7 +12,62 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.27.1 — in development
## v0.28.0
180 commits since v0.27.0.
### Versions
| | v0.27.0 | v0.28.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 18 | **19** | `punktfunk_connection_note_frame_index_ex` + `punktfunk_reanchor_gate_arm_expecting_drops` **added**; nothing removed, nothing widened |
| Rust edition | 2021 | **2024** | the whole tree bar four vendored crates |
| MSRV (`rust-version`) | 1.82 | **1.85** | the *declared floor* only — the pinned toolchain is unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 members; two `tools/` crates still deliberately *excluded*) |
| 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.25.0 | **0.27.0** | the management API **did** change this release (below); the file was regenerated mid-cycle, so it carries the then-current stamp, not `0.28.0` |
| gamescope patch level (`+pfhdrN`) | 5 | **6** | 7 patches → 8 (`GAMESCOPE_NO_FOCUS`); no new capability |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.1** | publishes the `icon` field |
**`crates/pf-driver-proto` changed again**, as it did in v0.27.0 — but *not* in its contract. The
wire bytes, `PROTOCOL_VERSION` (6) and `MIN_DRIVER_PROTOCOL_VERSION` (3) are all untouched; what
moved is the manifest (`edition`/`rust-version` now inherit from the workspace) and one test that
was reading a `[u8; 40]` through `bytemuck::from_bytes` — an alignment assumption a favourable
stack slot had been hiding, and the kind of thing Miri exists to catch (below). If you ship the
driver or the gamepad channel, this release needs no re-integration.
**`api/openapi.json` is still not gated by CI** — nothing regenerates or diffs it in a workflow.
A unit test (`openapi_document_is_complete_and_checked_in`) does compare the checked-in copy against
the served document, with `info.version` normalized on both sides, so the *surface* is protected
even though the stamp drifts. The docs-site copy is a plain file copy and was **not** protected:
see the note under **Documentation** below.
### ⚠ Breaking changes
**None on the wire, and none that break an embedder at runtime.** Wire protocol 2 is unchanged, so
existing pairings and every shipped client keep working; the C ABI moves by addition only. What
follows changes what the **host itself does**, how you **build**, and what a **stock package does by
default**.
- 🛑 **The host no longer scans any launcher itself — the six built-in library scanners are
deleted and replaced by plugins.** This is the only change here that can leave a working install
visibly emptier: **a host with no library plugins installed has an empty grid.** Full detail and
the (deliberately absent) migration below.
- **Rust edition 2024, MSRV floor 1.85.** If you vendor or patch any workspace crate, your toolchain
must be ≥ 1.85. Our pinned toolchain did not move — only the declared floor.
- **Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC.
No CMake, Go or NASM for the default (non-FIPS) build. Detail under the TLS section below.
- **GameStream is opt-in on every route.** A packaged host that served Moonlight by default becomes
native-only until the operator sets `PUNKTFUNK_GAMESTREAM=1`. Full detail below.
- **No punktfunk process holds REALTIME GPU priority any more.** Both levers (the driver's
`IddCxSetRealtimeGPUPriority` raise and the host's `HIGH → REALTIME` auto-upgrade) default OFF;
the ladders that re-enable them are new opt-ins. This is a field-convicted stall fix, below.
- **The shipped Bazzite `host.env` template no longer pins `PUNKTFUNK_GAMESCOPE_ATTACH=1`.** If you
copied it verbatim — which the docs told you to — Game Mode was mirroring the box's screen. Below.
### The six built-in library scanners are gone — every game source is a plugin (⚠ operator-visible)
@@ -167,7 +222,8 @@ The checked-in `api/openapi.json` remains the default-features document.
One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the
planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API
now share a separate **ECDSA P-256** identity (`native-cert.pem`/`native-key.pem`): generated by
ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs
rcgen on the workspace's aws-lc-rs backend, browser-compatible (Ed25519 server certs are not),
carrying real SANs
(localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted
`rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched.
@@ -184,6 +240,34 @@ grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a
(P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=<config>/native-cert.pem` in its
environment or rebuild against the current SDK.
**It is ECDSA P-256, not Ed25519 — deliberately.** rcgen can generate either, and Ed25519 would
be the obvious modern pick, but **no mainstream browser accepts an Ed25519 server certificate** and
an operator opens `/api/docs` in one. P-256 is the strongest curve that keeps the management API
reachable from a browser.
#### 🗓 Deprecation: the legacy-identity fallback goes away on **1 October 2026**
The fallback in `load_or_adopt` — "an upgraded host with live native pairings keeps presenting the
legacy RSA cert those clients pinned" — is a **migration aid, not a permanent branch**. From
**2026-10-01** the host stops taking it: a host that still holds only `cert.pem`/`key.pem` will mint
the P-256 identity and its native clients will have to re-pair once.
**Scope, precisely** — this affects the **native punktfunk/1 plane and the management API only**:
- **The GameStream/Moonlight plane is NOT deprecated and keeps its RSA identity permanently.**
Moonlight mandates RSA and its pairing hashes bind the cert's X.509 signature bytes, so that
identity cannot move without breaking every Moonlight client. Nothing about that changes on any
date.
- Operators who want the split **today** need no new release: unpair all native clients, restart the
host, re-pair. The host already logs exactly this.
- Fresh installs since v0.28.0 are already on P-256 and are unaffected.
**This date is a published commitment**, tracked as
[#201](https://git.unom.io/unom/punktfunk/issues/201) (due 2026-10-01), which carries the arm to
delete, the three identity-following consumers to re-check, and the test that has to invert. Without
it the notes would have promised something that silently never happens — the same shape as the
v0.22.3 notes describing a feature that release never contained.
### Memory-safety, compiler-enforced (embedder-visible lint tightening)
`punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network
@@ -344,6 +428,441 @@ New CI job **`smoke-install`** installs every published package from the registr
the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how
both facts above survived for so long.
### 🛑 The six built-in library scanners become plugins (M6/WP6.4 — breaking)
The host no longer scans any launcher. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and the
`scanner_defs()` table are **gone**; `GET /library/scanners` now lists exactly what the operator
installed, every row `origin: "plugin"`. This ends the migration whose bridge half shipped in
v0.26.0 — the plugins have been published and index-pinned since 2026-08-08, so the replacement has
been in the field for the whole bridge window.
⚠ **The upgrade note is the whole of it: a host with no library plugins installed has an empty
grid.** The console's one-click install per source is unchanged and still never auto-installs.
⭐ **There is no migration, by construction, and that is why this could be a deletion rather than a
rewrite.** A plugin *claims* its store (D2), and a claimed entry surfaces under the same
deterministic `<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a
app ids, client art caches, Moonlight pins, the per-source toggles and the per-entry hides all key
on that id and **none of them move**. `library-scanners.json` keeps its name, shape and contents —
an operator who had `steam` off still has it off.
Kept deliberately:
- **`launch.rs` in full.** Launch is host-owned by design (D1): a plugin publishes a validated
value, the host builds the command, so every typed kind survives. `xbox_pfn()` **moved here** out
of the deleted `xbox.rs` — resolving a package Identity to its PackageFamilyName needs
`AppRepository` enumeration, readable by the host (LocalSystem) and **denied to the plugin runner**
(LocalService). That measured asymmetry is the entire reason the `xbox` launch kind exists, so the
resolver is launch vocabulary, not scanner vocabulary.
- **`SourceOrigin::Builtin`.** No host build emits it any more, but the console ships as its own
package and drives an N-1 host that still does, so the variant stays in the schema.
- **A store-label table**, so a source row does not rename itself from "Steam" to `steam` the day
its plugin takes over.
Removed with the scanners: the background cover-art warmer and its on-disk cache (they existed only
for GOG and Xbox, the two sources that had to ask a network catalog what a cover was — a plugin
resolves art while it scans), the legacy `steam:` branch of the art proxy, and `GameMeta::pc()`.
### Mutter monitor rebuilds are serialized end to end — the two-client chain no longer kills GNOME
🛑 **Chaining two clients through a kept (keep-alive) Mutter display segfaulted gnome-shell in
`meta_monitor_manager_rebuild` (libmutter-18) and took the whole desktop down**; every later session
then failed `RemoteDesktop.CreateSession: ServiceUnknown` until GDM restarted, so the client just sat
black. ⭐ **A/B'd on .21 during this release's validation: byte-identical on the released 0.27.0 and
on the 0.28.0 RC — it was never a regression, the trigger had been there all along.**
`TOPOLOGY_LOCK` already serialized every topology-mutating D-Bus call, but two gaps let Mutter's
*rebuilds* overlap:
- **Teardown was fire-and-forget.** `StopGuard::drop` set a flag and returned; the session thread
only noticed on its ≤200 ms park tick. The dead-reuse path (reused kept display dead on first
frame → `mark_failed` → re-create) therefore issued its fresh `RecordVirtual` with the doomed
monitor's removal still pending — the fresh session could even win the lock *before* the old
thread had woken to take it, adding a monitor while the dead one still stood. The drop now waits
(bounded, 20 s) for the session thread to finish.
- **The lock was released while the shell was still rebuilding.** `Stop` / `RecordVirtual` /
`ApplyMonitorsConfig` all return mid-rebuild, and an `APPLY_TEMPORARY` config auto-reverts
asynchronously on top. Every locked mutation now ends with `settle_topology()` — poll
`GetCurrentState` until a removed connector is actually gone and the config serial holds still
across two consecutive reads — before the guard drops. Bounded at 4 s and best-effort (a read
error means the shell is gone; a hotplug storm must not park sessions), degrading to exactly the
old behaviour.
Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck per setup/teardown.
### KWin ≤60 Hz — the virtual output's real size is finally read back
🛑 **A 4K60 GameStream session captured 1920×1080.** `create()` asked KWin for 3840×2160, KWin built
something else, and nothing compared the two: only the >60 Hz arm read anything back, and it gets
that for free because it installs a custom mode. The ≤60 Hz arm installs nothing, which is exactly
why it never noticed.
**The line that should have caught it was the one that hid it.** `spawn_vout` returns a node id,
never a size, so `tracing::info!(node_id, width, height, "KWin virtual output ready")` was echoing
the **request** — the field log stated 3840×2160 while the output was 1080p, and the first pass at
diagnosing this was done against that number. It now logs `requested_w`/`requested_h` with the
readback beneath it.
### Apple/Android audio — the de-prime fuse counted callbacks, not time
🛑 **An iPad gave up on its audio ring three times sooner than a Mac**, which is the residual Apple
jitter that survived both the PLC fix (#82) and the jitter-policy fix (#111).
`JitterTuning::deprime_after` counted **callbacks**, and a callback is not a unit of time: the same
`4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks
for a short IO buffer — the shortest fuse of any client, on the one with the burstiest transport. A
100 ms Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the
identical policy rode it out everywhere else. It is now **`deprime_ms`**, measured in starved audio,
with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real hysteresis instead of
de-priming on the first short read. ⚠ **Android was latently exposed too** — AAudio's low-latency
burst is ~45 ms, so its `5` was also ~20 ms.
Measured by driving the real policy through a simulated link (100 ms stall / 5 s, 30 ppm, 10 min)
at a 5 ms quantum: **120 audible gaps and 690 ms of dead air before, 2 gaps and 60 ms after.**
### Console — "Update all" on the plugins screen
The Installed tab could only update one plugin at a time, one dialog and one watched job each. The
bulk action now sits beside the list it acts on, plus a count badge on the Installed tab trigger
(Browse is the tab the page opens on, and a control nobody passes is a control nobody finds).
**The host takes ONE package operation at a time** — 409 otherwise, because bun operations share a
lockfile and a `node_modules` tree — so this is a queue the console works through job by job, driven
by each job settling rather than by a timer, carrying its own copy of what is left.
### Android — the in-stream mic control leaves the stream overlay
The mic element sat in the top-right of every stream that opened a capture (a standing button on
touch, a Muted badge on TV). It is gone for now; the on-screen overlay UI being built will carry
mute as one of its controls. **Mute itself is untouched**`micRunning`/`micMuted`/`setMicMuted`
still back the Select + Y chord, which is now the whole of the control, and `MicChordHint` is its
only on-screen feedback.
### ⚠ Flatpak — the currency wave's one loose end
🛑 **Every flatpak leg died after #193.** The dependency currency wave took skia-safe/skia-bindings
0.87.0 → 0.99.0 in `crates/pf-console-ui/Cargo.toml`, but `packaging/flatpak/io.unom.Punktfunk.yml`
still pinned the **0.87.0** prebuilt archive, so the build failed with
`no variant … named 'Default' found for enum 'SkPathFillType'` inside
`skia-bindings-0.99.0/src/defaults.rs`. Nothing in that message points at the manifest, so it reads
like a crate bug — it is not: `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned
tarball verbatim, **including its `bindings.rs`**. Archive pinned to 0.99.0.
**If you bump `skia-safe`, bump the flatpak archive in the same commit.**
### Rust edition 2024 across the tree (MSRV floor 1.85)
The whole main workspace and `pf-vkhdr-layer` move to **edition 2024**; `[workspace.package]`
declares `edition = "2024"` and `rust-version = "1.85"`. The pinned toolchain did not move — only
the declared floor — but if you vendor or patch a workspace crate, 1.85 is now the minimum.
This is the safety half of the rust-safety programme's §8.4, not a tidy-up: in edition 2024
`std::env::set_var`/`remove_var` are **`unsafe fn`**, which converts an entire bug class from
invisible to counted. The environ data race the programme found the hard way lived in a file
containing zero occurrences of the word `unsafe`; every one of the 20 files that mutate the
environment now carries an `unsafe` block with a SAFETY comment naming the actual serialization
argument (a named lock, or a `--test-threads=1` contract, or single-threaded startup).
What a downstream integrator sees:
- The 13 crates that pinned `edition = "2021"` **literally** now inherit from the workspace. A root
bump alone would have reached only the `edition.workspace = true` crates and left `pf-encode`,
`pf-capture`, `pf-inject` and friends on 2021 while reading as complete.
- 148 `#[no_mangle]``#[unsafe(no_mangle)]` (83 of them in `abi.rs`), and 12 bare `extern` blocks
`unsafe extern`. Done textually across **all** `cfg` branches, because 44% of the host's unsafe
is Windows-only and a one-platform `cargo fix` silently misses it.
- `gen` is a reserved keyword in 2024, so `pf-vdisplay`'s generation stamps and the WinUI shell's
animation counters rename `gen``generation`. **Internal identifiers only — no serde field, no
wire name and no API surface changed.**
- The four **vendored** crates (`fec-rs`, `cros-codecs`, `usbip-sim`, the patched `ndk`) stay on
2021 deliberately: upstream code stays pristine.
### No punktfunk process holds REALTIME GPU priority by default (⚠ default change)
🛑 **Both of our REALTIME GPU-scheduling levers were convicted of *generating* the metronomic
capture-stall class the stall program has chased for weeks** — compose-silence holes of 150800 ms
in which ETW shows no process presenting while the GPU stays responsive. From the RX 9070 XT field
A/B: the virtual-display driver's `IddCxSetRealtimeGPUPriority` raise beat at ~1.751.78 s, and the
host's `HIGH → REALTIME` auto-upgrade beat at ~3.58 s in the sessions where it promoted. Disabling
each removed its own metronome; pinning both left the stall rate at the clean-run baseline.
Neither period matches **any** punktfunk clock — the full periodic-actor census (driver drain,
16 ms `E_PENDING` wait, 33 ms cursor poll, 3 s watchdog; host descriptor poll, VRAM gate, exclusive
re-assert, pinger, stats, phase-lock, LTR marks) has nothing in the 1.692.29 s band, and the period
even differs by *which* of our processes holds REALTIME. The periodicity is emergent from holding an
unreachable-priority queue against the WDDM scheduler on this AMD family. There is therefore no
punktfunk cadence to fix; the fix is to stop holding REALTIME, which is also canonical parity — no
shipping IDD raises it, and HIGH is the class that delivered the original encode win.
- **Driver:** the old `PFVD_NO_RT_GPU` opt-**out** (default ON) becomes the **`PFVD_RT_GPU` ladder,
default OFF on every vendor**. Unset = no raise = canonical IDD behaviour.
- **Host:** the `pf-frame` auto-gate no longer upgrades to REALTIME. `PUNKTFUNK_GPU_PRIORITY_CLASS`
still pins a class explicitly.
### The reanchor gate learns gap WIDTH — two new C ABI exports (ABI 19)
🛑 **Every unrecoverable loss armed the client's freeze gate twice**, and on AMD hosts the second arm
re-froze a stream that had already healed. The two signals are the frame-index gap (instant, and what
fires the RFI) and the reassembler ageing the lost frame into `frames_dropped` (~120 ms later, which
re-armed unconditionally). An LTR-RFI recovery anchor lands in ~60 ms — *between* them — so the stale
climb re-froze a bit-exact-healed picture, the host swallowed the re-ask as an RFI echo, and the
stream stayed frozen until the overdue backstop extracted a full IDR. This is the field
"H.265 freezes on every loss, AV1 fine" signature: AMF is the only LTR-RFI backend, and the slower
IDR path usually lands after the climb and dodged the race.
The gap-arm now **pre-credits** the climb it knows is coming (`ReanchorGate::arm_expecting_drops`;
the credit expires after `DROP_CREDIT_WINDOW` so a straggler-filled gap cannot mask a later real
loss), and `poll()` consumes credited climbs instead of re-arming. Plumbed through every embedder:
`pf-client-core`'s session pump, Android's sync and async loops (`note_frame_index` now returns the
gap width), and the Swift client via the two new exports —
**`punktfunk_connection_note_frame_index_ex`** and **`punktfunk_reanchor_gate_arm_expecting_drops`**.
Both originals keep their signatures and their behaviour, so an embedder that adopts neither is
unchanged; it simply keeps the race. Nothing new goes on the wire.
### ⚠ `punktfunk_send_input` now rejects an unrecognized event kind
`punktfunk_send_input` and `punktfunk_connection_send_input` **validate `ev->kind` before forming a
reference** and return `InvalidArg` for a value that is not a recognized `InputKind`. Previously the
byte was transmuted into an enum, which is UB for an out-of-range discriminant — a caller passing an
uninitialized or garbage `kind` had undefined behaviour rather than an error return. The safety
contract in the header relaxes correspondingly: `ev` need only point to *a readable
`InputEvent`-sized allocation*, not to an already-valid `InputEvent`. **If you build an event by
zeroing a struct and setting fields, nothing changes.** If you relied on an unknown kind being
silently forwarded, it is now an error.
### Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
The 0xD1 per-gamepad audio plane (DualSense haptics + speaker) was **Windows-host-only**:
`host_cap()` answered false everywhere else and `spawn()` was a stub, so a tier-A Android client
against a Linux host negotiated the capability off and fell back to wire rumble. The downstream
machinery — framer, silence gate, lanes, 0xD1 send — was already capture-agnostic; only the capturer
was WASAPI.
Linux hosts now mint **one PipeWire Audio/Sink node per DualSense-family pad**, carrying the identity
the game-side matchers read (ALSA-style `node.name` with the pad's pairing MAC, description
"Wireless Controller", bus/vendor/product/form-factor proplist, per-pad serial), 4-channel F32
48 kHz FL/FR/RL/RR, claiming no default sink, `priority.session 50`. The `process()` callback *is*
the capture. `host_cap()` on Linux = client asked **and** `PUNKTFUNK_PAD_AUDIO` **and** a reachable
PipeWire socket; the sink is minted lazily in the streamer thread. `PUNKTFUNK_PAD_SINK_NAME` /
`_DESC` override the strings for field debugging (`{pad}`/`{mac}` expand).
`PUNKTFUNK_PAD_AUDIO{,_SLOTS}` are no longer documented as Windows-only. Verified on a Bazzite 44
host: identity served through `pipewire-pulse`, rear-pair voice-coil tone captured bit-exact over
both the native and Pulse legs. The Linux sink speaks GE-Proton's AUX03 channel shape.
### Wake-on-LAN now works over Wi-Fi (WoWLAN)
The host's arming check asked **`ethtool`** about every NIC, which is the wrong question for
wireless: the magic-packet trigger lives in nl80211's WoWLAN state, and most Wi-Fi drivers print
`Wake-on: d` whether or not it is armed. An armed Wi-Fi host was therefore reported as *not* armed
and handed an `ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`, and the warning carries
WoWLAN-correct guidance (`iw … wowlan enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect). Two fallbacks for when `iw` cannot
answer: a **positive** ethtool reading counts (brcmfmac and friends do report there), a negative one
never does, and sysfs `device/power/wakeup` reading `disabled` is conclusive in the negative.
The **client sender** now emits from a socket bound to each non-loopback interface's own address
instead of leaving the choice to the routing table. A station in WoWLAN sleep stays associated and
its AP buffers broadcast frames until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default route
`255.255.255.255` never did. A failed bind falls back to the routed socket, so no segment is lost.
### Zero-copy capture withholds buffers until the encoder has finished reading
🛑 **Gamescope streams could tear pink at 120 fps.** The raw-dmabuf passthrough handed the SPA buffer
back to gamescope at `.process` return while the encode thread had not yet imported — let alone read
— its dmabuf, and nothing ordered the producer's writes against the consumer's read (there is no
explicit sync, and the implicit-fence wait measures `NoFence` on every compositor × vendor pairing we
have). On the direct-VCN arms (native NV12, RGB-direct EFC) the captured buffer *is* the encode
source for the whole 2-deep encode ring plus the phase-lock hold, so at 120 fps gamescope cycles back
into the buffer mid-encode: luma/chroma desync (the magenta tint) plus block corruption propagating
through the P-chain until the next intra. KDE sessions were clean because `cursor_blend` routes them
to the compute-CSC copy arm, whose read window is microseconds.
A published passthrough frame now carries a **`FrameHold`**, and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold into the ring slot at submit
and releases it when that slot's fence retires, extending "the producer must not rewrite this" across
exactly the GPU read. The host loop's repeat path is fixed by the same mechanism.
### Bazzite Game Mode no longer mirrors the box's screen (⚠ shipped-template default)
🛑 **Our own template caused it.** `packaging/bazzite/host.env` set
`PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install path — rpm, deb, Arch, nix — ships that file as
`/usr/share/punktfunk/host.env.bazzite` with the docs telling people to copy it verbatim. So the
*recommended* Bazzite setup turned the attach override on for everyone.
That override is **rung 2** of `pick_gamescope_mode`, above `dedicated_launch` at rung 3. The rung
comment calls the operator overrides a debug/CI escape hatch — correct, but we were shipping one as
a distro default, so on a Bazzite box the managed takeover and the dedicated game session were both
unreachable, and a game launched from a client's library could not get a session of its own. With a
physical display connected, attach then takes the `physical_display_connected()` arm and streams the
box's own head at the box's own mode: the mirror the field report described.
The template now forces nothing and lets per-connect detection answer, which on a box with
`gamescope-session-plus` is MANAGED. Attach stays available, documented as the opt-in it is, with the
mirror and the dedicated-session cost stated.
### `edid_lock` — pin AMD connector EDID emulation while streaming (EXPERIMENTAL)
A new display-policy axis beside `ddc_power_off` / `pnp_disable_monitors`, orthogonal to presets and
**off by default**. At the first Exclusive isolate the host pins each occupied AMD connector's live
EDID plus `ADL_EMUL_MODE_ALWAYS` — the software equivalent of an HPD-holding dummy plug — **before**
the physicals deactivate, so an awake sink answers its own live-EDID read; last-member teardown
unlocks. It targets the standby-sink stall class at its source: with emulation pinned the kernel-mode
driver stops servicing the sleeping sink's HPD/DDC/link.
Pinned emulation outlives the process, so a crash journal (`edid-lock-active.json`) unlocks on the
next host start, mirroring the `pnp_disable_monitors` recovery. Inert without an AMD driver
(`atiadlxx.dll` absent) and on non-Windows. The ADL FFI lives once in `pf_win_display::adl_emul`, so
the new **`display-disturb adl-emul`** probe and the host exercise byte-identical driver calls. The
console shows the toggle **only** when the GPU inventory lists an AMD adapter — a toggle that can
never act is exactly the "saved, then did nothing" trap the enforced-axes list exists to prevent.
### An over-declared stream level no longer demotes native Vulkan decode
A HEVC stream whose declared level exceeds what the device advertises is now treated as a **clamp**
rather than a refusal, so native Vulkan decode survives an encoder that over-declares. The Windows
client legs also build again: the edition-2024 `clients/session` binary could not compile on Windows,
and `pf-presenter` now spells `MAKEINTRESOURCE(1)` as `ptr::without_provenance` — clippy 1.96's
`manual_dangling_ptr` reads the integer-ordinal cast as a dangling pointer and fails the Windows
`-D warnings` gate, which was masked on main by the client bins failing to build first.
### Library, launcher marks and plugin-kit 0.4.1
- **Launcher tiles carry their launcher's mark.** A brand **token** goes on the wire (`steam`,
`heroic` — never bytes, never a URL) and each client draws the vector it already ships. `icon`
joins `GameEntry` and `CustomEntry` in the management API, and is hand-settable for the same
reason `role` is: an operator's own "Steam" tile should be able to look like one.
- **`@punktfunk/plugin-kit` 0.4.1 publishes the `icon` field.** The kit had shipped the field
without a version bump, so no plugin could name its mark.
- **Every pinned card gets a library, and it launches with that card's profile.**
### Decky: one library shortcut, not one per boot
🛑 A boot race minted a **new** Steam library shortcut on every plugin load, so the library
accumulated duplicates indefinitely. Fixed, and the plugin no longer toasts on every launch and every
failed panel refresh.
### A stats tier picked between streams now applies without a restart
The console latched the stats tier at stream start, so a tier chosen between two streams reached
nothing until the app was restarted.
### Miri, sanitizers, and the lint ratchets
- **Miri** now interprets the FFI-free leaf crates, one of them at **MSVC layout**. It immediately
earned its place: `pf-driver-proto`'s legacy-`AddRequest` test read a `[u8; 40]` (align 1) through
`bytemuck::from_bytes`, which takes a *reference into* the buffer and panics unless that buffer
happens to be 8-aligned — as a stack array usually is. Now `pod_read_unaligned`.
- **ASAN + LSAN over the C ABI boundary**: a `c-abi-asan` job in `audit.yml` runs the harness under
both, weekly and on demand, behind a `PF_SAN` sanitizer gate.
- **Two soundness fixes**: `InputKind` is validated before a `&InputEvent` is formed (above), and the
Windows `TOKEN_USER` buffer is properly aligned with `EqualSid` made to fail closed.
- **WP4**: `AvFrame`/`AvSwsContext` are RAII across all three libav backends in `pf-encode`.
- **The lint ratchets (WP2b + WP2c)**: crate-level gaps closed, the unsafe lints hoisted into the
workspace tables across all three workspaces, and three blocking unsafe-hygiene grep gates in
`ci.yml`. The two bindings-only `sys` crates are explicitly exempted from the hoisted deny.
### Dependencies, audit and licences
The 2026-08-13 dependency sweep, acted on in full:
- **Security:** `event-listener` 5.4.1 → 5.4.2 (RUSTSEC-2026-0221, unsound `Send`/`Sync`);
`spin` 0.9.8 → 0.9.9 (0.9.8 is **yanked** and was genuinely compiled); `wayland-scanner`
0.31.10 → 0.31.11, which moves `quick-xml` 0.39 → 0.41 and lets **both** RUSTSEC-2026-0194/0195
ignores be deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (`rsa` Marvin,
still unfixed upstream) remains.
-**Two CI gates that scanned nothing.** `cargo audit` only ever reads the **root** `Cargo.lock`,
so the drivers lock was in the job's `paths:` filter while being ignored; all four secondary
workspaces now get an explicit `--file`. And `packaging/windows/pf-vkhdr-layer` had **no lockfile
at all** while shipping as a DLL in the host installer, so neither cargo-audit nor cargo-about had
ever seen it — lockfile generated, committed, and added to `paths:`. `audit.toml` now also says out
loud that `cargo audit` reports unsoundness as a *warning* and the job fails only on
vulnerabilities, which is why the `event-listener` advisory sat unnoticed.
- **13 unused dependencies removed from `punktfunk-host`** (the Wayland stack, xkbcommon, reis,
khronos-egl, ash, usbip-sim, parking_lot, bytemuck) — the code moved to `pf-inject`/`pf-zerocopy`
in the subsystem extraction and those crates declare them; only the manifest entries and their
now-false comments stayed. Plus unused `bytes`, `anyhow`, `tracing`, `serde` in five other crates,
and the high-level `wdk` crate from all five driver crates.
- **Latent breakage fixed** — crates that compiled only through feature unification now declare what
they use: `pf-inject` (`tokio` `macros`), `pf-capture` (`tokio` `sync`), `pf-client-core` (two
windows-rs headers). `pf-console-ui` took `pf-client-core` **without** `default-features = false`,
unlike every other consumer; that default compiles the vendored PyroWave C++, which is fatal on
Windows ARM64 and only safe today because that leg passes `--no-default-features`.
- **Licences:** `ring`'s `OpenSSL` exception and its per-crate acceptance are retired now that ring
is gone. THIRD-PARTY-NOTICES regenerated — 601 → 580 → 582 crates across the sweep.
### The dependency currency wave — thirteen majors, and a silently-disabled AES path
The currency half the sweep above deferred, landed as one wave. Most of it is version hygiene, but
one item is a real defect and one changes a build flag you may be carrying.
🛑 **Hardware AES was silently off on every Android build.** `aes` 0.8 enabled the ARMv8 AES
instructions on aarch64 only behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH path
behind `--cfg polyval_armv8` — both set in `.cargo/config.toml`. A `RUSTFLAGS` environment variable
**overrides config rustflags entirely**, and `cargo-ndk` sets its own for every Android build, so
those two cfgs vanished and the per-packet decrypt path fell back to **software AES**. `aes` 0.9
runtime-detects through `cpufeatures` and `polyval` 0.7 selects its armv8 backend by `target_arch`,
so neither cfg exists any more and the flags are **deleted** from `.cargo/config.toml`. If you carry
a fork of that file, drop them: they are dead, and keeping them costs nothing but confusion.
- **The RustCrypto family moves as ONE change** — `aes` 0.9, `aes-gcm` 0.11, `sha2` 0.11, `hmac` 0.13,
`cbc` 0.2, `chacha20poly1305` 0.11. They share the `crypto-common`/`digest` traits, so a partial
bump strands crates on trait generations that cannot interoperate. The API generation forces
`AeadInPlace``AeadInOut` (`{encrypt,decrypt}_inout_detached` over `InOutBuf`), `generic-array`
`hybrid-array`, `Mac::new_from_slice``KeyInit::new_from_slice`, and the `BlockCipher*`/
`BlockMode*` renames. ⚠ **The GameStream wire formats are untouched** — AES-128-ECB no-padding, the
CBC audio path and the GCM control-stream seal all keep their exact byte behaviour; only type
plumbing moved.
-**`rsa` 0.9 cannot come along**: it is built on `digest` 0.10, whose 0.11 line is release-candidate
only — not something the Moonlight pairing ceremony should ride. The three sites where a digest is
an `rsa` *type parameter* now name `rsa::sha2::Sha256` explicitly; everything else is on sha2 0.11.
- **`skia-safe` 0.87 → 0.99** in `pf-console-ui` — twelve releases carrying Skia milestones 140150.
Only three reach us: m143 **deleted `SkPath`'s mutating API** (geometry is built through
`PathBuilder` and frozen with `snapshot()`/`detach()`; 34 errors over eight call sites), 0.93
deprecated `gradient_shader` for `gradient` (a warning, but the gate runs `-D warnings`), and the
Vulkan surface path came through untouched.
- **`wasapi` 0.23 → 0.24.** ⭐ 0.24 fixes upstream the dangling-`PCWSTR` bug this tree routes around
in five places — `DeviceEnumerator::get_device` built its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end of that statement
so `GetDevice` read freed memory. The five comments asserting that bug in the present tense are
corrected. ⚠ **The workarounds stay**`open_wasapi_device` is still the one resolution path whose
errors name the endpoint id, and `device_by_id` additionally filters to ACTIVE endpoints, which the
crate's `get_device` does not. Removing them would be a behaviour change, not currency.
- **Ten more**: `jni` 0.21 → 0.22 (the Android bridge), `rcgen` 0.13 → 0.14, `rand` 0.8 → 0.9 (the
host was the last crate on the old major), `base64` 0.22 → 0.23, `x509-parser` 0.16 → 0.18 — which
takes `thiserror` 1.0 out of the host graph entirely — `libloading` 0.8 → 0.9 across the five crates
that `dlopen`, `mdns-sd` 0.20 → 0.21 with `if-addrs` 0.13 → 0.15 (together, they share types),
`x11rb` 0.13 → 0.14, `xkbcommon` 0.8 → 0.9, `reis` 0.6.1 → 0.7.1, `windows-service` 0.7 → 0.8
(removing the last `windows-sys` 0.52 in the tree), `android_logger` 0.14 → 0.15, and `criterion`
0.5 → 0.8 (dev-only, benches).
- **New test coverage**: the TLS 1.2 Moonlight handshake, and the post-quantum group is pinned by a
test so a backend change cannot silently drop it.
- THIRD-PARTY-NOTICES regenerated across every client and the host for the wave.
### Documentation and the docs site
**`docs-site/public/openapi.json` had drifted far behind `api/openapi.json`** — it was stamped
`0.21.0` against the checked-in spec's `0.27.0`, and was missing five endpoints (`/library/hidden/{id}`, `/plugins/logs`, and all
three `/update/*` routes), so the published API reference described a host nobody was running. The
copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`) that nothing
in CI enforces, and it had simply been skipped. Re-synced for this release; the two files are now
byte-identical.
**It drifted again within the same release cycle** — the scanner-removal regen updated
`api/openapi.json` and not the docs-site copy, which is the failure mode repeating in miniature.
Re-synced a second time. **Until something gates it, treat `cp api/openapi.json
docs-site/public/openapi.json` as part of regenerating the spec, not a follow-up.**
### CI
- The C/C++ half of the build is cached and links with **mold**; the debug/release target caches no
longer collide.
- `release.yml` folds into `apple.yml`, and the two Windows-client workflows consolidate into one.
- The web console builds **once per push** instead of once per packaging job.
- The `smoke-install` job (see the Debian section) installs every published package from the registry
in pristine `ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the served
version is the one the run just built.
- ⚠ Gate C counted **comments**: a comment that named the env mutators verbatim satisfied the gate it
was documenting.
## v0.27.0
87 commits since v0.26.0.
Generated
+36 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.27.0"
version = "0.28.0"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ash",
@@ -3031,7 +3031,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ash",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3107,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"libc",
@@ -3119,7 +3119,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3133,11 +3133,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.27.0"
version = "0.28.0"
[[package]]
name = "pf-inject"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3166,14 +3166,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ash",
@@ -3188,7 +3188,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"serde",
"serde_json",
@@ -3196,7 +3196,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3208,7 +3208,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3217,7 +3217,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3250,7 +3250,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3261,7 +3261,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3272,7 +3272,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ash",
@@ -3484,7 +3484,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3494,7 +3494,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"android_logger",
"jni",
@@ -3512,7 +3512,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3529,7 +3529,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"pf-client-core",
"pf-console-ui",
@@ -3543,7 +3543,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3561,7 +3561,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3593,7 +3593,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3602,7 +3602,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3672,7 +3672,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3686,7 +3686,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"anyhow",
"ksni",
@@ -3709,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.27.0"
version = "0.28.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+12 -1
View File
@@ -165,7 +165,18 @@ pub use stats::Stats;
/// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both
/// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is
/// unchanged.
pub const ABI_VERSION: u32 = 18;
/// v19: added `punktfunk_connection_note_frame_index_ex` and
/// `punktfunk_reanchor_gate_arm_expecting_drops` — the width-carrying half of the reanchor gate.
/// `note_frame_index_ex` reports how MANY frames an arrival revealed as missing where
/// `punktfunk_connection_note_frame_index` reports only whether any were; passing that width to
/// `arm_expecting_drops` pre-credits the reassembler's `frames_dropped` climb that the same loss
/// produces up to ~120 ms later, so the gate does not read one loss as two and re-freeze a stream a
/// fast LTR-RFI anchor has already healed. NEW symbols, not widened ones — the same rule v18 states:
/// both originals keep their signatures and their behaviour, so an embedder that never adopts either
/// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
/// client-local: nothing new goes on the wire — the width is computed from frame indices the client
/// already receives — so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 19;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
Wire-compatible with 0.27.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This release is mostly about a host that behaves itself on the machine it lives on. A Windows host no longer keeps your computer awake around the clock, and no longer costs you frames in the game you are playing locally — both were real, both were reported from the field, and neither had anything to do with streaming. On Linux, the setting that was supposed to make streaming smoother turned out to be *causing* the stutter people kept reporting, and it is now off. Controllers get their speaker and touch feedback on Linux hosts for the first time, Wi-Fi machines can finally be woken remotely, and Debian is a supported system at last. There is one change you may need to act on: the Moonlight-compatible half of the host is now something you switch on rather than something that is always there.
## TL;DR
- **Your game library now comes from add-ons, not from the host itself** — and if you have none installed, your library will be empty after updating. One click per store puts it back. See *Before you update*.
- **If you connect with Moonlight, you must switch it on after updating.** The host now speaks only its own protocol unless you tell it otherwise. Punktfunk's own clients are unaffected. See *Before you update*.
- **A Windows host misbehaved on the machine it was installed on**, whether or not anyone was streaming: it stopped the computer from ever going to sleep, and it could wreck the frame rate of a game you were playing on that same machine — badly enough that one report cured it by uninstalling. Both fixed.
- **Two long-standing Linux streaming faults are gone.** The "make it smoother" setting was in fact *causing* the stutter people kept reporting — both places we asked the graphics card for priority are now off by default, nothing to switch on — and streams no longer tear with pink, blocky corruption at high frame rates on gamescope machines.
- **Linux hosts gain two things:** your controller's speaker and touch feedback work on them for the first time, not just on Windows ones, and **Debian 13 is now supported** with the patched compositor finally installable via `apt` — it had never once been published, on any release.
- **Your host's identity is being modernised, and there is a date on it.** Existing setups keep working exactly as they are; from **1 October 2026** a host still using the old identity will ask its clients to pair once more. You can switch early in one go — see *Before you update*.
## Before you update
- **Your library comes from add-ons now — check yours after updating.** Punktfunk used to look for your Steam, Epic, GOG, Heroic, Lutris and Xbox games itself. That job has moved to add-ons you install per store, which has been the supported way since the version before last. The built-in half is now gone, so **if you have no library add-ons installed, your library will be empty**. Putting it back is one click per store on the Library page — nothing else changes: your games keep the same entries, the same artwork, the same pins, and any store you had switched off stays switched off. Nothing to migrate and nothing to re-pair.
- **Your host has a new identity for Punktfunk's own connections — and you can switch to it today if you want to.** Until now one key proved your host's identity to everything, inherited from the Moonlight-compatible half and older than the security work around it. Punktfunk's own connections now get their own, freshly generated, on a modern elliptic-curve key. **A brand-new install uses it immediately.** An existing install deliberately keeps the old one: your clients remember the exact key they trusted the day you paired, and swapping it underneath them would lock every one of them out at once. Nothing is weakened in the meantime, and nothing breaks. If you want the new identity now, unpair every Punktfunk client on that host, restart the host, and pair them again — that is the whole procedure, and the host prints the same instruction in its log. **From 1 October 2026 the host will stop falling back to the old identity**, so any host that has not migrated by then will ask its clients to pair once more. Moonlight-compatible connections are untouched by all of this and keep their own separate identity, which cannot change.
- **If you use Moonlight, or any Moonlight-compatible client, read this.** The host used to answer both its own clients and Moonlight's out of the box. It now answers only its own unless you ask for the other, because the Moonlight-compatible half predates Punktfunk's own security work and is the older, weaker way in. Nothing about your setup is lost — set `PUNKTFUNK_GAMESTREAM=1` in the host's configuration file and it works exactly as before. Punktfunk's own clients need no change at all. On NixOS the corresponding setting is now off by default; on a Steam Deck the installer takes a flag to opt in. Windows was already like this and does not change.
- **Bazzite, if Game Mode has been showing you the machine's own screen instead of giving you your own display:** that was our fault, in the configuration file we told you to copy. Copy the new one and the problem goes away. If you edited yours by hand, remove the line that forces the attach mode.
- **Linux Mint, or plain Ubuntu 24.04:** these cannot host. The desktop underneath them has no way to create the extra display Punktfunk needs, and Ubuntu 24.04 is too old to run the compositor that would work around it. The host will now tell you this plainly instead of suggesting a setting that cannot help. Mint 22.x is supported by its makers until December 2026, so this is not a short wait — a supported system is the only fix.
- **Debian or Ubuntu, if you were ever told to install `punktfunk-gamescope` and it was not there:** it genuinely was not. It had never been published for `apt` in any release, despite the notes and documentation saying otherwise. It is there now, for Debian 13 and Ubuntu 26.04. Ubuntu 24.04 cannot run it at all.
- **If you build Punktfunk from source**, you now need a working C compiler. Nothing else was added.
## New
- **Your controller's speaker and touch feedback work on Linux hosts.** The per-controller audio that drives a DualSense's speaker and its finer feedback existed only on Windows hosts; on a Linux host your controller quietly fell back to plain rumble and nothing said why. Linux hosts now present each controller to games as its own audio device, exactly as Windows ones do.
- **A machine on Wi-Fi can be woken remotely.** Waking a sleeping host over the network only ever worked on wired machines. On Wi-Fi the host asked the wrong part of the system whether waking was switched on, so it told you it was not even when it was, and then offered a command the wireless driver rejects. It now asks the right question, gives advice that works, and the client aims the wake-up at every network it can see rather than trusting the default route — which, with a VPN running, never reached the wireless network at all.
- **Debian 13 is a supported system for hosting**, tested on every release from now on. It had quietly worked for a long time while the documentation said it was unsupported.
- **Launcher tiles show the launcher's mark**, so a Steam title looks like one at a glance.
- **Every pinned card has a library of its own**, and launching from it uses that card's settings.
- **Experimental: hold a display steady while streaming.** On machines with an AMD graphics card, Punktfunk can hold the connection to your monitor open while your own screens are switched off for a stream. This targets a specific stutter that comes from a sleeping monitor being woken and asked to identify itself over and over. It is off by default, and the switch only appears if your machine has the hardware it applies to.
## Improved
- **Streaming stopped asking your graphics card for emergency priority** — and everything got smoother. Two places asked for it, and a long field investigation into a stutter nobody could explain convicted both. The stall it produced was regular, arriving every few seconds, and turned out to be the graphics scheduler reacting to a demand it could not honour — not anything Punktfunk was doing on a timer. Neither request is made now, which is also what every comparable product does. Nothing to switch on, and the frame pacing this was supposed to protect is unaffected.
- **The connection recovers from a lost frame without freezing.** After a dropped frame the picture would freeze and stay frozen far longer than it needed to, most visibly on machines with an AMD graphics card and only when streaming in H.265 — the same loss was being counted twice, so a picture that had already repaired itself was frozen again by the stale second count. Streams recover as soon as they are actually whole.
- **The Android console got a proper going-over** — the real desktop backdrop behind it, an interface that stops shifting under your cursor, a controller route for the statistics overlay, and tables that no longer drift out of step with the other clients without saying so.
- **"Update all" for your add-ons.** The plugins page could only update one at a time, one dialog each. There is now a single button beside the list, and a count on the tab so you can see there is something to update without going looking. They install one after another, because the host only accepts one at a time.
- **The microphone button has left the stream overlay on Android.** It sat in the corner of every stream that used your mic. Muting is unchanged — the Select + Y button chord still does it, and the on-screen hint for that chord is now the only reminder. A proper on-screen control set is being built, and mute will come back as part of it.
- **A statistics setting chosen between two streams now takes effect.** It used to be read once at the start of a stream, so a change made in between did nothing until the app was restarted.
- **Connections are protected against future code-breaking computers.** Every encrypted connection now offers a post-quantum key exchange first, falling back to the classical one for anything that does not understand it. Nothing to configure, and nothing about pairing or your existing setup changes.
- **A stream no longer refuses the fast decoder over a mislabelled quality setting.** Some encoders claim a higher level than they use; that claim is now trimmed rather than treated as a reason to fall back to slower decoding.
## Fixed
- **A Windows host stopped the computer from ever sleeping.** Installing it was enough — connected or not, streaming or not. The microphone it provides to games was left running around the clock writing silence, and Windows treats any running audio stream as a reason to keep the machine awake. It now stops when nothing is speaking and starts again the instant something is, fast enough that you will not hear the difference. Streams still keep the machine awake while they are running, as they should.
- **A Windows host could ruin the frame rate of a game running on the same machine.** One report measured a game's worst frames at 25 per second and cured it by uninstalling Punktfunk. Two separate faults: on a machine where the audio devices could not be created properly, the host retried the full creation every minute forever, and each attempt made every game on the machine rebuild its audio; and the first stream ever run permanently raised the host's own priority above everything else, because the settings meant to last for one stream were never put back. Both are fixed, and the priority now goes back down when the stream ends.
- **Streams tore with pink, blocky corruption at high frame rates** on gamescope machines. The picture was being handed back to the compositor to be drawn over while it was still being read for sending, so parts of two different frames arrived in one. Reported at 120 frames per second; ordinary desktop sessions were never affected, which is why it took so long to place.
- **Game Mode on Bazzite mirrored the machine's own screen** instead of giving the client its own display, and a game launched from a client's library could not get a session of its own. The configuration file we ship — and tell people to copy exactly — forced a setting that overrode all of that.
- **Games ran on a black screen in Nobara's Game Mode.** The fix that shipped last release was being undone by the system's own start-up script moments after it was applied, so sound and controls worked over a black picture while the log said the problem had been handled.
- **A crash-looping overlay could turn the whole stream black** while every health indicator stayed green, on Bazzite handhelds. An invisible window was winning the contest for what gets shown, and the stream faithfully sent that.
- **Streaming a desktop still did not work on NixOS with KDE.** Last release's fix depended on reading something the system refuses to reveal about a program holding a special permission — and on NixOS the desktop itself holds one. The host now falls back to a way of asking that works, and the same fix covers other systems where the compositor holds that permission.
- **One slow start could downgrade a host until it was restarted.** The first attempt to get a picture is deliberately given a short deadline, but when that deadline passed the host concluded the machine had *refused* whatever it had asked for — high dynamic range, or the fast capture path — and stopped asking for the rest of its life. A gamescope machine starting up cold routinely needs longer than that deadline, so one lost race at connect pinned every later session to the slower, dimmer path.
- **Steam gained a duplicate Punktfunk entry on every restart** on Steam Deck, accumulating without limit. The plugin also stopped announcing itself with a pop-up every single launch.
- **Removing a paired device now actually removes it.** It was not being saved, so the device reappeared at the next restart, and a device that was already connected kept streaming until it chose to leave. Removing a pairing now ends that device's live session too.
- **Connecting a second device could kill the desktop on a Linux host running GNOME.** Connecting one device, leaving, then connecting a different one at a different screen size crashed the desktop outright — and every stream afterwards failed with a black picture until someone signed back in at the machine. Two pieces of the host's own display handling could overlap the desktop's rebuild of its screen list; they are now made to wait for each other. This was never new — it behaved exactly the same on the previous release — but it is fixed now.
- **Streaming a 4K screen at 60 Hz on a KDE host gave you a 1080p picture.** The host asked for the full size, KDE built something smaller, and nothing checked — while the log confidently printed the size that had been *asked for*, which is why the first look at this went the wrong way. The real size is now read back and reported.
- **Audio on an iPad broke up where a Mac was fine.** The rule that decides when to give up on a stalled audio buffer was counting audio deliveries rather than time, and an iPad asks for much shorter ones — so the same setting gave an iPad about 20 ms of slack where a Mac got 44 ms, on the device with the burstiest connection. It is measured in time now. On a simulated connection with a 100 ms hiccup every 5 seconds, ten minutes went from 120 audible gaps to 2.
- **Android was unscrambling every stream the slow way.** Phones and Android TV boxes have dedicated hardware for the encryption Punktfunk uses, and the app was supposed to use it — but the setting that switched it on was being wiped out by the Android build tooling before the app was ever compiled, on every build we have ever shipped. Every packet was unscrambled in software instead, which costs processor time and battery for nothing. The app now detects the hardware at runtime, so there is no setting left to lose.
- **The published interface documentation described a version of the host nobody was running.** It had drifted several releases behind, so five newer parts of the host's interface were missing from it entirely.
## For developers
Protocol, ABI, driver and add-on detail — the version table, the new environment variables, the build-time changes and what did *not* move — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.0/CHANGELOG.md).
The short version: **nothing breaks on the wire.** The wire protocol stays at 2, so every pairing and every shipped client keeps working. The C ABI moves 18 → 19 by *addition* — two new entry points for the loss-recovery fix above — and both existing entry points keep their signatures and their behaviour, so an embedder that ignores the new symbols behaves exactly as it does today.
The new host identity is **ECDSA P-256**, not Ed25519 — deliberately: no mainstream browser accepts an Ed25519 server certificate, and an operator opens `/api/docs` in one. The Moonlight-compat plane keeps its RSA identity **permanently**, because Moonlight mandates RSA and its pairing hashes bind the certificate's signature bytes; only the native plane's legacy fallback is dated, and it is removed on **1 October 2026**.
The one genuinely breaking change is on the host: the six built-in library scanners are **deleted**, and the library is now assembled entirely by plugins. There is deliberately no migration — a plugin claims its store and re-publishes each title under the same identifier the scanner used, so entry ids, app ids, artwork caches, pins, per-source toggles and per-entry hides all keep working untouched. The one visible consequence is an empty grid on a host with no library plugins installed.
Three things change how you **build** rather than how anything runs: the tree moves to Rust edition 2024 with a declared minimum of 1.85, building from source now needs a C compiler, and the Moonlight-compatible half is a compile-time feature as well as a runtime switch — building without it produces a host with the entire legacy protocol stack, and its transpiled C networking code, absent from the binary.
If you package Punktfunk: the Moonlight default flip and the Bazzite configuration template are the two changes your users will notice. Neither needs a packaging change.
+4
View File
@@ -0,0 +1,4 @@
• Streams now use your device's built-in encryption hardware, instead of falling back to software as every previous build did.
• The mic button has left the stream overlay. Muting is unchanged — the Select + Y chord still does it.
• The console gets a proper going-over: the host's real desktop behind it, and an interface that stops shifting under your cursor.
• A dropped frame no longer leaves the picture frozen longer than it needs to.
+12 -1
View File
@@ -94,7 +94,18 @@
// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both
// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is
// unchanged.
#define PUNKTFUNK_ABI_VERSION 18
// v19: added `punktfunk_connection_note_frame_index_ex` and
// `punktfunk_reanchor_gate_arm_expecting_drops` — the width-carrying half of the reanchor gate.
// `note_frame_index_ex` reports how MANY frames an arrival revealed as missing where
// `punktfunk_connection_note_frame_index` reports only whether any were; passing that width to
// `arm_expecting_drops` pre-credits the reassembler's `frames_dropped` climb that the same loss
// produces up to ~120 ms later, so the gate does not read one loss as two and re-freeze a stream a
// fast LTR-RFI anchor has already healed. NEW symbols, not widened ones — the same rule v18 states:
// both originals keep their signatures and their behaviour, so an embedder that never adopts either
// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and
// client-local: nothing new goes on the wire — the width is computed from frame indices the client
// already receives — so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 19
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**