An all-fixes patch on top of 0.19.1, all host-side:
- KDE Plasma display takeover moved off kscreen-doctor to in-process
kde_output_management_v2 (86d5d666), with the >60 Hz custom-mode install
ported too (64511af4) — a KWin host whose kscreen-doctor wedges now disables
its physicals and hands over the streamed output in ~2.4 ms instead of hanging
~26 s and never taking over.
- The gamescope managed takeover is display-manager-flavor-aware (e35dad52) so
switching a non-SDDM Linux desktop (Nobara / plasmalogin) into Game Mode no
longer start-limits the display manager into a permanent black screen, and
in-stream 'Switch to Desktop' is honored under it (a3e21d92).
- Windows display isolation anchors the kept sources at the desktop origin
(9b241d9d) so an origin-less desktop no longer fails 0x57, with restore
guaranteed never to leave the desktop all-dark.
- Windows pen/touch/absolute-mouse map over the streamed output's rect rather
than the whole virtual desktop (087e7d04), so absolute input lands correctly
when a physical monitor is kept on beside the streamed display.
Nothing moved on the wire (protocol 2) or at the C ABI (13), so no embedder
rebuild and 0.18/0.19 peers keep mixing freely. The Windows virtual-display
driver protocol is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pen, touch, and absolute mouse arrive normalized to the STREAMED display's
frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped
them over the whole virtual desktop — correct only when the virtual display
is the sole active display (Exclusive topology). In Extend — a physical
monitor kept on beside the virtual output, or an Exclusive isolate degraded
to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero
origin, so every sample landed shifted and mis-scaled. The pen exposed it
first (field report 2026-07-24): a stylus is strictly absolute, with no
closed-loop correction onto the target like a cursor.
New pf-inject::stream_target (Windows): the host publishes the streamed
output's CCD target id at capture bring-up (one central site —
capture_virtual_output covers the native and GameStream planes); mapping
sites resolve its current desktop rect through pf-win-display's
source_desktop_rect — the same resolver the cursor-readback poller uses, so
inject and readback always agree — TTL-cached (250 ms) because a
group-layout re-arrange moves a live output's origin mid-session. No target
set / never resolved falls back to the whole virtual desktop (the historical
mapping, still right for Exclusive topology and devtest).
One change in to_screen covers pen + touch; MouseMoveAbs converts the same
desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing
the identical latent absolute-mouse bug (apollo-comparison open item #14/#30).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the previous commit: the only KWin topology op still shelling out to
kscreen-doctor was the >60 Hz custom-mode INSTALL (`set_custom_refresh`). Port it
to `kde_output_management_v2` so the whole KWin path is kscreen-free on modern KWin.
`kwin_output_mgmt::set_custom_mode` builds a one-entry `kde_mode_list_v2`
(set_resolution / set_refresh_rate / set_reduced_blanking=full / add_mode), applies
it via `kde_output_configuration_v2.set_custom_modes` (since 18), waits for KWin to
generate the mode (its CVT generator may align the width down — matched with the
same height-exact / width-within-8 / refresh-within-1Hz gate as before), then
selects it — which changes the output size and drives the sacrificial-birth stream
renegotiation. `kwin::create`'s want_high branch tries this first and only resolves
a kscreen id + calls `set_custom_refresh` if it returns None (pre-6.6 KWin without
set_custom_modes, or the compositor not answering) — so a >60 Hz session on a box
where kscreen-doctor wedges no longer eats a 5 s resolve timeout either.
Bonus: `set_custom_modes` REPLACES the custom list, so reconnects are idempotent —
no more one-custom-mode-per-connect growth of the user's display list.
Verified: cargo test -p pf-vdisplay (73 pass), clippy -D warnings clean, fmt clean.
The set_custom_modes round-trip is proven live on KWin 6.6.4: apply=applied, and a
1648x928@75 request generates 1648x928@74.901 (CVT fractional refresh, caught by the
1 Hz tolerance). The mode SELECT + full >60 Hz stream is still owed on-glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A KDE host on Nobara stopped disabling its physical screens and creating the
virtual output the moment it updated: streaming still came up, but bring-up took
~26 s and the streamed output never became the desktop (`also_disabled=[]`).
Root cause is not our topology logic — it's that every `kscreen-doctor` call on
the reporter's session wedges. kscreen-doctor drives libkscreen, which (per
setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it
blocks in its own connect and never returns, so all five topology queries hit
their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin
6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time.
But the compositor's OWN Wayland is fully responsive on that same session — the
host just created a virtual output over it via zkde_screencast. So drive the
topology (resolve our output, take primary, disable the physical/bootstrap
outputs, capture their modes, re-enable on teardown, position) directly over
`kde_output_management_v2` + `kde_output_device_v2` instead of shelling out.
On that same Nobara box the in-process path binds management (v19) and
enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune
to whatever wedges the standalone tool.
- vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt
v19, device v20); generate client bindings inline (the interdependent-protocol
module split from the wayland-protocols crate; needs the `bitflags` dep for the
device protocol's bitfield enums).
- new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland
connection; every wait is time-bounded so a genuinely wedged compositor
degrades to `handled = false` and the old kscreen-doctor path still runs.
- `kwin::create` topology + `apply_position` prefer the in-process path (address
our output by its stable device UUID, supersede-robust) with kscreen-doctor as
the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the
>60 Hz custom-mode install still shells out (its in-process port is a follow-up).
Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets
-D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration
proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is
not yet on-glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First on-glass test of the DM-flavor takeover confirmed the black-screen
fix, but exposed that the managed model could not switch back: Steam's
in-stream session-select is a silent no-op while the display manager is
stopped (every config-write branch in os-session-select requires the DM
to be running), so the capture loss it causes just relaunched game mode.
The user pass of the switch script does leave one durable trace — the
~/.config/steamos-session-select sentinel, written before any DM check.
The managed launch now baselines its mtime; a capture loss with the
sentinel advanced reads as the switch request and is honored by
replaying the switch with the DM up: restore the display manager, run
the distro's own os-session-select (its internal pkexec is authorized
allow_any by the distro policy, so it works from the host's sessionless
context), then stop the autologin game-mode unit so Relogin lands in
the newly selected desktop. The capture-loss re-detection then follows
KWin as it comes up; a 120 s post-honor grace stops the rebuild loop
from racing the booting desktop back into game mode (superseded early
if the box's own game-mode unit reappears — the desktop→game leg stays
fast). The baseline is re-recorded on crash-restore so a pre-existing
sentinel never reads as a fresh request. Every verb in the sequence was
live-validated on the Nobara repro VM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (Nobara 43 HTPC): switching the host to Steam Game Mode
mid-stream permanently black-screened the box. Live-proven root cause on
a Nobara repro VM: our takeover masks the box's gamescope-session-plus
unit, plasmalogin's Relogin=true then fails its session Exec repeatedly
and trips systemd's start limit within ~1 s — the display manager dies,
and our restore verb (unmask + user start) cannot bring a seatless
gamescope back. Only 'reset-failed + restart' of the DM recovers.
The takeover is now display-manager-flavor-aware:
- SDDM (Bazzite/SteamOS) keeps the proven mask+SIGKILL path unchanged.
- plasmalogin/unknown DMs never mask: with privilege (root or an
operator polkit rule scoped to the DM unit — documented) the host
stops the DM for the stream and restores it with reset-failed +
restart (PUNKTFUNK_RECOVER_SESSION_CMD as fallback), recorded in the
persisted takeover state so a host crash still restores; without
privilege the managed takeover degrades to ATTACH and mirrors the
box's live Game Mode instead of destabilizing the seat. Both legs of
the privileged cycle live-verified on the repro VM (headless managed
session works with zero login sessions; render nodes are 0666).
A loaded-but-inactive leftover instance never triggers the DM stop.
Companion fixes from the same triage:
- ensure_box_gamescope_mode gains the attach-only rebuild-probe guard
both managed paths already had (stale post-capture-loss detection
restarted the box's unit), and no longer re-modes a box that drives a
physical display — attach mirrors on-glass; re-mode is the
headless-box model.
- Capture-loss rebuilds targeting gamescope get a 100 s budget: the
40 s budget expired inside the first 45 s Steam-cold-start launch
attempt, a guaranteed single-shot failure.
- A PUNKTFUNK_COMPOSITOR pin now WARNs once per capture loss when the
live session no longer matches it (the pin disables
session-following — the reporter's original stream-death trigger).
- A managed session that took nothing over (client gamescope pin beside
a live desktop) is stopped on disconnect instead of being orphaned
forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 3 of the field-reported exclusive-topology 0x57, with the reporter's
retest logs finally separating the variable: the SAME isolate call converged
rc=0 whenever a kept member already sat at (0,0), and failed 0x57 on every
shape — doomed-path-carried AND keep-only escalation — whenever the doomed
physical held the origin. A committable config must still contain a primary
(a source pinned exactly at (0,0)); deactivating the display that held it
while the kept virtual stays at its EXTEND offset supplies an origin-less
desktop, which Windows rejects wholesale. Translate the kept sources rigidly
so the top-left-most lands on the origin; sets already covering (0,0) are
untouched so plain re-commits stay byte-identical.
Also from the same logs:
- restore_displays_ccd now guarantees the desk is never left all-dark: the
saved snapshot can be unappliable (0x64a — it pinned a virtual target
incarnation removed before teardown) or apply cleanly yet re-light nothing
(snapshotted while an earlier failed teardown had the physicals off). If no
external physical is active after the apply while one is connected, fall
back to the database EXTEND preset. Internal panels don't count — a closed
lid must not be forced back on.
- the final isolate failure now names the surviving targets instead of
asserting 'a non-virtual display stayed active' — the logs showed a sibling
VIRTUAL display as the survivor (linger-expiry shrink), and the wording sent
triage the wrong way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An all-fixes patch on top of 0.19.0. The headline is session teardown — the
native stop flag made enforceable and bounded (ac3dc432) and GameStream ending
on the control peer's ENet disconnect (41fa25c4), so a session can no longer
outlive the client that started it on either plane.
Also: the Windows input-desktop pair (display writes b5fa878b + pen/touch
injection 24a24734 both surviving a UAC prompt), the Apple display-sleep guard
(bc5f6a38), the gamescope corner-parked cursor (4b2d2d1e), the KWin custom-mode
selection (3a33a694), the tvOS glob warnings (cc8cccbe), and the Linux
starter/env docs revamp (66172753).
Nothing moved on the wire (protocol 2) or at the C ABI (13), so no embedder
rebuild and 0.18/0.19 peers keep mixing freely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`punktfunk-core` emitted `unused import: super::super::*` for
client/pump/handshake.rs and client/pump/input_task.rs — but only when
built for a tvOS target, where the cfg set leaves the glob contributing
nothing. Nobody saw them because CI never builds tvOS: that needs
BUILD_TVOS=1 and the Tier-3 build-std nightly path, and the Apple
workflow only runs swift build/test (macOS).
Both files also `use super::*`, i.e. the parent `pump` module, which
itself globs `super::*` (`client`) unconditionally — so every name stays
reachable by that path and the inner glob was pure duplication. Their
sibling datagram_task.rs already imports only `use super::*`, so this
also makes the module consistent. control_task.rs and data.rs keep
theirs: those genuinely resolve names through it and never warned.
Verified warning-free AND compiling on every slice the xcframework
ships — aarch64/x86_64-apple-darwin, aarch64-apple-ios,
aarch64-apple-ios-sim, aarch64-apple-tvos, aarch64-apple-tvos-sim,
x86_64-apple-tvos — plus Linux with and without the `quic` feature. A
full xcframework rebuild now reports 0 warnings (was 6: two per tvOS
slice), and swift build, swift test (170 tests), and xcodebuild for the
macOS, iOS and tvOS schemes all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native sessions could survive long after the client was gone, in two
independent ways.
HOST: `stop` was only advisory. The one thing that ends a session is the
stream thread returning — every teardown (conn.close, the joins, and the
RAII drops of the session permit, admission entry and stream marker)
sits after that await, and nothing forced the issue. The encode loop
checks `stop` between iterations, so any unbounded call INSIDE one never
reaches the check, and one stuck syscall became a permanent zombie: it
held its semaphore slot (four of those and the host stops accepting
QUIC entirely), its admission entry (a later client gets "host busy"
forever), and not even the console's Stop button could clear it — that
button sets this same flag.
* Bound the wait: once the session has been told to stop, the thread
gets STREAM_STOP_GRACE (90 s, well past the 40 s capture-rebuild
budget) to return, then teardown runs anyway. The thread is detached,
not killed — Rust can't cancel a blocking thread — so it keeps its
capturer/encoder until the stuck call returns, but the session's slot
and admission entry come back and the host keeps serving. It logs at
ERROR as the host wedge it is.
* Bound the audio/input joins too — the last unbounded await in
teardown.
* Take the session permit AFTER the QUIC handshake instead of before
`accept()`, so a host at its concurrency cap still accepts and the
waiting client sees a live path instead of a silent dial timeout.
* Bound the compositor helpers that caused the wedge in the first
place: new pf-vdisplay `proc::{status_within, output_within}` kill a
child that outlives its budget. `kscreen-doctor` is a Wayland client
of the very compositor it configures, so against a wedged KWin it
never returned; same for systemctl/dbus against a stuck session bus.
CLIENTS: the connection was never closed, so the host was right to keep
the session — it still had a live, keep-alive-answering peer.
* Android: backgrounding did no teardown at all, and Android doesn't
suspend the process, so the worker kept answering keep-alives until
the OS reclaimed it (on a TV box, never). End the session on ON_STOP,
via the existing onDispose path; a plain close, not a quit, so the
host lingers the display for a fast return.
* Apple: the .background arm was iOS-only AND gated on an opt-in that
defaults off, so backgrounding did nothing — while the `audio`
background mode kept the app (and its connection) alive indefinitely.
Act unconditionally, and cover tvOS.
* Core: `conn.close()` only queues the frame, and run_pump is the body
of a block_on whose runtime is dropped the instant it returns, so the
driver could never put it on the wire — a deliberate quit reached the
host as silence (8 s idle timeout, no quit code, and the linger meant
for an unwanted disconnect). Carry the endpoint out of the handshake
and flush with wait_idle(), the same discipline the pairing and probe
paths already use.
Linux check/clippy/tests green: 262 host, 71 pf-vdisplay (incl. new
bounded-process tests), 231 core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameStream sessions outlived their client: the only complete teardowns
were the explicit ones (RTSP TEARDOWN, nvhttp /cancel, mgmt DELETE
/session), and the only automatic detector was a media-UDP send error —
which needs an ICMP port-unreachable, so a true vanish (Wi-Fi drop,
sleep, power off, crash) left video+audio encoding into the void
forever, and even a plain Moonlight quit (which sends neither TEARDOWN
nor /cancel) leaked the session. The stale state then cascaded: a
lingering launch 503-blocked a different client under
mode_conflict=reject, and streaming=true made a reconnect's PLAY take
its "stream already running" branch — no new threads, old threads still
aimed at the dead endpoint, the reconnect got no media.
ENet already detects all of this — the control peer's reliable-ping
timeout (or clean disconnect) fires Event::Disconnect within ~5-30 s —
but the handler only reset input state. Wire the real teardown into it:
* AppState::end_session — THE compat-plane session teardown: stops both
media threads (their flags), clears launch + negotiated stream config;
idempotent. /cancel and mgmt stop_session now share it.
* control.rs Disconnect → end_session. Gated on the TRACKED session
peer, and Connect only tracks a peer from the /launch owner's IP (the
same source-IP bind the RTSP/media plane uses), so an unauthenticated
LAN peer connect+disconnect can't end a live session, and a fast
reconnect's stale-peer timeout can't kill its successor.
* Client-unreachable UDP send errors now end the whole session via an
OnSessionLost callback (built at PLAY) instead of stopping only the
plane that noticed — audio no longer keeps streaming after video
detects the dead client, and vice versa.
Linux check/clippy/tests green (53 gamestream tests incl. the new
end_session regression test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A KDE host streaming to a phone-shaped client fell back to 60 Hz even though the
120 Hz mode was sitting right there in KDE's display list, one manual click away.
KWin generates every custom mode's timing with libxcvt, whose first step is
`hdisplay_rnd = hdisplay - (hdisplay % 8)`. A 2868x1320@120 request (iPhone 16
Pro Max) therefore becomes 2864x1320@119.92 — 4 px narrower, fractional refresh.
We then asked kscreen-doctor for `mode.2868x1320@120`; its `findMode` matches a
mode's id or its own `WxH@qRound(Hz)` name, so that string matched nothing, the
select silently no-op'd, the output stayed on its sacrificial birth mode, and
`size_applied` came back false → "KWin rejected the custom mode" → 60 Hz.
Widths like 1920/2560/3840 are all multiples of 8, which is why only clients with
phone-shaped panels ever hit this.
Resolve the mode out of the output's OWN list instead and address it by kscreen
mode id: exact height, width at most one cell narrower than asked, refresh within
1 Hz (which excludes the native 60 Hz entry). `set_custom_refresh` now returns the
whole achieved mode, and `create` reports that as the output's `preferred_mode` —
so the capturer's renegotiation gate waits for the size KWin will actually deliver
and the encoder opens against it, rather than starving on the requested one.
Also skip `addCustomMode` when a usable mode is already installed: kscreen-doctor
APPENDS to the list and KWin persists it per output name, so the old code grew the
user's display list by one entry per connect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same field report as the display-write fix, other half of the symptom: with a
UAC consent prompt up — one the user could SEE in the stream, because capture
already renders the secure desktop (326d6e17) — pen and touch did nothing,
while mouse and keyboard kept working.
The split was exactly which paths knew the input desktop can move. sendinput.rs
reattaches, so mouse/keyboard reached the prompt. pointer_windows.rs had no
desktop handling at all, so every InjectSyntheticPointerInput came back
ERROR_ACCESS_DENIED:
22:20:31 virtual pen created (PT_PEN)
22:20:31 pen inject failed error=Zugriff verweigert (0x80070005)
22:20:42 touch inject failed error=Zugriff verweigert (0x80070005) contacts=1
Measured on glass before fixing, to find out what it actually takes:
device on Default, thread on Default -> 0x80070005 (the field failure)
device on Default, thread on INPUT -> OK
device on INPUT, thread on INPUT -> OK
The middle row is load-bearing: the synthetic pointer device is NOT
desktop-affine, so rebinding the thread suffices and the device is never
destroyed and recreated across a desktop switch — which would have dropped
in-flight contacts and the pen's in-range state mid-stroke.
Injection now retries once bound to the input desktop. The binding is scoped,
not persistent like sendinput's: inject_pen/inject_touch_frame run on TWO
threads (the caller's apply_batch and the refresh threads), and the batch caller
is a shared task thread that must not be left parked on a Winlogon desktop that
disappears when the prompt is dismissed.
The first-failure WARN now carries the rejected sample (flags, pen flags/mask,
pressure, rotation, tilt, position). A 0x80070057 INVALID_PARAMETER was seen
once BEFORE any prompt existed and is still unexplained; the ranges all look
sound on inspection (coordinates clamped to the virtual screen, roll/azimuth
u16 so the modulo cannot go negative, tilt bounded 0..=90), so catching the
actual offending sample is the way to find it.
Verified on glass: with a consent prompt up, pen and touch now reach it — zero
inject failures where every prior session failed immediately. A UAC prompt can
now be dismissed from an iPad with the Pencil.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field-reported 2026-07-23: a consent prompt left up on an unattended host made
the box unreachable. Every connect black-screened and failed after ~60 s, and
the host had to be reached over SSH to clear the prompt.
Windows refuses ChangeDisplaySettingsEx/SetDisplayConfig issued from a thread
that is not on the desktop currently receiving input. While UAC (or the lock /
logon screen) is up that desktop is Winlogon, and the host — which the service
launches explicitly onto WinSta0\Default — got DISP_CHANGE_FAILED /
ERROR_ACCESS_DENIED for every write. A session starting in that window could
never set its virtual display's mode, so the capturer sized its ring to a stale
mode, every composed frame was dropped for a size mismatch, and bring-up burned
8 retries before giving up.
Measured on glass (RTX box, SYSTEM host in console session 2, real consent
prompt up, virtual display active):
INPUT desktop = Winlogon
UNBOUND CDS_TEST -> -1 (DISP_CHANGE_FAILED)
UNBOUND SDC_VALIDATE -> 0x5 (ERROR_ACCESS_DENIED)
BOUND CDS_TEST -> 0 (DISP_CHANGE_SUCCESSFUL)
BOUND SDC_VALIDATE -> 0x0 (ERROR_SUCCESS)
New input_desktop.rs mirrors pf-inject's sendinput.rs retry model: issue the
write, and only when it fails the way a wrong-desktop write fails, rebind this
thread to the current input desktop and retry once. A working write is never
touched, so the normal path is unchanged. The retry predicate stays narrow
(ERROR_ACCESS_DENIED only) so the unrelated 0x57 exclusive-mode topology bug is
not re-issued and mis-attributed. Unlike sendinput's dedicated injector thread,
the binding here is SCOPED — a shared display-write thread left on a Winlogon
desktop that is later destroyed would fail every subsequent write, which is the
wedge this removes.
Applied to all eight SetDisplayConfig sites and both ChangeDisplaySettingsExW
calls in set_active_mode. The isolate site now decides its supplied config once,
outside the write, so a retry re-issues the identical config rather than logging
its escalation twice.
A save is logged, because a silent one is indistinguishable in a field log from
a write that never needed saving — which made the first on-glass verification of
this change inconclusive.
Both ACCESS_DENIED diagnostics now ask which cause applies instead of naming
only the disconnected-RDP one: that phrasing sent this investigation chasing a
phantom RDP session on a host that was already service-launched in the console
session, while a consent prompt was the actual cause.
Verified on glass: connect with a consent prompt up now reaches first frame in
3.0 s (was a black screen then "Connection failed" after ~60 s), with six
"retried bound to it and it applied desktop=Winlogon" lines and rc=0x0
throughout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gamescope hides its pointer by WARPING the X pointer to the root's bottom-right corner pixel;
it does not swap in a transparent X cursor, so XFixesGetCursorImage keeps handing back the last
opaque arrow. The XFixes source picked its display by "whichever pointer moved", and a parked
pointer never moves again — so it froze on the parked Xwayland and composited that arrow at
(w-1, h-1): a sliver of cursor welded to the corner of the stream for the rest of the session
while the real pointer went undrawn. Reported on-glass in every game (a game grabs the pointer,
so the hide is permanent): "part of cursor shows up on bottom right ... isn't where it really is".
Follow GAMESCOPE_CURSOR_VISIBLE_FEEDBACK instead — gamescope publishes it on every nested
Xwayland root: 1 on the server whose pointer it is drawing, 0 on the others and 0 on all of them
once the pointer is hidden. It answers both questions correctly for a STATIC pointer, which
motion cannot: which Xwayland owns it, and whether to draw it at all. Read at connect, re-read
on its root PropertyNotify (event drain now discriminates CursorNotify vs PropertyNotify) with a
250 ms resync as the self-heal. A pointer gamescope draws nowhere is published visible:false,
not dropped — the encode loop overwrites the frame's overlay from this slot and strips invisible
ones, so a None would leave the last visible overlay standing on repeat frames. Honouring the
atom also gives the stream gamescope's own idle auto-hide, which this source never had.
The pointer-motion heuristic stays as the fallback for a gamescope that publishes no verdict
(logged at session start via cursor_feedback=false), so nothing regresses to a cursorless stream.
Measured on a live 1920x1080 Steam Gaming Mode session (RTX 5070 Ti, gamescope c31743d+,
--xwayland-count 2 --hide-cursor-delay 3000): idle 3 s => pointer (1919,1079) + feedback 0;
real evdev motion => pointer live + feedback 1. End-to-end against that session the source now
publishes visible=false while parked and tracks the live pointer within one 250 ms sample of
motion. cargo clippy -p pf-capture --lib -D warnings and the 5 new pick_active tests are green
at cargo 1.96.1 (CI parity); cargo fmt --all --check clean.
A stream is not user activity to the OS, and controller input does not feed
the HID idle timer on any Apple platform — so a gamepad-only session reliably
idles the panel out from under the user mid-play. A keyboard/mouse capture
session masks the bug because those events are real local HID. The Android
client has held FLAG_KEEP_SCREEN_ON while streaming all along; the Apple
clients held nothing.
DisplaySleepGuard is acquired in beginStreaming and released at the top of
disconnect, so it is scoped to the session — every teardown path (user quit,
sessionEnded, the backgrounded keep-alive timeout) funnels through disconnect.
iOS/iPadOS/tvOS: UIApplication.isIdleTimerDisabled. App-wide and ignored
while backgrounded, which is what the audio-only keep-alive wants.
macOS: ProcessInfo.beginActivity(.userInitiated, .idleDisplaySleepDisabled),
the Foundation wrapper over IOKit power assertions. That defers display
sleep but NOT the screen saver, which runs off the HID idle timer
independently — so a 30 s IOPMAssertionDeclareUserActivity heartbeat runs
alongside it. Intended side effect: an idle-lock that follows the screen
saver is deferred for the session too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field triage (Nobara, Discord): the kde.md starter host.env told desktop
users to set PUNKTFUNK_COMPOSITOR=kwin, which PINS the backend — detect()
short-circuits and the capture-loss rebuild never re-detects — so a
mid-stream switch to Game Mode killed the stream instead of following it.
A follow-up hardcoded XDG_RUNTIME_DIR=/run/user/1000 anchor broke PipeWire
for any non-1000 uid (pw audio connect: Creation failed).
Revamp across every starter/example/reference:
- Desktop starters (kde/gnome/hyprland/sway) shrink to
PUNKTFUNK_VIDEO_SOURCE=virtual + an explicit warning that pinning
disables session-following; forcing a backend is CI/appliance-only.
- host.env.example: rewritten around auto-detection; anchors demoted to a
commented ssh/cron-only block with the uid trap spelled out; the
gamescope ATTACH/MANAGED knobs documented (previously missing);
case-sensitivity called out.
- packaging/bazzite/host.env + README: drop the uid-1000 anchors (a
systemctl --user service inherits/derives them); README's stale
PUNKTFUNK_COMPOSITOR=gamescope-era template synced to the real one.
- packaging/kde/host.env: loud APPLIANCE-ONLY header (it pins on purpose).
- configuration.md: session-anchors section inverted to "leave unset",
compositor row states the pin consequence, case-sensitivity note.
- troubleshooting.md: new "session fails right after editing host.env"
section (case, wrong-uid anchors, stale pin, restart-to-apply).
- gamescope.md/bazzite.md: attach/managed descriptions match current
behavior (managed is the infra-detected default; attach re-modes a
box-owned session to the client's resolution).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release notes were written for the people who build Punktfunk, not the people who use it —
dense with protocol/ABI/type names that confused even technical readers. Rewrite v0.19.0 in a
benefit-first, plain-language voice (New/Improved/Fixed) with all internal terms removed from the
body and every protocol/ABI/embedder detail moved to a single bottom "Under the hood" section.
docs/releases/README.md now codifies this voice as the format spec for all future notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v0.19.0 deb + decky release-attach steps source gitea-release.sh under `sh` (dash) and
died with "Bad substitution" at _release_notes_path's `${BASH_SOURCE[0]:-$0}` — a bash array
subscript dash rejects. So those legs never attached their assets to the release (the bash
legs — apple, android, rpm — were fine, which is why the body still seeded and the DMG/.ipa
attached). CI always sources this from the repo root, so resolve the notes as
docs/releases/<tag>.md relative to CWD and drop $BASH_SOURCE entirely. Verified under dash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headline: the whole pen/tablet input stack (P0–P5) — a pressure-sensitive stylus plane
across core wire, Linux, GameStream/Moonlight, Windows, iPad Pencil and Android. Plus
touch-injection fixes, cursor-DPI scaling, three Windows-host display/cursor fixes, a
gamepad same-PID conflict fix, and the Android mouse/keyboard (TV) regressions.
Release notes authored in-repo at docs/releases/v0.19.0.md (first release on the new flow;
CI seeds the release body from it). cargo update --workspace syncs the lock (versions only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pen P0 work added a new embeddable C entry point (punktfunk_connection_send_pen)
but left ABI_VERSION at 12 — the value 0.18.0 shipped. Per the project's convention
(0.18.0 bumped to 12 for the additive cursor channel), additive C-surface growth bumps
the ABI so embedders can gate on the new symbol. Regenerated include/punktfunk_core.h
via cbindgen (cargo build -p punktfunk-core); WIRE_VERSION is unchanged (pen is
capability-gated on HOST_CAP_PEN).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The iOS leg archives with development signing and re-signs for App Store distribution at
export time, but the existing export uses destination=upload (straight to TestFlight) and
leaves no .ipa on disk. Add an export step that re-exports the SAME archive with
destination=export to produce an App Store distribution-signed .ipa, then:
* attach it as the `punktfunk-ios-ipa` run artifact on every build (upload-artifact@v3,
since Gitea's backend rejects @v4), and
* attach it to the unified Gitea release on vX.Y.Z tags (alongside the DMG), via the
shared ensure_release/upsert_asset helpers.
Same gate as the archive; warn+skip (never fails the best-effort iOS leg) if the archive is
absent. Note: an App Store-signed .ipa installs only via TestFlight/App Store, not by direct
sideload — it is a release/archival artifact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hex dumps from the on-glass session show textbook SS_TOUCH packets with
pressureOrDistance = 0x7fc00000 (NaN): VoidLink encodes 'unknown finger
pressure' as NaN (iPad fingers have no force sensor), and the anti-forgery
finiteness gate rejected the entire packet — silently disabling the touch
plane while pen (real Pencil force, always finite) worked. Coordinates keep
the strict gate (they feed injector scaling); pressureOrDistance now
sanitizes non-finite to 0.0, the spec's own 'unknown', which the pen path
already maps to full-scale ink on contact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An on-glass 'touch does nothing' currently leaves NO trace: log the first
SS_TOUCH per session at info (proves the client sends + we decode), and warn
when a packet carrying a pointer magic fails the body parse (layout mismatch)
instead of vanishing into the unknown-magic drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two Android TV M&K regressions from the c90343c2 overhaul, both user-reported:
1. Mouse back/forward buttons doubled as Android navigation while streaming.
The BUTTON_BACK/FORWARD motion edges already go over the wire as X1/X2, but
Android's input reader ALSO synthesizes KEYCODE_BACK/FORWARD key events from
those buttons unconditionally (stamped SOURCE_MOUSE, no FLAG_FALLBACK) — and
the guard only swallowed the FLAG_FALLBACK variant, so the duplicate reached
the BackHandler and yanked the user out of the stream. Swallow mouse-sourced
BACK/FORWARD too; a remote/keyboard BACK is never mouse-sourced and still
exits.
2. The soft keyboard popped up on physical-keyboard typing. KeyCaptureView (the
always-focused IME/capture anchor) unconditionally claimed to be a text
editor with a live editable InputConnection, so the TV IME counted input as
active and showed its UI the moment hardware keys arrived. Gate the editor
identity on imeShown (the user actually summoning the keyboard via gesture /
remote toggle), with restartInput on both edges so the gate is re-read, and
an onKeyPreIme BACK hook so an IME self-dismissal can't leave the gate
stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field bug (Steam Deck client, exclusive topology): every isolate retry failed
SetDisplayConfig 0x57 with the physical panel left lit, while iPhone sessions
worked — their virtual monitor's connected-set already had a sole-display
topology persisted, so Windows deactivated the physical on hotplug and the
isolate was a no-op re-commit (deactivated 0). The Deck's monitor identity had
no such entry, forcing the live-deactivate path: supplying the doomed path
(inactive, modes unpinned) plus its orphaned mode entries is rejected 0x57 by
some driver/OS validation combos, so the retry loop could never converge — the
prior mode-index unpin (5e088af7) was necessary but not sufficient on this box.
Attempt 1 keeps the shipped shape; once a survivor is seen, later attempts
supply ONLY the keep paths with the mode table rebuilt to just their referenced
entries (indexes remapped, clone-shared entries deduped). The same host applies
exactly this keep-only shape rc=0 whenever the DB has pre-isolated the virtual
display, so the escalated shape is field-validated on the failing machine. The
final attempt also drops SDC_FORCE_MODE_ENUMERATION — a real path removal
drives COMMIT_MODES on its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass report (iPad Moonlight vs the Windows host): pen inked, native touch
did nothing. Cause: POINTER_INFO.pointerId was the client's raw wire id —
Moonlight sends arbitrary large pointerIds, and synthetic-pointer injection
rejects them (Apollo compacts ids into dense slots for exactly this reason);
the failure was logged at trace, i.e. invisibly. Wire ids now map to the
lowest free slot for the contact's lifetime, and the FIRST injection failure
of a device logs at WARN (pen too) so an inert input plane is never silent
again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Android leg of design/pen-tablet-input.md §7: against a HOST_CAP_PEN host,
stylus/eraser pointers split out of BOTH touch models (passthrough + gesture)
into StylusStream — state-full samples with pressure, AXIS_TILT, azimuth from
AXIS_ORIENTATION (Android's 0 = away-from-user IS wire north), AXIS_DISTANCE
hover, both stylus barrel buttons, the eraser tool, and historical (coalesced)
samples batched oldest-first. Kotlin heartbeats ≤100ms per the wire contract.
JNI: nativeHostSupportsPen + nativeSendPen (flat 10-float stride, sentinels
<0). No barrel-roll axis exists on Android — roll stays unknown here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flagship leg of design/pen-tablet-input.md §7: against a HOST_CAP_PEN host
the Pencil splits out of the finger path (independent of touch-input mode) into
PencilStream — state-full samples with force→pressure, altitude→polar tilt,
azimuth (+90° to the wire's north-clockwise convention), Pencil Pro rollAngle→
barrel roll (17.5+), coalesced touches batched ≤8/send for full 240Hz fidelity,
and Pencil hover (zOffset>0 distinguishes it from trackpad hover) as in-range
samples with distance. Squeeze holds barrel 1, double-tap clicks barrel 2 (the
Pencil has no hardware buttons/eraser — these are how host apps get those
affordances). Implements the ≤100ms heartbeat wire contract (80ms timer) so a
stationary held stroke survives the host's 200ms dead-client failsafe. Toward a
pen-less host nothing changes (Pencil stays a finger).
Also fixes hostSupportsCursor testing the STALE 0x04 bit — HOST_CAP_CURSOR
moved to 0x08 when TEXT_INPUT claimed 0x04; the old test would mistake a
text-input host (e.g. Windows) for a cursor grant and double-render the pointer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release notes now live in the repo at docs/releases/vX.Y.Z.md, authored during the
version bump (before the tag). ensure_release (gitea-release.sh + .ps1 twin) seeds the
Gitea release body from that file in the create POST, so the release is born WITH its
notes instead of being created empty and PATCHed afterward. canary/rc have no file ->
empty body, unchanged.
New manual `announce` workflow (workflow_dispatch, tag input) re-syncs the notes file
over the live release and posts a violet embed (title, notes lead-in, release-page link)
to the Discord #releases channel via the DISCORD_RELEASE_WEBHOOK secret. Stable-only:
a -rc tag is refused unless allow_prerelease=true. Pressing "go" is the quality gate, so
a half-built or failed release is never announced.
docs/releases/README.md documents the ritual; TEMPLATE.md is the skeleton.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parity with the Apple client (35c2c145): the SDL/Linux presenter built the
host-forwarded cursor as an OS cursor at its native bitmap size, so the
on-screen pointer tracked the HOST's display scaling — a 4K/high-DPI virtual
display forwards a 96 px bitmap that then rendered huge on the client.
SDL cursors are fixed-size from their surface (no draw-time scaling), so
cache the shapes RAW and resample each by the live video-fit factor
(min(window_px / mode) — the same aspect-fit `finger_to_content` uses) at
install time, rebuilding when the serial OR the fit changes (a resize). The
resample area-averages on premultiplied alpha so a transparent pixel's
colour can't bleed into the fringe. The pointer now stays sized to the
streamed desktop at any host scaling.
Not yet eyeballed on a Deck/Linux box — SDL's HiDPI cursor sizing is
version-dependent; validate on glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows leg of design/pen-tablet-input.md §6, following Apollo's recipe:
a per-session PT_PEN device (pressure rescaled 0..1024, polar tilt→tiltX/Y,
barrel roll on rotation — Windows Ink renders Pencil Pro roll natively, barrel
button, eraser via INVERTED/ERASER flags, hover) with a 40ms refresh thread
against the ~100ms synthetic-pointer staleness auto-lift, plus a PT_TOUCH
device closing the historical SendInput wire-touch no-op (full active-contact
frames, per-id DOWN/UP edges, self-healing lost-DOWN synthesis). pen_supported
now probes PT_PEN creation on Windows (1809+), which lights up HOST_CAP_PEN
and the GameStream featureFlags there — Moonlight iPad + Pencil can ink on a
Windows host. Frame grouping mirrors the Linux uinput backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Moonlight leg of design/pen-tablet-input.md §4: DESCRIBE now advertises
SS_FF_PEN_TOUCH_EVENTS wherever pen injection exists (Linux+uinput, same gate
as HOST_CAP_PEN — elsewhere the flag stays 0 and Moonlight keeps client-side
mouse emulation exactly as today). SS_PEN packets merge over last-known state
into state-full PenSamples (BUTTON_ONLY per spec carries no position; unknown
contact pressure 0.0 inks at full scale; UP keeps proximity only for clients
that demonstrated hover) and drive the same PenTracker→VirtualPen chain as the
native plane — Moonlight iOS + Apple Pencil now exercises the full injection
path end to end. SS_TOUCH forwards as wire touch on a synthetic 65535² surface
with CANCEL_ALL replayed per tracked contact. No stroke timeout on this plane:
ENet is ordered/reliable and Moonlight doesn't heartbeat a stationary pen.
Packet layouts pinned against moonlight-common-c Input.h/Limelight.h upstream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now
route through the per-session PenTracker into a lazily-created 'Punktfunk Pen'
uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel
roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput
and hand apps zwp_tablet_v2 with full fidelity. The host now advertises
HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch);
transitions group into SYN frames so proximity-enter carries its position.
Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire
contract — capture APIs are silent for a stationary pen); 200ms of silence
force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke
through the real tracker→uinput chain, no client needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (iPad via GameStream, 2026-07-23): cursor missing from the
stream. The session's fresh target 257 reported cursor_excluded=false
(DECLARED_TARGETS is keyed per target and only 259 had declared), so the
host skipped forced-composite — but DWM's pointer exclusion after an
IddCx hardware-cursor declare reaches EVERY later monitor of the adapter,
not just the declaring target (proven on-glass: declare on 259 under
WUDFHost 2192, then 257's frames streamed pointer-free with CURSOR_SHOWING
set and SM_MOUSEPRESENT true). GameStream/Moonlight clients cannot draw a
forwarded cursor, so those sessions were silently cursor-less.
ADD replies now report any-declare-this-WUDFHost-life (the state's real
scope — it dies with the adapter reset, same as before); per-target ids
stay only as the dbglog audit trail. Channel-less sessions on a declared
adapter then hit the existing forced-composite gate (GDI poller + blend)
and carry the pointer in-video. Host code unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Mac client drew the host-forwarded cursor bitmap at its native pixel
size (pixel-as-points), so the on-screen pointer tracked the HOST's display
scaling: a 4K/high-DPI virtual display renders a 96 px pointer, which then
appeared huge on the client — and on retina every pointer was already
2x-inflated and upscaled-blurry, since the backing scale was ignored.
Cache the shape raw and rebuild the NSCursor at the live video-fit factor
(fit.width / mode.width — the same AVMakeRect fit the input mapping uses)
on each apply, re-fitting on resize / retina moves. The pointer now renders
at its true size relative to the streamed desktop at any host scaling:
crisp and 1:1 with the video at 100%, and proportional (not ballooning)
when the host sits at 200-300% — including a deliberately raised scale.
iOS/tvOS (UIPointerInteraction) and Android (no forwarded-bitmap path)
render the pointer differently and need no change; the SDL/Linux client has
the same native-size issue and will follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.18.0 regression: UAC consent and Winlogon (lock/logon) stopped appearing
in streams. The cursor channel's IddCx hardware-cursor declare — re-issued
by the driver on every swap-chain assign — keeps the path out of the OS's
software-cursor mode, which is the only mode the secure desktop renders
through; DWM then never presents UAC/Winlogon into our swap-chain and the
stream repeats the last normal-desktop frame for the whole interaction.
The GDI cursor poller now classifies the input desktop on its reattach
cadence (UOI_NAME != Default = secure, 250 ms cadence instead of 2 s), and
the capturer edge-triggers the existing-but-unused proto-v6
IOCTL_SET_CURSOR_FORWARD flip: OFF at secure entry (the driver stops its
per-assign re-declare; the host facade forces the same-mode re-commit that
actualises the software-cursor default, under the vdisplay manager lock via
the new force_recommit()), back ON at dismissal for channel sessions.
Channel-session open resets the driver's persisted desired state to ON so
a session that died mid-secure-desktop can't leave the next one adopting
UNdeclared (the §8.6 cross-session composite trap); orderly teardown does
the same. The flip closure is built for every Windows session — a
channel-less session can reuse a driver monitor whose earlier-session
cursor worker still re-declares, and NOT_FOUND from never-declared targets
is ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Steam-conflict gate matched any physical Valve `28DE` HID device, so a
Steam Machine with a physical Steam Controller 2 (`28DE:1302`) plugged in
degraded a client's requested virtual Steam Deck (`28DE:1205`) to a DualSense
— the reporter's controller passthrough came up as the wrong pad.
Steam Input drives *distinct* Steam controllers side by side; only two
*identical* `28DE` devices confuse it (the Deck-as-host case: built-in
`28DE:1205` + a second virtual Deck, observed live on Bazzite). Narrow the
gate to key on the exact VID+PID instead of the `28DE` vendor alone:
- `steam_backend_product()` maps each virtual Steam backend to the PID it
enumerates as (Deck 1205, classic SC 1102, SC2 1302, SC2 Puck 1304).
- `physical_steam_controller_present()` -> `physical_steam_product_present(pid)`
matches `:28DE:{PID}`, keeping the FVPF-serial / `vhci_hcd` / `/virtual/`
exclusions so our own virtual pads never count.
- `degrade_steam_on_conflict` degrades only on a same-PID duplicate and logs
the conflicting identity. `PUNKTFUNK_STEAM_FORCE=1` override unchanged.
A physical SC2 no longer blocks a virtual Deck (and vice versa); a genuine
same-identity duplicate (physical Deck + virtual Deck, or physical SC2 +
SC2 passthrough) still degrades. Adds a unit test for the PID mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pen plane from design/pen-tablet-input.md, protocol side only (the P1 uinput
tablet injector will consume it): 0xCC kind 0x05 carries batches of state-full
PenSamples (pressure, polar tilt + azimuth, barrel roll, hover distance, eraser
tool, barrel buttons); PenTracker diffs samples into injector transitions so a
lost datagram self-heals; HOST_CAP_PEN (0x10) gates sending and stays
unadvertised until a real backend exists. C ABI: PunktfunkPenSample +
punktfunk_connection_send_pen, documented in docs/embedding-the-c-abi.md §8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Steam's "Add a Non-Steam Game to My Library" entries carry no `appmanifest`, so
`scan_manifests` can't see them and they never appear in the streamed library.
Read each Steam account's binary `userdata/<id>/config/shortcuts.vdf` directly
(a binary KeyValues parse) to surface the user's own custom entries, plus their
grid artwork from `userdata/<id>/config/grid`. Best-effort: an unreadable or
absent shortcuts.vdf contributes nothing, and hidden shortcuts / duplicate
appids are dropped. Verified: clippy -D warnings + compile green on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The visible "Punktfunk" shortcut's Steam appId is cached in SteamUI
localStorage, which lives in Steam's CEF profile — so it survives a
plugin uninstall/reinstall. Deleting the shortcut left the key dangling;
on the next mount ensure*Shortcut recalled the dead appId, took the reuse
branch, and ran SetShortcut* against a non-existent id (silent no-ops).
AddShortcut was never reached, so the entry never came back. The same
stale-reuse also made the hidden stream shortcut RunGame a dead gameid.
Verify the remembered appId still maps to a live shortcut via
appStore.GetAppOverviewByAppID before reusing it; a confident miss falls
through to AddShortcut. When appStore is unavailable, assume it exists so
a false negative can't spawn a duplicate library entry.
Also add a "Recreate library shortcut" recovery button to the QAM About
section (recreateShortcuts): drops any dead cached keys and re-ensures.
Covers deleting the shortcut without reinstalling, where no mount fires
the self-heal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every flatpak Tooling failure on 2026-07-22 (4 runs, incl. two AFTER the
runner fleet got its local dnsmasq cache) died in flatpak remote-add with an
instant 'Could not resolve hostname' ×10 while dnf in the SAME container
resolved fine. The one thing only this leg does is force glibc onto TCP DNS
(use-vc) — a 2026-07-11 mitigation for the embedded resolver dropping UDP
under fleet load. With job containers now resolving against the on-box
dnsmasq cache (daemon.json dns → docker0), UDP is reliable again and the TCP
path through the same chain is the flaky one under concurrency. Remove the
hack; keep the nss-resolve drop and retry.sh as the backstop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cursor-channel sweep grew AddReply by the cursor_excluded u32 tail (24
bytes, legacy prefix 20) and bumped PROTOCOL_VERSION to 6, but left two lib
tests behind: the AddReply byte-layout roundtrip still built the legacy
initializer (a compile error that turned the rust CI leg red on main), and
the version-coherence assertion still pinned v5 (hidden behind that compile
error). Cover the new tail bytes + the legacy-size constant, and widen the
compat-window note to v4–v6 (floor stays 3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bun-audit went red mid-day on a fresh advisory wave (13 findings across two
snapshots of a moving DB): node-tar DoS trio+1 (critical), sharp/libvips CVEs,
fast-uri host confusion, undici ×7, brace-expansion/linkify-it/js-yaml/postcss
DoS-class, immutable, dompurify. All transitive pins in the management-console
tree; none ship in the streaming stack.
Every fix version is in-range for its consumers, so: pin overrides for the
single-major packages (tar/dompurify/linkify-it/sharp/fast-uri/immutable/
undici/postcss/js-yaml — bun ignores range-scoped override keys, so plain keys)
and a full lockfile re-resolve, which also lifts brace-expansion 2.1.1→2.1.2
in place while its 5.x line (minimatch@10) stays untouched. : No
vulnerabilities found.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The KWin/Phase-A/B commits were built but never clippy-checked (that distrobox
had no clippy component), so they left TEMP on-glass probes and lint debt in the
tree. With clippy now runnable (fedora rust 1.96.1 = CI parity):
- drop the `fec424ee`/`8cff30d5` TEMP probes: the `update_cursor_meta` SPA_META
diagnostic logs (also un-detaches the `// SAFETY:` comment from its `unsafe`
block → fixes `undocumented_unsafe_blocks`) and the KWin composite-arm probe in
the encode loop.
- `#[allow(clippy::too_many_arguments)]` on `spawn_pipewire` (8 params since the
KWin leg added `expect_exact_dims`; mirrors `from_virtual_output`).
clippy `-p pf-capture -p pf-vdisplay -p punktfunk-host --locked --features
nvenc,vulkan-encode -- -D warnings` is now green. (--all-targets additionally
trips a pre-existing env mismatch: the fedora libspa binding lacks
`SPA_VIDEO_TRANSFER_SMPTE2084`, referenced only by a `#[cfg(test)]` guard-test —
not a code issue; CI's pinned pipewire has it.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A declared IddCx hardware cursor is IRREVOCABLE for its OS target's life
(§8.6), and the sticky exclusion survives monitor REMOVE→ADD because each
client gets a STABLE target id — so once any desktop-mode session declared,
every later pure-capture session on that target streamed a cursor-less
desktop: DWM excluded the pointer, no channel forwarded it, no blend drew it
(the exact no-regression gap §8.6's per-session cap gate cannot see).
Driver: track every successful SetupHardwareCursor per target
(DECLARED_TARGETS — scoped to the WUDFHost's life, exactly the sticky
state's scope) and report it in a new AddReply::cursor_excluded tail field
(dual-size discipline, both skews degrade cleanly; no proto bump).
Host: the flag rides AddedMonitor → Monitor → WinCaptureTarget; a session
WITHOUT the cursor channel on a flagged target forces composite mode in the
IDD-push capturer — GDI poller + blend for the session's life, pinned on
(set_cursor_forward cannot clear it: with no client drawing, un-compositing
would erase the pointer entirely).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gamescope excludes its pointer from the PipeWire node it feeds us and can't embed
one either (`set_hw_cursor` is inert), so every gamescope stream was cursorless.
Read the pointer from gamescope's nested Xwayland instead — XFixesGetCursorImage
for shape/hotspot/visibility, core QueryPointer for position — and publish a
CursorOverlay into the capturer's existing `cursor_live` slot, so the encoder
blend composites it into the video exactly like the SPA_META_Cursor path.
- pf-capture/src/linux/xfixes_cursor.rs (new): the XFixes reader. Connects to
EVERY nested Xwayland (Gaming Mode runs one per --xwayland-count) and follows
the focused one each tick (the display whose pointer moves), reading that
display's own cursor shape. Un-premultiplies ARGB -> straight RGBA. Drop stops
the thread.
- Capturer::attach_gamescope_cursor + the PortalCapturer override spawn it into
the same `cursor_live` slot; pf_vdisplay::gamescope_xwayland_cursor_targets
discovers the (DISPLAY, XAUTHORITY) pairs via the GAMESCOPE_WAYLAND_DISPLAY scan.
- host: SessionPlan.gamescope_cursor (set from the compositor at both resolve
sites AND on a mid-stream Desktop->Gaming switch); the blend gate now builds the
encoder blend for gamescope; a sibling composite arm attaches capturer.cursor()
per tick for capture-mode clients (no channel needed). native NV12 is disabled
for these sessions — that encode path can't blend the cursor (it assumes
gamescope embeds the pointer), so we capture RGB and route to the proven CUDA
VkSlotBlend / compute-CSC blend.
- the pipewire thread no longer clobbers `cursor_live` with None on a buffer that
carries no SPA_META_Cursor (gamescope) — that raced the XFixes writer and
strobed the composited pointer on/off.
On-glass (home-bazzite-2, RTX 5070 Ti, Gaming Mode): cursor visible + steady in
Big Picture and CS2 menus, follows focus between the two Xwaylands, hidden in-game.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Since the cursor-channel work, every Linux virtual output was created in
metadata pointer mode — making ALL sessions depend on host-side cursor
compositing, including the ones that can never use the channel (Moonlight/
GameStream, legacy clients, capture-mode starts). Those sessions paid the
blend bring-up per session and, whenever a visible cursor was composited,
the loss of NVENC's stream-ordered submit — for a strictly worse cursor
than the compositor's own.
Mirror the Windows no-regression gate: the already-wired per-session
set_hw_cursor(cursor_forward) now drives the Linux backends too. A
cursor-channel session gets metadata (shapes forwarded, composite flip
blends host-side — today's validated path, unchanged); every other session
gets the pointer compositor-EMBEDDED at creation (KWin zkde pointer=2,
Mutter cursor-mode=1, wlroots/hyprland portal CursorMode::Embedded — their
pre-channel default; the portal pair also gains the Metadata arm for
channel sessions, wired but untested on-glass). SessionPlan.cursor_blend
narrows to cursor-forward sessions and rides into the direct-SDK NVENC
open, which skips the Vulkan slot-blend bring-up entirely when off —
embedded sessions ring on plain CUDA surfaces and pay zero cursor cost,
per-session or per-frame.
The keep-alive registry's reuse key grows the created pointer mode (new
VirtualDisplay::hw_cursor getter): a kept embedded display has no cursor
metadata for a channel session to forward, and a kept metadata display
would leave a channel-less session with no pointer in its frames.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A vendored PTX blob is JIT'd against the driver's ISA ceiling, so the
cursor-blend module silently dies on drivers older than the generating
toolkit (CUDA_ERROR_UNSUPPORTED_PTX_VERSION/INVALID_PTX, 222/218 — the
KWin leg's invisible composite cursor on driver 595/CUDA 13.2 vs a
CUDA 13.3 blob). SPIR-V has no such coupling, and the in-tree precedent
already exists twice (vulkan_video's CSC blend, VkBridge's exportable
OPAQUE_FD → cuImportExternalMemory bridge).
New pf_zerocopy::vkslot::VkSlotBlend: the direct-SDK NVENC encoder now
allocates its input ring as exportable Vulkan buffers CUDA-imports (same
contiguous InputSurface layouts, pitch = row bytes rounded to 256), and
the cursor composite is a compute dispatch over the cursor's rectangle
(cursor_blend.comp, vendored .spv; spec-constant selects ARGB/NV12/YUV444;
BT.709 limited, matching the retired .cu). The surface SSBO is uint[] with
every invocation owning whole words — no 8-bit-storage device dependency.
Cursor-bearing frames force the existing CPU-synced submit path so the
CUDA copy → Vulkan dispatch (fence-waited) → NVENC encode ordering is
CPU-established; cursorless frames keep the stream-ordered fast path
untouched. Any bring-up/alloc/registration failure falls back wholesale
to plain pitched CUDA surfaces (never a mixed or short ring): sessions
always encode, composite mode just loses the cursor, warned once.
cursor_blend.cu / cursor_blend.ptx and the CursorBlend PTX loader are
deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rate-limited journal logging bracketing the silent segment of the Linux
composite path: what overlay (if any) the encode loop's composite arm
hands the encoder, and whether the NVENC cursor-blend kernel launches and
with what geometry. On-glass (KWin leg) the blend module loads yet the
composite cursor stays invisible — these two probes attribute it to
no-overlay / stripped-en-route / kernel-draws-nothing. The module-loaded
INFO line is permanent (success must be as attributable as failure);
everything else in this commit is temporary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KWin's ScreenCastStream builds its PipeWire format offer — including the
maxFramerate cap it actively throttles delivery to — ONCE at stream
creation, when the virtual output sits at its hardcoded birth 60 Hz. A
kscreen custom-mode change afterwards updates the OUTPUT (readback says
240) but never the offer, so every consumer negotiates max=60 and the
stream delivers 60 (pw-dump-verified on KWin 6.6.4). The only path that
rebuilds the offer is the stream's own resize handling: a source SIZE
change while recording re-runs buildFormats — picking up the output's
current refresh — and renegotiates the live stream.
So above 60 Hz, birth the output at a sacrificial height (+16), then
install + select the real WxH@hz custom mode: the first frame recorded
after the consumer connects triggers KWin's resize path, which renegotiates
the live stream to WxH@hz. The capturer holds (requeues) buffers until the
negotiated size matches — new expect_exact_dims plumbing VirtualOutput →
registry (fresh creates only) → open_virtual_output → a self-disarming
gate in the process callback — bounded by a 3 s deadline that accepts the
producer's dims rather than wedging the session into the first-frame
retry loop. set_custom_refresh reads back the full active mode; a size
reject (pre-6.6 KWin) recreates plain at the real size @60.
Every kscreen operation now addresses the output by its NUMERIC id,
resolved by matching the managed name-prefix AND the just-created birth
size (newest id wins): a mode-switch supersede reuses the per-slot output
NAME (deliberately, for KWin's per-name config persistence) while the
superseded sibling is still alive, and name addressing hit the FIRST
match = the OLD output — on-glass that resized the live session's display
out from under it (wrong-res/black), read the old output back as 'mode
applied', and set the old output primary while the replacement starved at
its birth mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vendored cursor_blend.ptx was regenerated with the CUDA 13.3 toolkit,
which stamps '.version 9.3' — and a driver whose JIT predates that ISA
refuses the whole module with CUDA_ERROR_UNSUPPORTED_PTX_VERSION (222),
silently killing host-side cursor compositing (on-glass: composite-mode
cursor invisible on driver 595.58, KWin leg). The kernels use only baseline
arithmetic, so hand-lower the version directive to 8.0 (CUDA 12.0), which
every supported Turing+ driver JITs. Both files now warn that an nvcc
regeneration re-stamps the version and must be re-lowered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frame-attached overlay is the pointer position at the last damage
frame; repeats re-encoding a static desktop froze the blended cursor
between redraws — on-glass the composite-mode cursor stuttered while
window drags (constant damage) were smooth. Refresh the repeat's overlay
from Capturer::cursor each tick so pointer-only motion re-blends at tick
rate — the bandwidth the pre-channel embedded mode already paid. Linux
only: the Windows capturer composites internally and its frames must
never carry an overlay a blend path would double-draw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The forwarder read only frame.cursor — the overlay attached to the LAST
EMITTED frame. Mutter's pointer-only buffers update the capture-side cursor
state but are skipped as frames, so between damage frames the forwarder
re-sent a stale snapshot: on-glass the client cursor appeared exactly when
clicking or crossing hover targets (damage) and froze/vanished otherwise —
the same pointer-only-motion gap the Windows IddCx channel fills, which is
why the encode tick already prefers the capturer's LIVE cursor. Publish the
overlay from every dequeued buffer (frames or not) into a shared slot and
implement the Capturer::cursor hook on PortalCapturer.
Host-composite mode on Linux still updates the blended pointer only on
damage frames (no re-encode of a static desktop on cursor motion) — the
regen-on-cursor-change the Windows path has is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mutter only rewrites a buffer's SPA_META_Cursor region when the cursor
changed; recycled buffers between damage frames carry a stale id-0 meta.
Treating id 0 as a hidden pointer flickered the client cursor off between
hovers on-glass — per the SPA contract id 0 is 'invalid/no cursor info',
so keep the last-known state (OBS's consumer does the same). A genuinely
hidden pointer stops producing updates; the M3 relative-mode hint keeps
its Windows CURSOR_SUPPRESSED source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mutter offers SPA_META_Cursor with a FIXED size pod —
SPA_POD_Int(CURSOR_META_SIZE(384, 384)) in meta-screen-cast-stream-src.c —
while our request capped the range at meta_size(256, 256). The intersection
is empty, so the Meta param silently failed to negotiate and no buffer ever
carried the meta region: the entire Linux cursor pipeline (forward AND
blend) was blind on GNOME, proven by an on-glass probe counting 32k+
buffers with zero metas. KWin's offer fits inside 256², which is why the
same consumer code passed there. Raise the max to 1024² headroom (the
negotiated allocation follows the producer's value, not our max) and align
the bitmap parse guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rate-limited journal logging of SPA_META_Cursor arrival/absence, reported
id/position/bitmap offset, and bitmap acceptance — the Linux cursor
pipeline is otherwise blind end-to-end during Mutter RecordVirtual
metadata bring-up on .21.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The libei absolute device advertises one region per logical monitor, and
both MouseMoveAbs and touch mapped into regions().first() — whichever
output the compositor announced first. Next to a physical monitor (on-glass:
GNOME with a dummy HDMI beside the virtual primary) that put the pointer and
every click on the WRONG output: the seat cursor never entered the streamed
monitor, so neither embedded nor metadata cursor capture could ever see it,
and the cursor channel had nothing to forward. Pick the region whose logical
size matches the streamed mode (the wire flags already carry it); fall back
to first() for the single-monitor case. Region geometry now rides the
device-RESUMED log line for diagnosability; matching the screencast
mapping_id instead of the size is the follow-up for same-sized-monitor
ambiguity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same trap as the Mutter backend: zkde_screencast pointer mode 2 (embedded)
never delivers SPA_META_Cursor, starving both the cursor channel and the
encoder blend. Mode 4 (metadata) feeds both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The resize rebuild creates the new virtual display BEFORE retiring the old
one (create-before-drop), so the registry saw a live same-backend sibling —
its own dying predecessor — and told the backend it was not first in group.
Mutter then skipped the Primary/Exclusive apply ("joining an existing
display group — extending") and the retiring owner took the topology with
it: every resize silently demoted the virtual output to an extended,
shell-less desktop. Thread the superseded pool gen through acquire so the
replacement establishes topology, and stop counting kept (Lingering/Pinned)
entries as demoting siblings — no session owns them, so there is no live
desktop to clobber.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecordVirtual was created with cursor-mode EMBEDDED, which never delivers
cursor metadata — the cursor channel had nothing to forward (client-drawn
cursor invisible) and the always-built encoder blend had nothing to
composite (host-drawn cursor invisible too). Metadata mode feeds both:
non-forwarding sessions get the blended pointer (same pixels Mutter would
have embedded), forwarding sessions strip the overlay and send shape/state.
The capturer already negotiates the meta on every stream (meta_param).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
faad4b57 shipped the field, shader scale, and plumbing but the
scripted edit inserting the sdr_white_level_scale() refresh in the
scratch-rebuild arm matched nothing and silently no-op'd (no assert) —
sdr_white_scale stayed at its 1.0 init, i.e. 80 nits, i.e. exactly the
old dark cursor. Now queried on every scratch rebuild, with an info
log of queried/applied so an HDR session shows what it uses.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The polled position is also the composite-blend position: at 16 ms a
240 fps session reused a stale position for ~4 consecutive frames and
the composited pointer visibly stuttered against the video. 4 ms
out-paces every session rate; a tick is one GetCursorInfo syscall.
- Log the queried DISPLAYCONFIG_SDR_WHITE_LEVEL scale on each blend-
scratch rebuild so an HDR session shows what is actually applied
(the virtual display's own SDR-brightness setting is the suspect
when the cursor still reads dark).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sRGB→linear alone lands cursor-white at 80 nits (scRGB 1.0) while DWM
composes the surrounding SDR desktop at the user's SDR-brightness
setting (~200+ nits by default) — the cursor read visibly dark on HDR.
Scale by DISPLAYCONFIG_SDR_WHITE_LEVEL (new
win_display::sdr_white_level_scale, queried on blend-scratch rebuilds —
the CCD query stays off the per-frame path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause, proven on-glass across five driver builds: a declared IddCx
hardware cursor is IRREVOCABLE. There is no un-declare DDI, the
empty-caps re-setup is rejected INVALID_PARAMETER, and after a
successful same-mode re-commit with the driver's re-declare provably
suppressed (sticky per-target flag, zero re-setups logged) DWM still
never composites the software cursor back into the monitor's frames.
Every DWM-based composite flip is therefore unfixable.
The composite (capture) model is now implemented in OUR pipeline:
- the driver keeps its hardware cursor declared for the session's whole
life (the state that works) — frames stay pointer-free always;
- in composite mode try_consume routes the conversion through a blend
scratch: slot copy + one alpha-blended quad of the GDI poller's shape
at its polled position (CursorBlendPass — fullscreen-triangle VS with
viewport placement, straight-alpha PS, sRGB→linear for FP16/HDR
rings), covering all four convert paths (NV12, 4:4:4 copy, P010,
PyroWave) GPU-side under the slot's keyed mutex;
- pointer-only motion produces no driver publish (declared hw cursor),
so try_consume REGENERATES from the last slot whenever the polled
cursor state changes — the cursor moves on a static desktop at tick
rate, without faking freshness for the stall/death watchdogs;
- set_cursor_forward becomes purely host-internal state; the
SET_CURSOR_FORWARD IOCTL machinery is no longer used by the host
(driver keeps it, dormant) and its sender plumbing is removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass: 're-setup on swap-chain assign -> 0x0' fired AFTER 'enable=0
stored' — duplicate/successor monitor entries (re-arrival churn) kept
their default-true flag and re-declared the hardware cursor right after
the composite flip's re-commit, undoing it.
The desired state now lives in a per-target static
(CURSOR_FORWARD_DESIRED): every arrival inherits it (no generation can
resurrect a declare the session turned off), and the flip stamps EVERY
live entry matching the target, declaring against whichever has the
live worker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The empty-caps un-declare is REJECTED by IddCx (STATUS_INVALID_PARAMETER,
on-glass driver 9.9.0722.1407) — there is no un-declare DDI. The
composite flip now works with the only lever the OS gives us: every
mode COMMIT reverts the path to the software cursor, and the driver
skips its per-commit re-declare while cursor_forward_on is off. So:
- driver: disable stores the flag only (no DDI call); enable still
declares immediately against the live worker's event.
- host capturer: after a successful disable flip, force a same-mode
re-commit (win_display::force_mode_reenumeration) — DWM composites
the pointer from the very next commit; the swap-chain flap is the
class the driver's preserved-publisher machinery already rides out.
- state now survives re-arrivals end-to-end: the capturer caches the
APPLIED state (cleared on every channel (re)delivery, which
re-created driver entries need — their flag defaults to declared),
and the stream loop re-applies every tick instead of edge-gating
(steady-state cost: one Option compare).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass (.173, match-window session): every window-size convergence
re-creates the driver-side monitor (re-arrival resize / sibling-session
slot churn), destroying the cursor worker the channel was delivered to —
the flip then died NOT_FOUND (no entry with target_id ∧ worker) and the
declared state was lost entirely.
Driver: set_cursor_forward becomes STATE, not an edge on one monitor
generation — find by target_id ∧ hw_cursor, store cursor_forward_on
even without a live worker (it steers the next delivery/re-setup);
declare/un-declare only when a worker exists. Channel delivery honors
the flag: setup_and_spawn(declare=false) adopts + spawns WITHOUT
declaring (composite mode), so a later enable-flip declares against the
worker's event.
Host: retain the SET_CURSOR_CHANNEL sender; extract
deliver_cursor_channel and RE-deliver the surviving section on every
ring recreate and — with one retry — when a flip IOCTL fails
(the re-arrived entry gets a fresh worker, declared per its flag).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Belongs to 493f4fae (which shipped only the crates/ side — this
packaging/ half was left unstaged, so the deployed driver silently
lacked the IOCTL and every flip died NOT_FOUND at dispatch):
IOCTL_SET_CURSOR_FORWARD dispatch + monitor::set_cursor_forward
(cursor_forward_on flag, resetup gate) + cursor_worker::
unsetup_hardware_cursor (empty-caps un-declare experiment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PunktfunkConnection.setCursorRender(clientDraws:) over the new
punktfunk_connection_set_cursor_render export, driven by one
edge-detected reconciler in StreamView (clientDraws = captured &&
desktopMouse) called from every transition — the ⌃⌥⇧M chord,
engage/release, and session start. Capture model and released now get
the host-composited pointer back in the video (full fidelity); the
desktop model keeps the local NSCursor path. xcframework rebuilt v12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cursor channel excluded the host pointer from the video for the
whole session, but the client only draws it under the DESKTOP mouse
model — flipping ⌃⌥⇧M to capture mid-stream left the session
pointer-blind (user-found). The fix makes render ownership LIVE state:
- wire: CursorRenderMode 0x51 (client→host, control stream) —
client_draws: true = desktop model (host excludes + forwards),
false = capture model / released (host composites, full fidelity —
DWM on Windows incl. real XOR inversion, encoder blend on Linux).
Sessions start client_draws=true (the pre-message behavior).
- host: control task stores the flag; the encode loop edge-detects it —
forwarding + frame.cursor strip while the client draws, quiet
forwarder + overlay-into-blend while composited. SessionPlan now
grants blend CAPABILITY wherever the capture has a pointer (dropping
the !cursor_forward gate) so the composite side needs no encoder
rebuild; per-tick frame.cursor decides what is drawn.
- Windows: Capturer::set_cursor_forward → retained
IOCTL_SET_CURSOR_FORWARD sender (proto v6) → driver (un)declares the
IddCx hardware cursor on the LIVE monitor. Un-declare re-issues the
setup with EMPTY caps (candidate mechanism — the DDI has no
documented un-setup); resetup-on-commit is skipped while off, so a
mode commit's software-cursor default is the fallback path. Failure
against a pre-v6 driver logs and keeps declared-at-ADD behavior.
- SDL client: one edge-detected reconciler at the cursor pump site —
desktop-active = client draws; capture model or released = host
composites. Covers the chord, the M3 auto-flip, and engage/release.
- C ABI v12: punktfunk_connection_set_cursor_render (additive; wire
version unchanged — pre-§8 hosts ignore the unknown message type).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cursorChannelActive/hostCursors/cursorState stayed latched across
sessions — a next session against a host WITHOUT the cursor cap would
wear the previous session's stale shapes via resetCursorRects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The M2c redesign (design/remote-desktop-sweep.md §8): the IddCx hardware-
cursor query is alpha-only by design — IDDCX_CURSOR_SHAPE_TYPE has no
monochrome value, the OS pre-converts monochrome to masked-color, and
masked-color delivery is dead code on modern builds (proven on-glass at
every ColorXorCursorSupport level). The driver keeps its hardware cursor
declared (XOR FULL) purely so DWM excludes every cursor type from the
IDD frame; the SHAPE now comes from a GDI poller in the capture host —
which runs as SYSTEM inside the interactive session, so no helper
process is needed.
CursorPoller (idd_push/cursor_poll.rs, the RustDesk/WebRTC/OBS pattern):
~60 Hz GetCursorInfo on a dedicated thread; rasterise via
CopyIcon→GetIconInfo→GetDIBits only when the HCURSOR value changes;
monochrome via the WebRTC truth table with invert pixels as opaque black
plus a white outline; AND-mask alpha for alpha-less color cursors;
CURSOR_SUPPRESSED = hidden; per-output visibility via the target's
desktop rect; per-monitor-V2 DPI awareness thread-scoped; input-desktop
reattach on failure + 2 s cadence (GetCursorInfo on a stale desktop
succeeds with stale data). Spawned iff the cursor channel was delivered;
a live poller is the sole overlay source (no serial-namespace mixing) —
the driver shm read remains as fallback if the poller dies.
Not chosen: DXGI Desktop Duplication GetFramePointerShape —
PointerPosition.Visible goes stale under injected-only input on current
Win11 (Sunshine #5293, exactly our topology), it burns one of the
session's four duplication slots, and IDD-output metadata has
conflicting field reports.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Durable results of the M2c on-glass bring-up (.173, 2026-07-22):
- The base IddCxMonitorQueryHardwareCursor DDI slot is stubbed to
STATUS_NOT_SUPPORTED on WDK 26100 — add the ...QueryHardwareCursor3
wrapper (wdk-iddcx) and drain it instead; v3 X/Y are only meaningful
when PositionValid, so a position-invalid tick keeps the prior position.
- Hardware-cursor setup is per-mode-commit: the OS silently reverts to a
software cursor on every mode commit, so re-issue the setup on each
swap-chain assignment (monitor::resetup_cursor, called from
assign_swap_chain) or the query fails NOT_SUPPORTED forever.
- One caps definition for initial setup and re-setup, resting at XOR
FULL: the query delivers ONLY alpha shapes in every configuration
(all three ColorXorCursorSupport levels, event-driven and 30 Hz
polled) — masked/monochrome cursors never arrive. FULL keeps the
frame cursor-free for ALL cursor types; the full-fidelity shape comes
from the session-side cursor source in the host
(design/remote-desktop-sweep.md §8).
- Poll the query at ~30 Hz instead of pure event-wait: masked cursors
never fire the data event, and the seqlock's position/visibility
should stay fresh regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass (Mac ↔ Windows M2c host): the host cursor's VISIBILITY is not a
usable 'a game grabbed the mouse' signal — Windows hides the pointer for
ordinary desktop activity (clicking, typing). The per-frame forwarder
turned those transients into relative_hint=true, so the client flipped
desktop→capture→desktop, which (a) warped the cursor to view-centre and
(b) flushed held buttons via releaseAll — a spurious mouse-up ~200 ms
into every press that broke window dragging/resizing.
Disable the auto-flip; the mouse model stays user-driven (⌃⌥⇧M). The
hint still rides the wire, unused, for a future M3 that keys off a REAL
pointer-lock signal (host-side ClipCursor/raw-input detection) instead
of visibility.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FIRST session's Welcome runs before any backend construction
(vdisplay::open happens at display prep, after the Welcome), so the
capability probe's vdm() expect panicked the very first handshake of a
fresh service — the client saw a dead connect and auto-wake kicked in.
init() is idempotent and the driver facade is a unit struct, so the
probe now initialises the manager itself. Found on-glass (.173, first
Mac-client connect), fix deployed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Mac client now exercises the full cursor channel against capable
hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host
stops compositing the pointer, and StreamView draws the forwarded
shapes as the real NSCursor — plus the M3 host-driven model flip.
- ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 —
Android untouched), PunktfunkCursorShape/State + the two
next_cursor_* poll fns (audio-style borrow contract), and
PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion
split next_host_timing's cfg/no_mangle attributes off the fn, which
silently dropped the symbol from the header AND dylib — caught by
the Swift build, reattached with its docs.
- PunktfunkConnection: clientCaps connect param, hostSupportsCursor,
CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState
(one cursor thread drains both planes; cursorLock joins the close()
ladder).
- SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect
with the cursor cap on macOS.
- StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via
CGImage), resetCursorRects wears the HOST shape while the desktop
model is engaged (hidden host pointer ⇒ invisible), latest-wins
state pump, and the M3 auto-flip — edge-triggered on relative_hint,
⌃⌥⇧M sets an override latch cleared by the next host edge, leaving
relative warps the pointer to the host position via the inverse
letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's
coordinate convention).
Verified: swift build + full test suites green (xcframework rebuilt at
ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the cursor channel to Windows hosts. The pf-vdisplay driver
declares an IddCx hardware cursor for sessions that negotiated
cursor-forward — DWM then EXCLUDES the pointer from the IDD frame and
delivers shape/position out-of-band, into the same CursorOverlay →
forwarder → wire → client pipeline the Linux portal path uses.
- pf-driver-proto v5 (additive, host floor stays 3): AddRequest's spare
tail becomes hw_cursor (same size/offsets); IOCTL_SET_CURSOR_CHANNEL
delivers a host-created CursorShm section (64-byte seqlock header +
256² shape buffer, layout pinned + tested). No event crosses the
boundary — the host polls at encode-tick pace.
- driver: wdk-iddcx grows the two cursor DDI wrappers; a per-monitor
cursor worker (event wait → QueryHardwareCursor → seqlock publish)
starts only when BOTH the ADD asked and the channel arrived, so a
failed delivery leaves DWM compositing as today. Shape bytes ship raw
(BGRA/masked + pitch); the host converts.
- host: the section rides the existing sealed-channel broker (least-
privilege dup, remote reap on failure); IddPushCapturer::cursor()
seqlock-reads → CursorOverlay (BGRA→RGBA, masked-color approximation,
desktop→frame origin shift, per-shape conversion cache). New
Capturer::cursor() trait hook — the encode loop prefers it over the
frame-attached overlay because hardware-cursor moves produce NO new
frame on a static desktop. hw_cursor survives the re-arrival resize
(carried on the manager's Monitor).
- negotiation: cursor_forward grows the Windows arm (client cap ∧
driver proto ≥ 5, probed once via the control device); SessionPlan
carries cursor_forward → OutputFormat.hw_cursor.
- drive-by: wdk-probe's two pre-existing same-type casts (clippy) and
pf-vdisplay's stale spike-test refs (crate::win_display moved to
pf-win-display; tracing-subscriber was never a dep) repaired.
Verified: proto tests on Mac AND MSVC (15/15 incl. the CursorShm
layout pin); clippy -D warnings for proto/frame/capture/vdisplay/host
on Linux (.21) and native Windows (.173); the DRIVER workspace clippy
-D warnings green against the real WDK 10.0.26100 bindgen (DDI names,
enum variants and IDARG layouts all bind). On-box driver deploy +
on-glass validation follow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full Parsec model: launching a game from a desktop session flips the
client into captured relative automatically, and back — no chords.
- Capture overlay now distinguishes 'hidden' from 'no cursor yet':
CursorOverlay grows a visible flag, overlay() returns Some whenever a
bitmap is known (the encode loop strips invisible overlays after
forwarding so no blend path ever draws one; the CPU composite guarded
on visibility already).
- Host forwarder maps it onto the reserved wire bit: visible ⇒ VISIBLE,
hidden-but-known ⇒ RELATIVE_HINT (an app grabbed/hid the pointer),
never any hint before the first bitmap — a cold start can't flip a
desktop session into capture.
- Presenter: edge-triggered auto-flip on hint changes via the new
Capture::set_desktop (chord semantics untouched); a manual ⌃⌥⇧M sets
an override latch that holds until the HOST's intent next changes, so
the hint never fights the user. Leaving relative warps the local
cursor to the host's last pointer position through the new
content_to_window inverse-letterbox mapping (unit-tested against
finger_to_content) — the hand-back is seamless.
Verified on .21: fmt + clippy -D warnings + tests (presenter 20 incl.
the inverse-mapping roundtrip, host 245).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.
Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
fixed display_hdr block — presence disambiguated by remaining length,
which caps the post-HDR tail at 27 bytes (documented); Welcome answers
HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
client caches by serial — re-showing a known shape costs 14 bytes, not
a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
position, sent once per encode-loop tick — latest-wins, self-healing
under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
next_cursor_shape/next_cursor_state; connect() grows client_caps
(C ABI passes 0 until the v11 cursor poll fns exist).
Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
encode loop ticks a CursorForwarder every iteration: shape-serial diff
→ control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
(nearest-neighbor downscale backstop for XL cursors, unit-tested).
Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
SDL color cursors (from_surface + hotspot), applied while the desktop
mouse model is engaged; visibility follows the host; capture/released
hands back the system cursor. Sessions advertise the cap when they
START in desktop mode.
Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Folds the parked client-side-cursor machinery (cursorMode auto/always/
never, hidden while disabled) into the cross-client mouse model:
MouseInputMode capture|desktop under DefaultsKey.mouseMode, picked in
Settings ▸ Keyboard & mouse (macOS), resolved at session start and
gated off on gamescope hosts (relative-only EIS).
Desktop model = the un-neutered absolute path with the SDL cursor
policy: pointer never disassociated (enters/leaves the stream freely),
monitor forwards letterboxed absolute positions, and the local cursor
hides only while over the view via an invisible-cursor rect (the
host's composited cursor is the one you see; AppKit manages the rect,
so no hide/unhide balancing). ⌘⇧C becomes ⌃⌥⇧M — the same chord as
the SDL clients — flipping the model live with an atomic
release/re-engage.
Verified: swift build + full test suites green (macOS arm64).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New physical-mouse model beside capture: Desktop leaves the pointer
uncaptured and sends absolute positions through the letterbox
(MouseMoveAbs — every host injector already consumes it). Capture
stays the default and the game model.
- pf-client-core: MouseMode (capture|desktop) persisted in Settings,
default capture so existing stores are unchanged.
- pf-presenter: Capture grows a desktop model — latest-wins pending
abs position coalesced per loop iteration (same 1000 Hz discipline
as relative), flushed before clicks/keys/wheel so they land where
the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor
stays hidden over the window (the host's composited cursor is the
one you see until the M2 cursor channel); Windows keyboard grab
only engages for capture (a desktop stream is something you
Alt-Tab away from).
- gamescope gating: its EIS is relative-only, so desktop mode is
pinned off there (resolved_compositor), with a log note.
- Settings surfaces: GTK row (dynamic caption), WinUI combo,
console-UI row + step test, capture-hint line.
Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21;
clippy -D warnings for all five crates incl. punktfunk-client-windows
native on .173.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.17.2 placeholder underestimated this cycle: Android mouse & keyboard
(physical mouse + capture, TV remote-as-pointer, IME text, clipboard), the new
committed-text input plane (InputKind::TextInput / HOST_CAP_TEXT_INPUT), and
the ChaCha20-Poly1305 session cipher are feature work — a minor, not a patch.
Wire/driver/C-ABI markers all unchanged (2 / 4 / 10): every addition is
negotiated or ignored by older peers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The android leg failed twice on 2026-07-22 with sdkmanager's "Error on
ZipFile unknown archive" — once in our NDK/build-tools step, once inside
setup-android's default package set. Root cause is the shared runner fleet
dropping packets under parallel-job load (the same class retry.sh documents
for the flatpak leg): sdkmanager unzips while streaming, so a truncated
stream reads as a corrupt archive. A solo re-download on the same runner
verified clean.
- setup-android: install only platform-tools — the action's default legacy
`tools` package drags in the ~250 MB emulator this job never uses, which
was the largest and flakiest download in the whole leg.
- NDK/build-tools/CMake: wrap sdkmanager in retry.sh (4 attempts, linear
backoff) — a failed attempt leaves no partial package, so re-invoking is
safe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
punktfunk negotiates HDR correctly (the stream is BT.2020 PQ 10-bit) but never
told the client to switch its display into HDR picture mode, so HDR streams
stayed in SDR on the TV. Sunshine sends an async control-stream message
(IDX_HDR_MODE, type 0x010e, carrying SS_HDR_METADATA) shortly after the stream
starts; Moonlight only flips the TV into HDR on that cue. We sent exactly one
host→client control message (rumble 0x010B) and never this one. (aurora-tv PR
#53 worked around it client-side by inferring HDR from the negotiated format;
this fixes it host-side so stock Moonlight clients switch too.)
Once the control stream is live and the session negotiated HDR, send a one-shot
0x010e message (enable + 26-byte SS_HDR_METADATA from generic HDR10 defaults),
latched per connection and re-armed on Disconnect. The rumble sequence counter
is unified into a single monotonic `host_seq` shared by both message types —
the GCM nonce derives from `seq`, so a separate per-type counter would reuse
(key, nonce) pairs in the host direction.
The BT.2020 PQ VUI already rides the bitstream (the encoder derives it from the
P010 capture format), so this is purely the missing "switch now" signal; no
encoder change needed. Wire layout locked by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rustls-webpki 0.103 accepts only X.509 v3 certs and returns
`UnsupportedCertVersion` for anything older, aborting the mTLS handshake at
the `CertificateVerify` step before it ever checks the signature. The
moonlight-embedded client family (moonlight-embedded, aurora-tv, …) still mint
self-signed **v2** certs with no keyUsage extension, so pairing fails against
punktfunk while it works against Sunshine — whose OpenSSL verify callback never
inspects the cert version. (aurora-tv PR #53 worked around this client-side by
switching to v3 + keyUsage; this fixes it host-side for stock clients too.)
The cert's X.509 version and extensions carry no security weight here: the
client cert is self-signed and pinned by SHA-256 (`peer_is_paired`), and the PIN
pairing ceremony is the real proof of identity. The property that DOES matter —
that the peer holds the private key for the cert it presented — is still
enforced: when webpki rejects the cert we re-run exactly that `CertificateVerify`
check with a version-agnostic parser (x509-parser SPKI + the same `rsa` verify
`pairing::verify256` uses). This is a full cryptographic check, never a bypass:
a bad signature still fails, and non-RSA / unsupported schemes fall through to
webpki's original error. Matches Sunshine's leniency without loosening the
pinned trust model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop clients' clipboard protocol, adopted on Android (text-only v1):
opt-in ClipControl at stream start when the host advertises HOST_CAP_CLIPBOARD
and the new "Shared clipboard" setting (default on) allows.
Device → host: local copies (primary-clip listener + a probe at start) are
announced as lazy format-list offers; the text crosses only when the host
actually pastes (FetchRequest → served from the live clipboard). Host →
device: a host copy's offer is fetched eagerly and lands in the system
clipboard (Android has no practical lazy-paste provider), with an echo guard
so the resulting clip-changed callback doesn't bounce it back as a new offer.
Native side: session/clipboard.rs JNI shims over NativeClient's clip_*
surface; events cross to Kotlin as compact strings from a blocking
nativeNextClip poll on a dedicated thread (the nativeNextRumble pattern),
joined before the session handle is freed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:
- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
keymap grows Unicode keysyms on demand (the wtype model), so keymap
re-uploads never disturb the main device's layout/modifier state. The
KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
has the cap — the IME runs its full machinery, mirrored to the host live via
common-prefix diffs of the composition (backspaces + new suffix), with
setComposingRegion adopting committed text so autocorrect-revert flows diff
instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
Linux hosts too.
Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase A — hardware mouse: new MouseForwarder routes every SOURCE_MOUSE event
while streaming. Uncaptured (default): hover/drag positions forward as absolute
cursor moves, wheel with fractional accumulation (high-res wheels), all five
buttons incl. back/forward as X1/X2; the local cursor is hidden over the stream
(TYPE_NULL pointer icon) so the host's composited cursor is the visible one.
Captured ("Capture pointer for games" setting, or Ctrl+Alt+Shift+Q live): the
OS pointer is grabbed via requestPointerCapture on the (repurposed) key-capture
view and raw relative deltas forward as mouse-look; held buttons are tracked
and flushed on capture loss/teardown, and the engaging click is swallowed
(desktop parity). Mouse clicks are intercepted before Compose so they can never
be misread as trackpad taps; a mouse back-button's FALLBACK BACK is dropped so
it can't yank the user out of the stream.
Phase B — keyboard: media/consumer keys (play/pause/next/prev/stop) now map to
their VKs instead of falling to the system; TV-remote SELECT maps to Enter.
Phase D — Android TV: RemotePointer turns a D-pad-only remote into a pointer
(Siri-remote analogue): hold SELECT ~0.8s toggles, D-pad glides the host cursor
with ramped acceleration (Choreographer-paced, diagonal-normalized), SELECT
taps click, play/pause right-clicks, holding it toggles the on-screen keyboard,
BACK leaves pointer mode; an overlay hint teaches the vocabulary.
Settings: "Capture pointer for games" + "Invert scroll direction" (applies
to the wheel and two-finger touch scrolling).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: with Exclusive Topology, the physical monitor sometimes stayed
lit — every isolate attempt re-committed and failed SetDisplayConfig with
0x57 ERROR_INVALID_PARAMETER. Clearing DISPLAYCONFIG_PATH_ACTIVE alone leaves
the deactivated path's source/target modeInfoIdx pinned to the queried mode
entries, and per the SetDisplayConfig contract a path being turned off must
have BOTH mode indexes set to DISPLAYCONFIG_PATH_MODE_IDX_INVALID; some
driver/topology combinations (notably when the doomed path shares a source
with the kept virtual path) reject the supplied config outright, so the
retry loop could never converge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remote INPUT resets the compositor's idle timers, but a passive
(video-only) viewer sends none — observed live: a SteamOS Game-Mode
host s2idled mid-day and dropped off the network (in a VM with GPU
passthrough it never woke again). Refcounted across planes: the native
LiveSessionGuard and the GameStream video worker each hold a share;
first hold takes the logind sleep:idle block inhibitor on a dedicated
plain thread (zbus blocking must not run on a tokio worker), last drop
releases. Best-effort: no logind → warn once and stream on. No-op off
Linux (macOS/Windows manage their own assertions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- rebuild-check.sh + punktfunk-rebuild-check.service (enabled, ordered
Before=punktfunk-host): ldd-probes the host binary at session start —
milliseconds when healthy, a full update.sh rebuild only when a
SteamOS update actually broke its library links. update.sh restarts
go --no-block so the check → update.sh → restart chain can't deadlock
against the unit ordering. update.sh retrofits the unit.
- installer summary: web console is https (the unit serves TLS).
- docs: steamos-host.md (runner in the build step, keep-list + auto
rebuild = updates survive hands-free), gamescope.md (Gaming Mode
touch = single-finger pointer, exact taps, no multi-touch),
plugins.mdx (SteamOS runner note), scripts/steamdeck/README.md
(rebuild-check, runner payload, keep list, honest packaging
trade-off + the CI-prebuilt future direction).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-client-pixels fallback is exact only while the managed session
runs at the client's mode. When they diverge — foreign-gamescope
attach at its own resolution, supersample/under-render, transitions —
raw pixels drift or land out of range. The EIS relay file now carries
the session's current output size as a second "WxH" line (from
current_gamescope_output_size()); the injector scales normalized
client positions into it, keeping raw pixels only as the last resort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit's no-region fallback never fired: gamescope DOES
advertise a region — (0,0,INT32_MAX,INT32_MAX), meaning "coordinates
are raw". Mapping normalized positions into it explodes a center tap
to x≈1e9, clamped by gamescope to the far corner — the cursor pinned
at (1279,799) and every degraded-touch tap landing there. Treat a
region as an output geometry only when plausibly sized (≤16384 px);
otherwise emit raw client pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gamescope's "Gamescope Virtual Input" advertises pointer_abs but no
region, so every MouseMoveAbs (and the degraded-touch moves built on
it) was silently dropped (emitted=false) — clicks then landed at a
stale cursor position. With no region, the managed session runs at the
client's mode, so client pixels are output pixels: emit them raw.
Also: log region count/dims at device-resume, and add
PUNKTFUNK_INPUT_TEST_ABS=WxH to `input-test` (corners + center, 1s
apart) so the degraded-touch path is verifiable with xdotool alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gamescope's EIS ("Gamescope Virtual Input") advertises pointer/
pointer_abs/button but never a touchscreen — so in Game Mode every
remote TouchDown/Move/Up was dropped on the floor (Desktop Mode works
via KwinFakeInput, which does touch). Headless KWin's libei has the
same gap. Instead of dropping: degrade to a single-finger absolute
pointer — down = abs-move + left press, move = abs-move, up = release —
synthesized through the normal Mouse* inject paths so region mapping,
held-state tracking, and release_all apply. First finger drives the
pointer; later fingers are ignored (a pinch degrades to a drag). A
touchscreen device appearing later takes over on the next touch —
checked per event, never latched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified live on a SteamOS 3.8.14→3.8.16 update: the A/B partset switch
rebuilds /etc and drops everything not on Valve's keep list — the udev
rule (uhid access: virtual pads silently degrade to Xbox 360), the
vhci-hcd autoload (native Deck pad transport), and the UDP buffer sysctl
(back to 208 KB → stutter). Valve's sanctioned extension point is a
drop-in under /etc/atomic-update.conf.d/ — itself on the stock keep
list, so it self-preserves. install.sh §4 and update.sh now register
scripts/punktfunk-atomic-keep.conf there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/steamdeck/install.sh (§2b) + update.sh: build and install the
scripting runner user-scoped (~/.local wrapper + pinned bun + bundle) —
SteamOS's read-only /usr can't take the .deb layout, so the console's
plugin store reported "the plugin runner isn't installed" on every
SteamOS host. update.sh retrofits it onto existing installs.
- plugins.rs runner discovery: check the ~/.local layout after the /usr
ones; mention the SteamOS path in the not-installed error.
- update.sh: define warn() — it was used but never defined, so under
`set -e` the first warn aborted the update before services restarted.
- pf-client-core gamepad: a Steam Input virtual pad forwarded on a real
Deck (the only-pad case) now declares the DECK kind in its per-pad
arrival instead of the wrapper's Xbox 360 identity — the session-level
auto_pref already resolved SteamDeck, but the arrival overrides it on
current hosts.
- reject tests: cover SetupFailed; move the foreign-code probe off 0x68.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three fixes for the "most connects simply fail" trap on a SteamOS host
with no physical display (VM testbox, panel-less mini PC):
- restore path: skip the restore-to-physical-panel when no DRM connector
reports a connected display — removing the headless drop-in and
restarting gamescope-session.target on such a box just crash-loops
gamescope and strands every later connect on "no usable compositor".
Keep the headless session (and the takeover state) instead.
- connect path: when no live graphical session exists but the MANAGED
gamescope infra is present (SteamOS gamescope-session / Bazzite
session-plus), route to the gamescope takeover — which rebuilds the
session at the client's mode — instead of failing the connect.
- error delivery: a session-setup failure used to just drop the QUIC
connection, reaching the client as "control stream finished mid-frame"
(indistinguishable from transport trouble). Close with a typed
setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error
text, and give the client handshake/pairing error paths a short grace
for the CONNECTION_CLOSE to beat the stream error it caused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pyrowave-sys's build.rs runs cmake to build the vendored PyroWave C library,
but cmake wasn't in the distrobox apt list — so `cargo build -p punktfunk-host`
dies on every SteamOS host with "is cmake not installed?". Add it.
Also move sudo acquisition from step 4 (after the ~15-min build) into preflight
and keep the timestamp warm across the build. Before, a non-interactive run
(`ssh host 'bash install.sh'`) silently skipped the UDP-buffer/udev/vhci-hcd/
input-group tuning AND enable-linger — a "successful" host with no gamepad
passthrough, lossy streaming, and no headless persistence (dies on logout).
Now sudo is prompted once up front (authorize and walk away), and a no-TTY /
no-password run fails loudly before the build instead of silently at the end.
Validated end-to-end on SteamOS 3.8 in a VM with Radeon 780M passthrough:
full host + web build, all planes listening, VAAPI render node, headless
auto-start via linger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).
Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.
Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.
Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.
Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.
Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI workflow assembled the store zip by hand and silently drifted from
scripts/package.sh when 60d46530 added assets/ and controller_config/ — every
published build (canary and stable through 0.17.1) was missing both, so the
Steam shortcut a fresh install creates had no images and no controller layout
(shortcut_art() found nothing to send; apply_controller_config failed with
template-missing). CI now stages through scripts/package.sh — the plugin file
list lives in one place — and a backstop verifies the runtime-loaded pieces are
in the zip before publishing.
Frontend hardening for installs poisoned by those builds: applyArtwork stamped
its per-appId ART_VERSION marker even when zero assets were applied, so
shipping the files alone would never heal existing shortcuts — only mark done
when something actually landed, and bump ART_VERSION 2→3 to force one re-apply.
Same guard for the controller-config marker (ok with zero account configset
dirs is not done), plus one deferred artwork retry for the fresh-AddShortcut
registration race setShortcutHidden already defers around.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PUNKTFUNK_PIN_CLOCKS clock pin (AMD power_dpm_force_performance_level=high / NVIDIA nvmlDeviceSetGpuLockedClocks) was armed once at host start and released only at host exit, so the box's clocks stayed pinned the whole time the host ran — even with no client connected.
Move the pin behind a box-wide refcount (gpuclocks::session_pin()) shared across both streaming planes (native + GameStream): it arms on the first live client and releases on the last disconnect. on_host_start() now only installs the process-scoped NVIDIA P2-cap application profile. No behavior change when PUNKTFUNK_PIN_CLOCKS is unset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.
- punktfunk-core: dial in a loop until the embedder's connect budget is
spent — short 3 s attempts keep the Initial cadence dense, so the
connect lands within ~a second of the host's network returning. Only
silence is retried: pin mismatches, typed rejections, and any real
answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
probe (hosts-page online pips), not a failed session — log it at
debug instead of WARN "session ended with error", which buried the
real failures in the reporter's log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The system-tuning block (UDP buffers, gamepad udev rule, vhci-hcd autoload,
input group) was gated on `sudo -n true` — passwordless sudo — so on a stock
SteamOS box (the deck account needs a password) the whole block was silently
skipped: /dev/uhid stayed root-only so the gamepad degraded to an Xbox 360 pad,
and the UDP buffers stayed at the 416 KB default. Video was unaffected because
Game Mode/gamescope needs none of it, which masked the skip on-glass.
- install.sh + update.sh: acquire sudo interactively (`sudo -v` prompt on a TTY)
instead of requiring passwordless; skip only when there's no TTY or auth fails,
and then print the exact manual block plus a `passwd` hint — a stock SteamOS
'deck' account has no password, so sudo can't work until one is set.
- update.sh: also retrofit the UDP-buffer sysctl for older/skipped installs, and
loudly nag to reboot when the input group was just added (a --user restart does
not pick up the new group; only a fresh login does).
- steamos-host.md: note this step prompts for the sudo password.
Root is unavoidable: the udev rule, input group, and vhci-hcd module are all
kernel operations with no user-space alternative.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fresh SteamOS host install failed on-glass two ways: KWin denied Desktop-mode
capture (zkde_screencast_unstable_v1) so every session died in the pipeline build,
and the native Steam Deck pad degraded to an Xbox 360 controller. Both stem from
setup that only a fresh login applies — the host user-service keeps the
pre-install group set and KWin reads its .desktop grant at session start — plus a
missing vhci-hcd autoload the pad's USB/IP transport needs (the deb/rpm/arch
packages ship it; the Deck installer did not).
- install.sh: install punktfunk-modules.conf (+ modprobe vhci-hcd now) for the
native Deck controller transport, seed the KDE RemoteDesktop grant
(kde-authorized) for Desktop-mode input, and print a loud "reboot before
streaming" notice when a relogin is actually required (input group added or the
.desktop grant first installed).
- update.sh: retrofit the udev rule, vhci-hcd autoload, input group, and grant so
existing installs pick them up on the next update without a reinstall.
- steamos-host.md: document the first-install reboot and the native controller
passthrough requirements (input group + vhci-hcd + client-side Steam Input Off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The VirtualHere DualSense recipe linked the `virtualhere-dualsense.ts` SDK
example as a deployable recipe but skipped everything needed to run it: that
VirtualHere is a server (couch) + client (host) pair, and that the example —
like every example — imports `../src/index.js`, which only resolves inside the
SDK repo. A user copying it out had no way to know they must
`bun add @punktfunk/host` and swap that import.
- automation.md: rewrite the recipe into a full walkthrough — the two-sided
VirtualHere setup, the `-t` verbs, the zero-code hooks version, and a new
scripted section with exact deploy steps, env vars, and running it as a
service (its own SIGTERM handler makes `systemctl stop` release the pad).
- sdk/README.md: say how to run an example in-repo vs deployed on a host.
- virtualhere-dualsense.ts: header note on the import swap + service setup,
since that file is where the doc link lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swift `Int` is 64-bit (Foundation infers %lld); the stats mirror's format
string used %d (32-bit C int) for fps/presents/lost. macOS 26's strict
String(format:) validator rejects the mismatch and drops the whole line
(the error also cascades onto the float args, mis-blaming floor_p50). Use
%lld for the three Int fields. Pre-existing, unrelated to the DCP work;
surfaced while reading presenter logs during the panic fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The windowed-macOS "mismatched swapID's" @UnifiedPipeline.cpp kernel
panic is the CAMetalLayer ASYNC image queue (commandBuffer.present,
mandatory with displaySyncEnabled=false) diverging from WindowServer's
compositor on a high-refresh composited session — it survived glass
pacing (PyroWave, 2026-07-18) and hit windowed HEVC on the same 240 Hz
Mac Studio (2026-07-21), so it's the async queue itself, at any codec
or pacing.
f407f418's mitigation routed windowed PyroWave through a BGRA8 IOSurface
layer.contents pool, which cannot carry HDR — extending it to HEVC would
have tone-mapped windowed HDR down to SDR. Instead, present the drawable
through a Core Animation transaction (CAMetalLayer.presentsWithTransaction)
for every windowed session: commit, waitUntilScheduled, then drawable.present()
inside a CATransaction, so the swap commits with the layer tree and stays
in lockstep with the compositor instead of racing it. The full
rgba16Float / PQ / EDR render path is untouched — windowed HDR is real
HDR again. Fullscreen keeps the async path (direct scanout, lowest latency,
no panic there).
Removes the IOSurface surface pool, its surfaceLayer sibling, and the
macOS windowed PQ->SDR tone-map (tvOS keeps its no-headroom tone-map).
Net -150 lines. swift build + 169 tests green. Needs on-glass soak on
the 240 Hz Mac Studio (kernel race — only real hardware confirms).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip both zero-copy levers from opt-in to default:
- The capture negotiation now PREFERS gamescope's producer-side NV12 pod by
default (PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation).
Codec-aware gating rides a new OutputFormat::nv12_native ->
ZeroCopyPolicy::native_nv12_session edge resolved by the host facade from
the session plan (pf_encode::linux_native_nv12_ok): only H265/AV1 sessions
whose backend can open the raw Vulkan Video encoder ever see the NV12 pod
-- an H264/Moonlight session (libav VAAPI, which would misread the
two-plane buffer) keeps today's BGRx negotiation, as do the
GameStream-resolve and portal-mirror paths, PyroWave, and NVENC prefs.
- RGB-direct's unaligned modes default to the true-extent direct import
(PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded-copy staging).
Guarded-tested on Van Gogh with the kernel journal watched: clean, and at
5.38 ms p50 the fastest 1080p encode path measured on that hardware. The
EFC only exists on Mesa >= 26, where the codedExtent-driven session_init
padding is guaranteed (verified back to Mesa 24.2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opt-in alternative to RGB-direct's padded-copy staging at unaligned modes:
direct-import the visible-size capture and declare the TRUE-SIZE source
codedExtent. RADV has derived the VCN session_init firmware padding from
srcPictureResource.codedExtent since Mesa 24.2, so the EFC is told the source
lacks the alignment rows and the hardware edge-extends them internally --
which also reframes the 2026-07-20 field GPU reset: that crash passed the
ALIGNED extent as the source codedExtent (firmware padding zero) over a
visible-size buffer. Off by default until a guarded live test proves the EFC
front-end honors the padding like the YUV fetch path does; the padded-copy
staging remains the shipped behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the padded-copy staging for producer-native NV12 and direct-import the
visible-size buffer at unaligned modes (1080p) too. Safe by the driver's own
contract rather than by allocation luck: native sessions now author the H265
SPS / AV1 sequence header at the RENDER size and pass the matching codedExtent
on every picture resource. RADV rounds the bitstream SPS up itself (with a
conformance window -- radv_video_patch_encode_session_parameters, per the
VK_KHR_video_encode_h265 proposal's "implementations may override" clause) and
programs the VCN session_init with the true extent plus nonzero firmware
padding, so the hardware edge-extends the alignment rows internally and never
fetches past the source's real extent -- the exact mechanism VAAPI/radeonsi has
always used to encode 1080p. The driver-emitted header NALs already carry the
patched SPS, so the wire format is unchanged.
The CSC and RGB-direct paths keep the app-aligned convention (their sources
genuinely cover the aligned extent); RGB padded-copy staging is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Software decode uploads swscale RGBA with no CSC/tonemap pass. On the new
desktop-compositor HDR10 surfaces (GNOME 48 / Plasma 6 + Mesa >= 25.1 offer
ST2084 even on SDR desktops) that sRGB-encoded content was composed as PQ —
the field-reported psychedelic picture (Fedora clients decode HEVC in
software because stock Mesa strips it; reproduced + fix live-validated on
GNOME Wayland: mode-0 soup -> HDR→SDR washed-out-but-correct).
The CPU lane keeps the SDR swapchain and its known untonemapped-PQ gap; a
real PQ→sRGB pass for CPU frames is the follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDL_APP_ID = io.unom.Punktfunk — compositors match it against the shipped
.desktop for the window icon; without it KWin/mutter show the generic Wayland
icon (field-noticed on the Deck). Linux analog of win32 AppUserModelID.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GNOME 48 / Plasma 6 with Mesa >= 25.1 now offer HDR10_ST2084 surfaces even on
SDR desktops, silently flipping PQ streams into the mode-0 passthrough lane —
previously unreachable on desktop compositors and never validated there. The
hatch pins the shader tonemap for diagnosis and as the field escape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native QSV encoder only attached mfxExtVideoSignalInfo on HDR sessions —
an SDR stream carried no colour description at all (NVENC writes 709-limited
unconditionally; qsv.rs now mirrors it).
Diagnostics for the field-reported blue/magenta Intel-host colours:
- hdr-p010-selftest takes an optional WxH + GPU vendor (intel|nvidia|amd) and
prints which adapter it tested — 64x64 on the default GPU proved nothing on
dual-GPU boxes, and the field heights (1080/1400) are not 16-aligned.
- pf-capture: ignored live test running the converter at 1920x1080 pinned to
the Intel adapter.
- pf-encode: qsv_live_p010_1080_colorbars_dump — known P010 bars through the
UNALIGNED-height ingest copy (1080 src -> align16 1088 pool, the seam no
640x480 test exercises) to Main10 HEVC, dumped for off-box decode checks.
- dxgi.rs: drop the stale 'falls back to the R10 path' claim (no such path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With PUNKTFUNK_PIPEWIRE_NV12=1 (bring-up gate), the PipeWire negotiation
offers an NV12 LINEAR DMA-BUF pod (BT.709 limited pinned MANDATORY) ahead
of the BGRx one, and gamescope's producer-side RGB->YUV pass replaces the
host CSC entirely: the encoder imports the two-plane buffer as a profiled
VIDEO_ENCODE_SRC image and the VCN encodes it directly. Contributed
measurements: encode p99 2.9 ms (from ~4-4.5 ms via EFC RGB-direct),
60 fps capture, 0 send drops.
Hardening on top of the contributed patch:
- unaligned modes (1080p!) stage through a padded aligned NV12 copy (edge
rows/columns duplicated, transfer-only) instead of direct-importing the
visible-size buffer -- a direct import would make the VCN read past the
producer allocation, the exact OOB class behind the 2026-07-20 field GPU
reset; the encode extents return to the aligned coded extent everywhere
- the UV plane layout honors the producer's plane-1 chunk (offset/stride)
when the SPA buffer carries one (same-BO verified by inode), with the
contiguous-plane contract as fallback
- PyroWave sessions are excluded from the gate (their Vulkan compute CSC
ingests packed RGB), and a native-NV12 session that resolves to libav
VAAPI (H264 codec, PUNKTFUNK_VULKAN_ENCODE=0, feature off, or a failed
Vulkan open) refuses at open instead of streaming garbage chroma
- pad staging images carry TRANSFER_SRC (the width-padding pass self-copies
the staging image -- previously missing on 1366-wide modes)
- metadata-cursor one-shot warn (parity with RGB-direct) and a padded-NV12
PUNKTFUNK_PERF split label; the padded RGB copy gets its timestamps too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
settle_desktop_portal pushes the live desktop's WAYLAND_DISPLAY into the
systemd --user manager (import-environment) so a re-activated portal inherits
the right session — but the import PERSISTS after that desktop dies. Every
later user unit then inherits the stale socket, including
gamescope-session.target: gamescope sees WAYLAND_DISPLAY, runs NESTED, and
aborts with "Failed to connect to wayland socket: wayland-0" — observed live
on a Deck, where Game Mode could not start at all (autologin crash-looped)
until the var was unset by hand.
Two-layer fix, mirroring the protection launch_session already gives its
transient unit:
- observe_session_instance: when a desktop compositor instance goes away,
unset WAYLAND_DISPLAY/DISPLAY from the manager env (a desktop bounce is
harmless — the next portal settle re-imports).
- write_steamos_dropin: UnsetEnvironment=DISPLAY WAYLAND_DISPLAY on the
takeover drop-in, unit-scoped belt-and-suspenders for host-initiated
restarts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Observed live on a Deck host: the user switched Game Mode → Desktop, KDE came
up in 0.8 s — and the capture-loss rebuild's FIRST detection ran inside that
gap, still said Gaming, and its gamescope re-acquire restarted
gamescope-session.target. On SteamOS that steals the seat: the user was
yanked out of the KDE session they had just chosen, the two session managers
fought (job canceled / dependency failed), no gamescope node appeared within
30 s, and the stream died at the 40 s rebuild budget.
A rebuild probe acting on possibly-stale detection must never stop, relaunch,
or take over box sessions. New pf_vdisplay::rebuild_probe_scope (RAII,
counted): while held, the gamescope managed/takeover create paths only attach
to a live node and fail fast otherwise — no stop_autologin_sessions, no
session-plus relaunch, no gamescope-session.target restart. The capture-loss
loop holds it for the first 4 s after a loss (the detection-ambiguity
window); after that, destructive rebuilds are allowed again, so a genuine
switch INTO Game Mode still gets its headless takeover. Probe failures are
instant, so the loop now paces itself at ~2 Hz instead of spinning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two session-transition stalls found live on a SteamOS Deck host, one cause:
the pipeline retry loop couldn't tell a wait-it-out failure from a
retry-now-and-it-works one.
- Gamescope first-frame race: a PipeWire stream connected while gamescope
re-initializes its headless takeover negotiates a format, reaches
Streaming, and never receives a buffer — while a fresh connect delivers
within ~0.5 s. Every gamescope bring-up ate the full 10 s first-frame
budget on attempt 1 (17 s bring-ups; KWin: 1.2 s). The retry loop's FIRST
attempt now waits 2.5 s (Capturer::next_frame_within), so the reconnect
that fixes the race happens at ~3 s. Later attempts keep the patient 10 s
— the documented 30-60 s Big Picture cold start still fits the budget.
- Capture-loss rebuild vs session switch: the rebuild loop re-detects the
active session between build_pipeline_with_retry calls, but each call
burned 8 attempts (~13 s) against a compositor that no longer exists (a
Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin). The
capture-loss path now passes max_attempts=2, turning the outer loop into
~1 s detect-and-retry cycles that follow the box to the new session.
The resize path's direct build_pipeline call keeps the default budget (no
retry wrapper there to absorb an early bail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SteamOS scripts built the host featureless, so PlatformAuto could never
pick the Vulkan Video backend (default-on since 84329205) and every session
silently ran libav VAAPI — unlike the deb/arch/rpm/sysext builds, which all
pass the feature. Three gaps, found live on a Deck host:
- install.sh/update.sh: cargo build with --features punktfunk-host/vulkan-encode
(matches the packaged builds; pure-Rust ash, no new system dep).
- host.env: RADV_PERFTEST=video_encode — Van Gogh RADV still gates
VK_KHR_video_encode_* behind it, so without it the backend can't open and
falls back to VAAPI. Harmless where encode is exposed by default.
- io.unom.Punktfunk.Host.desktop (Exec rewritten to this install's binary):
KWin only grants zkde_screencast_unstable_v1/org_kde_kwin_fake_input to an
exe a .desktop authorizes — without it Desktop-Mode capture fails outright
and a mid-stream Game↔Desktop switch strands the stream on the old backend.
update.sh retrofits the last two idempotently so existing installs get them
without a reinstall.
Validated on a SteamOS 3.8.14 Deck (Van Gogh): Vulkan HEVC opened on both the
KWin and gamescope paths at 5120x1440 — in-place ABR reconfigure (no IDR, no
encoder rebuild) vs the VAAPI path's full teardown per ABR step, and Desktop
capture came up without a re-login.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<CardContent className="p-0">` never removed the padding it looked like it
removed: tailwind-merge resolves conflicts within a variant, so `p-0` cancels
`p-4` but leaves `sm:p-6` standing. Every call site that used it nests a
CardHeader inside, which brings its own `sm:p-6` — so at >=640px the title sat
24px further in than its neighbours'. Visible on the Plugins page as 'Installed
plugins' not lining up with 'Plugin runner'.
Give CardContent a `flush` prop that omits the padding outright, and use it at
the three call sites (Store, Pairing, Stats) rather than fixing the symptom
three times and leaving the footgun in place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard checked the package NAME shape, and `@punktfunk/plugin-kit` — the
framework every kit-built plugin depends on — matches `@scope/plugin-*`
exactly. Windows on-glass accepted an uninstall of it; bun no-ops removing a
non-dependency so nothing broke, but the store was offering to pull a library
out from under the plugins using it.
Require membership in the top-level dependency list, the same authority that
already keeps plugin-kit out of the installed listing. Same blind spot, two
call sites — the listing was fixed during implementation, this one wasn't.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things looked wrong in the store:
The cards had no space above the icon. `CardContent` zeroes its own top padding
(`pt-0`/`sm:pt-0`) because it normally sits under a `CardHeader` that supplies
it — but these cards have no header, so the top padding simply vanished while
the other three sides kept `p-card`. `p-card` does not undo it: `card` is a
custom `--spacing-*` token, which tailwind-merge does not recognise as a
spacing value and so never dedupes against `pt-0`, leaving the longhand to win.
Both header-less cards in this section now restore it explicitly, at both
breakpoints.
Platform chips read "windows" / "linux". Those are the catalog's platform
IDENTIFIERS (the index validator pins `linux | windows | macos`), which is right
for the wire and wrong on screen; the card now maps them to display names —
Linux, Windows, macOS. Proper nouns, so deliberately not routed through i18n.
Adds Store/StoreCard stories covering the padding, the platform labels, and the
installed / update / incompatible / blocked / external states, so the card can
be eyeballed without a host or a catalog.
Gates recorded (nvenc-subframe-slice-output.md Phase 3): loss-harness curve
identical streamed-vs-whole; adversarial security review clean; live .21
3-leg A/B — streamed 0 corrupted frames, p99 8527→5363 µs vs sliced-whole,
first_slice_us ~500 µs of a ~1250 µs encode reaching the send thread; wire
bytes rate-governed under CBR + 1-frame VBV (the ~1-2 % slice-header cost is
absorbed by rate control, not added to the wire). GameStream: plane has zero
code diff (own RTP packetizer; shares only untouched send_pacing); a live
Moonlight re-test joins the standing owed Moonlight item.
- resolve_slices(codec, default): env 1..=32 wins (1 = the NEW explicit
single-slice escape), else the backend default — 4 on Linux direct-NVENC,
1 elsewhere. resolve_subframe(default_on): PUNKTFUNK_NVENC_SUBFRAME
tri-state (0 = never, 1 = force, unset = backend default).
- Linux nvenc_cuda: resolves both ONCE in query_caps — the sub-frame default
is gated on the GPU's SUBFRAME_READBACK cap so an unsupporting GPU never
has enableSubFrameWrite forced into its init params — and feeds the same
resolved values to build_config, build_init_params (open AND in-place
reconfigure) and the chunked-poll latch.
- Windows: env-only as before (async path untouched, byte-identical config
for unset env); LowLatencyConfig.slices + the explicit subframe param
replace the shared env reads.
- Tests: chunked e2e now runs at the DEFAULTS (no knobs); the fallback test
becomes the escape test (SLICES=1 disarms; SUBFRAME=0 disarms while
4-slice encode continues on the plain poll path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slipped through 421e8112: the on-box clippy runs covered punktfunk-core and
punktfunk-host but not the tools crates; CI's workspace-wide pass caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bootstrap key generated during implementation was lost, and its
replacement was pasted into the wrong repository's secret store — Gitea
secrets neither cross repos nor read back, so neither is usable. The private
half of this one lives in punktfunk-plugin-index's own INDEX_SIGNING_KEY,
which is the only thing that signs the catalog.
Nothing has shipped pinning any of these, so this is a straight replacement
of slot 0; the second slot stays reserved for a genuine rotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B2 default-on (design/vulkan-rgb-direct-encode.md): wherever the probe
passes, Vulkan Video sessions now take the EFC RGB source by default —
direct import on aligned modes, padded-copy staging on unaligned ones
(1080p). PUNKTFUNK_VULKAN_RGB_DIRECT becomes the override: =0 disables,
=1 forces, unset = the default below.
The one session class that must NOT default on: cursor-as-metadata
captures (every non-gamescope compositor), where the CSC shader's blend
IS the visible pointer — the EFC cannot composite, and defaulting there
would silently drop the cursor from the stream. The hint rides the
existing plumbing:
- SessionPlan gains cursor_blend, resolved once where the compositor is
known (gamescope embeds the pointer itself → false; kwin/mutter/
wlroots/hyprland → true), and shows up in the logged plan line.
- open_video/open_video_backend thread it through (native pump: all
three encoder-open sites read plan.cursor_blend; GameStream monitor
capture: true — it negotiates metadata cursor; spike: false).
- VulkanVideoEncoder::open resolves: env override, else ON iff the
session never hands us cursor bitmaps. The warn-once for a cursor on
an RGB session (forced via =1) stays.
Verified on-hw box (Linux): pf-encode + punktfunk-host compile, clippy
clean, unit suite green. The GPU paths themselves are unchanged from the
smoke-validated 96e19986 — this commit only changes which sessions
select them by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
one (defense-in-depth — the geometry invariants make it unreachable
today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
and second-final-with-different-totals rejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Phase-2 gate: more, smaller wire units must not regress FEC recovery.
Adds a GF16 streamed column (three chunk pushes + finish per AU — sentinel
blocks then real totals). Measured: identical to whole-AU at every loss
rate (50/50 through 1/6, the same 25%-budget cliff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The probe's reassembler is the shared core one, and the probe is the tool
that measures the streamed-AU overlap win — advertise the cap so a host
with chunked encode streams to it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.
Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
received sentinel block is out of range or not full-K kill the frame
wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
never the last block the limits allow, no total to lie about); the exact
derived-geometry check runs unchanged on every non-sentinel packet and
retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.
Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.
Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The encoder half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 1): with PUNKTFUNK_NVENC_SLICES=N +
PUNKTFUNK_NVENC_SUBFRAME=1 on a sync depth-1 session, Encoder::poll_chunk hands
the in-flight AU out as slice-boundary chunks read through doNotWait sub-frame
locks while the tail is still encoding — the readback loop the on-hw probe
validated (~200 µs slice spacing on the 5070 Ti), productionized.
- codec.rs: AuChunk (AU metadata on the first chunk, `last` closes the AU;
chunks concatenate to exactly the poll() bytes) + supports_chunked_poll /
poll_chunk trait surface. Default impl wraps poll() as one self-closing
chunk, so a chunk-driven consumer works against every backend.
- nvenc_cuda: chunked readback cut at slice boundaries only (bitstream size at
n reported slices = end of slice n, Annex-B contiguous); completion is NEVER
numSlices alone — one finishing BLOCKING lock is the authority and the wedge
watchdog, so the final chunk blocks exactly like sync poll (the depth-1 pump
contract; 6dc195f9 bug class). Keyframe on early chunks is the submit-time
IDR prediction (exact under P-only/infinite-GOP), cross-checked at finish.
Debug builds shadow-check emitted chunks against the finished AU prefix.
Mutually exclusive with pipelined retrieve (gated off when async_rt exists,
dropped by the escalation rebuild); composes with stream-ordered submit.
- nvenc_core: slices_env/subframe_requested shared parses so the config author
and the chunked-poll arming can't disagree.
- TrackedEncoder forwards both new methods (the set_wire_chunking trap class).
Host loop untouched — Phase 2 (VIDEO_CAP_STREAMED_AU seal/send) consumes this.
On-hw: nvenc_cuda_chunked_poll_end_to_end + nvenc_cuda_chunked_poll_fallback_whole_au.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every session encoder is boxed in TrackedEncoder (open_video), and the
wrapper never forwarded Encoder::set_pipelined — so the host loop's
contention escalation (ae673158) hit the trait default, which returns
false ("backend can't pipeline, stop asking"), and the pipelined-retrieve
stage could never engage adaptively. Only the explicit
PUNKTFUNK_NVENC_ASYNC=1 open-time path worked. The exact defaulted-method
trap the wrapper's own comment documents for set_wire_chunking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.
Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.
Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
own framework: it listed as installed and would have been imported as a
unit. Both now key off the plugins dir's top-level dependencies, with an
emptied dependency list meaning 'nothing installed' rather than falling
back to the naming convention
- the store must not pass new flags to the runner: the scripting package
ships separately and an older one reads an unknown flag's value as a
package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
as Ok with an empty body — handled as an error it made every refresh
after the first verify a signature over zero bytes and sit stale
Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.
- store/index.rs signed index parse + ed25519 verify (ring), validate-and-drop
per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs single-flight install/uninstall: registry-integrity preflight
against the pin, spawn the runner CLI with live log capture,
post-install version check with rollback, provenance record,
runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs 12 routes under /api/v1/store, denied to the plugin token
(a plugin that can install plugins is an escalation primitive)
Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts 3aacec53's alignment gate into a mode select: an aligned mode keeps
the true zero-copy direct import; an unaligned one (1080p!) now blits the
visible frame into a per-slot ALIGNED BGRA staging image and duplicates
the edge rows/columns into the 64x16 padding — transfer-only regions in
one vkCmdCopyImage (1080p: visible + 8 row regions; width padding adds a
second self-copy pass in GENERAL), no compute shader. The encode reads
the staging image, never the capture buffer, so the EFC can never read
past a producer allocation (the field GPU hang). Still one ~8 MB copy vs
the CSC path's ~17 MB + dispatch + plane copies. Verdict line:
active(padded-copy). The staging import drops the video profile entirely
(TRANSFER_SRC only).
CPU-payload paths made honest on the way (they were the smoke baseline
AND the software-capture fallback):
- rgb mode: the staging upload is padded CPU-side (edge duplication) so
the aligned encode-src is fully defined;
- CSC mode: the sampled image is now SOURCE-sized with a matching copy
extent — the old aligned-size image + tightly-packed buffer sheared
rows and left garbage rows at unaligned modes (black-bar artifacts;
YMIN=16 in every smoke frame), which also masked as a 24 dB PSNR
'regression' against the (correct) padded output.
On-glass (780M, host Mesa 26.0.4): all four smokes pass at 256x256
(direct) AND 250x250 (padded); padded-EFC frames decode perfectly
uniform (YMIN==YMAX) at the exact 709-narrow values (79/148/60 for the
first three fills); CSC-vs-padded PSNR 49.9 dB avg after the baseline
fix. clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field report (commit 25624443, RDNA2/VCN3 host, 1080p): repeated VCN0 VM
protection faults from punktfunk-host, vcn_enc_0.0 ring timeouts, two ring
resets, then a failed reset escalating to a full MODE1 GPU reset with VRAM
loss. Root cause is B1's RGB-direct source: the session's coded extent is
64x16-aligned (1920x1088) but the captured dmabuf is only the real mode
size (1920x1080) — the VCN EFC reads the 8 padding rows past the end of
the buffer (8 x 7680 B = 61 KB, matching the faulting page span exactly).
The CSC shader absorbs alignment by clamping reads and duplicating edges;
RGB-direct has no such stage. Every prior validation surface happened to
be aligned (256x256 smoke, 1440p live box) — 1080p is the common mode
that is not. The stall watchdog's rebuild-and-refault loop then turned a
per-session fault into the GPU death spiral.
Two layers:
- Open-time gate: RGB-direct engages only when the real mode equals the
aligned coded extent (720p/1440p/4K yes, 1080p no); otherwise the CSC
path runs and the verdict line says why (unaligned-mode). The padded-
copy variant that re-enables 1080p is design-doc B2 work.
- Submit-time check: an RGB-direct frame that does not cover the coded
extent errors out (encoder-rebuild path) instead of importing an
undersized source.
On-glass (780M, host Mesa 26.0.4): all four smokes pass aligned; at
PF_SMOKE_W/H=250 the gate refuses RGB-direct and the rgb smokes soft-skip.
clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CardContent is shadcn's header-adjacent variant (`p-4 pt-0 sm:p-6 sm:pt-0`),
but the four Live-status tiles use it with no CardHeader. tailwind-merge lets
the call site's unprefixed `p-4` cancel the base `pt-0`, yet nothing cancels
`sm:pt-0` — so the tiles lost their top padding at >=640px only, and the
content sat against the top edge. Restores the top inset at the sm breakpoint
and lets the content fill the stretched grid cell so it stays centred when a
sibling tile is taller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RGB→NV12 compute CSC shares the SMs with the game; request an
elevated global-priority queue (VK_KHR/EXT_global_priority) so the
driver can schedule it ahead. PUNKTFUNK_VK_QUEUE_PRIORITY =
off|high|realtime (default realtime), downgrading REALTIME→HIGH→none
on NOT_PERMITTED/INITIALIZATION_FAILED — a refused class never fails
the bridge. Mirrors PyroWave's ac0e7332 (same honest caveat: WDDM
didn't move; the Linux driver may — contended-tail lever, correct and
harmless either way).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_NVENC_ASYNC gains a real tri-state: 1 = always (as before, now
documented as ~+1 tick at depth-1), 0 = never (vetoes escalation), unset =
ADAPTIVE — off until the session loop's cadence-overrun detector escalates.
The host loop's adaptive-depth leaky bucket grows a second stage: once the
capturer's depth is maxed (Linux portal is permanently depth-1), it asks
the encoder for pipelined retrieve via the new Encoder::set_pipelined hook
(asked exactly once; default impl declines, Windows untouched).
nvenc_cuda engages at a safe point via a clean session rebuild WITHOUT the
IO-stream binding: with input==output stream bound, later stream work
waits on prior encode completions and would serialize a pipelined session
— stream-ordered submit and two-thread retrieve are mutually exclusive.
The ordered gate now also requires async_rt absence (belt-and-braces for
the runtime switch). Re-open's first frame is the standard session IDR.
On-hardware test: escalate mid-session → retrieve thread live, binding
gone, all AUs deliver, first post-escalation AU is the IDR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
design/vulkan-rgb-direct-encode.md B1: when the probe passes AND
PUNKTFUNK_VULKAN_RGB_DIRECT=1 (default OFF until the B2 on-glass A/B),
the session opens with pictureFormat=B8G8R8A8 and the captured RGB frame
becomes the encode source — the VCN EFC front-end does the 709-narrow CSC
inline during encode. Deleted from the per-frame path: the compute CSC
dispatch, both NV12 plane copies, the semaphore hop and one queue submit
(dmabuf frames are ONE encode-queue submit; ~17 MB/frame of GPU traffic
gone). The DPB stays NV12; RFI/RC/quality-level machinery is untouched.
Shape:
- probe_rgb_direct now returns the chroma-siting bits to create the
session with (midpoint preferred, else cosited-even per axis); the open
verdict line gains "active" / "available(off; ...)" states.
- RgbProfileStack: the rgb-chained video profile rebuilt on the stack per
profiled-image creation after open (profile identity is by value) —
dmabuf imports become profiled VIDEO_ENCODE_SRC images (import cache
unchanged), the CPU staging image likewise (concurrent encode+compute).
- record_submit_rgb: steps 2–4 twin (dmabuf: single submit; CPU: staging
copy on the compute queue, semaphore-ordered); shared step-1/bookkeeping.
- begin_encode_cmd takes a SrcAcquire (CSC general / fresh-import FOREIGN
QFOT / cached visibility-only / staging TRANSFER_DST) so both paths
share the encode recording.
- RGB-direct frames skip the CSC per-slot resources entirely (make_frame
split into csc/common halves); cursor bitmaps warn once (EFC cannot
composite; gamescope — the flagship — embeds the cursor itself).
On-glass (780M RADV PHOENIX, host Mesa 26.0.4): all four smokes pass
(vulkan_smoke{,_av1,_rgb,_rgb_av1}); the EFC-encoded H265 stream decodes
clean and matches the CSC-encoded stream at 49.9 dB average PSNR (min
48.9) — within the design's ±1-code-value tolerance. (Raw-OBU ffmpeg
probing of the tiny AV1 dumps fails identically for BOTH paths —
pre-existing dump quirk, not a stream defect.) check+clippy+full unit
suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
servePluginUi's Bun.serve leaves idleTimeout at Bun's 10s default, so the old
15s keepalive could never arrive — an idle status feed was closed before its
first ping, which is exactly what a live host showed. Default is now 5s.
Also adds the regression suite that should have caught it: the original test
only covered a finite, self-driving stream. The new one exercises the
production shape (PubSub-backed, published after the response opens) plus the
keepalive — with a reader that does not re-enter read(), which is what made the
first version of this test lie.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@unom/ui rings cards ring-2 ring-accent, sized for its own subtle accent; in
this palette --accent is the brand violet, so every card shouted. The console
already softens the same ring in its Card wrapper — plugin UIs now inherit that
instead of each wrapping AnimatedCard themselves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_NVENC_SLICES=N (2..=32, default off) splits H.264/HEVC frames
into N slices (sliceMode 3); PUNKTFUNK_NVENC_SUBFRAME=1 (default off)
arms enableSubFrameWrite + reportSliceOffsets on sync sessions only.
Both experimental groundwork for sub-frame slice output (plan §7 LN1).
nvenc_cuda_subframe_slice_probe (on-hardware, ignored) answers the LN1
go/no-go: spins lock_bitstream(doNotWait) against an in-flight frame and
prints the (t_us, status, numSlices, bytes) timeline — incremental slice
availability and its spacing, or all-at-completion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass B0 result from the 780M (RADV PHOENIX, host Mesa 26.0.4): the
probe returned no-709-narrow-midpoint because RADV advertises
xChromaOffsets = COSITED_EVEN only — the VCN EFC does canonical H.26x
left-cosited x-chroma, while the requirement was written to bit-match our
2x2-average (midpoint) shader. The delta is a half-pel chroma-x phase,
imperceptible and unsignalled in our bitstream; EFC's siting is arguably
the more correct one. Accept either siting bit per axis (model + range
stay exact: 709 narrow); B1 will pass the preferred available bit.
With this the 780M probes rgb_direct=available; both GPU smoke tests
(H265 + AV1) pass on that box against host Mesa 26.0.4 — the first
real-RADV validation of the poll fix, the quality-level control, and B0.
(Container mesa alone can't run the H265 smoke: Fedora ships RADV with
H264/H265 encode compiled out — AV1 only. Use the host ICD via
VK_ICD_FILENAMES on Atomic boxes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@unom/ui reads --secondary as muted TEXT; this palette (like the console's) uses
it as a dark SURFACE, so EmptyState captions, badge text and table headers
rendered dark-on-dark. Tailwind derives both utilities from one token, so the
text lane is re-pointed at --muted-foreground here — once, for every plugin,
instead of in each plugin's stylesheet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bind the session's IO streams to the encode thread's high-priority copy
stream: in sync-retrieve depth-1 use the per-frame input copy and cursor
blend now enqueue with NO cuStreamSynchronize and encode_picture orders
after them on the stream. Same stream both directions, so the encode's
completion is inserted into the stream and later work (the next frame's
copy into a reused ring slot) waits for it.
Soundness gate: the fast path engages only when pending is empty (true
depth-1 usage) — every prior encode was drained by a blocking poll, and
the caller holds the frame payload across the matching poll (contract now
documented on Encoder::submit; both host loops already comply). Pipelined
callers and PUNKTFUNK_NVENC_ASYNC mode keep the blocking copies.
True zero-copy input registration (registering the worker-owned IPC
buffer directly) stays the LN2 v2 follow-up — it needs a contiguous
worker-pool NV12 layout and a registration<->IPC-mapping lifetime tie.
PUNKTFUNK_NVENC_STREAM_ORDERED=0 restores the old blocking behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Latency plan §7 LN0 (Linux/NVIDIA encode follow-on):
- sampled PUNKTFUNK_PERF submit split (copy/blend/map/pic) in nvenc_cuda —
the host loop's submit_us folds all four together; the D2D input copy is
the LN2 zero-copy target and now measurable on its own
- sampled blocking lock_bitstream timing on pf-nvenc-out — in two-thread
mode the host loop's wait_us wraps a non-blocking poll, so the real
encode wait was measured by no timer
- caps probe + log SUPPORT_SUBFRAME_READBACK / SUPPORT_DYNAMIC_SLICE_MODE
(LN1 sub-frame slice-output prerequisites, fleet visibility)
- explicit zeroReorderDelay=1 in the shared low-latency config (P-only +
no lookahead has no reordering anyway; pins the bit against preset or
driver drift; shared with the Windows backend)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First phase of design/vulkan-rgb-direct-encode.md (punktfunk-planning): make
the captured BGRx dmabuf the direct encode source with the VCN EFC front-end
doing the 709-narrow CSC — deleting the per-frame compute CSC, both plane
copies, the semaphore hop and one queue submit.
B0 changes nothing about the encode path. It vendors the extension surface
(vk_valve_rgb.rs — ash 0.38 predates it; same rationale and style as the
vk_av1_encode module) and probes at open whether this host qualifies:
extension present (Mesa >= 26.0 + EFC hardware) → feature bit → conversion
caps cover the compute shader's exact math (709 / narrow / midpoint both
axes) → encode-src format set offers B8G8R8A8 with DRM-modifier tiling.
The verdict is one INFO line (rgb_direct=available | first missing
requirement) — the field telemetry that decides where B1 can default on.
Verified: cargo check + clippy clean + unit tests green (Linux box); the
probe short-circuits to no-ext on non-RADV drivers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field follow-up to 6dc195f9 (the AMD host whose H265 encode sits at ~5 ms):
the remaining time is the VCN ASIC preset, and we never chose one.
1. Explicit encode quality level. RADV only emits a VCN preset op when the
app issues ENCODE_QUALITY_LEVEL — we never did, so every session ran the
firmware's default preset. Install the resolved level on the first
frame's RESET control (chained ahead of the CBR state, which satisfies
the spec's quality-change-carries-rate-control rule) and bake the same
level into the session-parameters object (the spec requires the match).
Default 0 = the fastest tier the driver exposes (RADV: SPEED, except
H265 on pre-RDNA4 which the driver pins to BALANCE); clamped to the
profile's maxQualityLevels. PUNKTFUNK_VULKAN_QUALITY=0..3 overrides for
quality-biased setups. Logged at open so field logs show the tier.
2. Persistent bitstream mapping. read_slot vkMapMemory/vkUnmapMemory'd the
host-visible bitstream buffer every frame; map each ring slot once at
build instead (coherent memory; vkFreeMemory implicitly unmaps).
3. PUNKTFUNK_PERF CSC split. GPU timestamps bracket the compute batch
(import barriers + cursor prep + CSC dispatch + plane copies) and a
sampled csc_us line logs every ~2 s — separating shader/copy time from
the ASIC encode inside the pump's wait_us, the VAAPI submit-split
analog for this backend. Off (and unrecorded) unless PUNKTFUNK_PERF.
Verified: cargo check + clippy + DPB/RPS unit tests green (Linux box).
The GPU smoke tests still need a RADV host (NVIDIA driver fails session
open on the build box, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session pump's depth-1 loop (capture → submit → poll) treats a poll()
None as "the backend holds the frame internally, re-poll next tick" —
true only of the libav AMF/QSV wrappers, whose codecs genuinely hold ~2
frames. The Vulkan Video backend probed its slot fence with
get_fence_status instead, so the AU of every frame — finished on the
ASIC in ~5 ms — sat unharvested until the tick AFTER the next frame was
submitted: encode_us read as one full frame period (~17 ms at 60 Hz vs
VAAPI's ~5.3 ms on the same VCN, the AMD field report) and every frame
shipped a frame period late — one frame of avoidable glass-to-glass
latency on the path that exists to beat libav VAAPI.
Wait the oldest in-flight slot's fence in poll() instead, matching the
sync NVENC backend's blocking lock_bitstream and the documented
Capturer::pipeline_depth contract ("capture → submit → poll-blocks").
Bounded by ENCODE_FENCE_TIMEOUT_NS like enqueue()'s backpressure wait,
for the same reason: poll runs on the thread the stall recovery runs on,
so a wedged GPU must surface as an error, not park it. None now only
means "nothing submitted". Covers H265 and AV1 (shared poll).
Verified: cargo check + DPB/RPS unit tests green (home-worker-5); the
GPU smoke tests fail at session open on that box's NVIDIA driver
identically on unmodified origin/main — pre-existing, needs the RADV
box for on-glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plugin contracts share ProviderEntry/Artwork/LaunchSpec/PrepStep with their
UIs; the kit root stays node-only (reconcile keeps the HostClient transport).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`web/project.inlang/settings.json` pointed `modules` at
https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js —
inlang's stock `paraglide-js init` scaffolding, which nobody had a reason to
keep. It made `bun run codegen` need the network at build time, so in a
sandbox that has none (the Nix build; any air-gapped CI) the plugin import
failed and paraglide compiled ZERO messages.
That failure is silent by design: the inlang SDK logs a PluginImportError as a
WARNING, paraglide still prints "Successfully compiled" and exits 0, vite
bundles the empty message set, and the console only dies at request time inside
renderToReadableStream because every `m.foo()` is undefined. Reported from a
NixOS host running punktfunk-web-server.
The plugin is a normal npm package, so depend on it like one: add
@inlang/plugin-message-format as a devDependency and reference it by relative
path. `loadProjectFromDirectory` (which both `paraglide-js compile` and the
vite plugin use) imports non-`http` modules straight off disk, and the shipped
dist/index.js is a self-contained bundle with no imports of its own, so it
loads from the data: URL exactly as the CDN copy did. Compiles 268 messages
offline, with no project.inlang/cache round-trip.
Add tools/check-i18n.mjs, run at the end of `codegen`, so this can never ship
silently again: it fails the build on a remote `modules` entry, a module that
does not resolve, or a compiled message count below what messages/en.json
defines. Verified it catches all three (remote URL, missing plugin, corrupt
plugin that compiles to 0 messages) and passes a healthy build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite),
extracted as one Effect-v4 package:
- runtime: definePluginKit — async-main boundary hiding a ManagedRuntime
(two-effect-instances discipline), signal-driven interruption with a
bounded shutdown grace
- config: Schema-driven raw round-trip (defaults only in the Schema via
withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape),
atomic writes, world-writable refusal, changes stream
- cache-store: disposable derived state, corrupt→empty, write-through
- reconcile: kit-owned provider wire schemas + typed client over the
skew-safe untyped pf.request seam
- sync-engine: generic poll/watch/debounce/single-flight-coalesce/
fingerprint-skip engine with a status PubSub (the SSE feed)
- ui-server + sse: effect/unstable/httpapi behind servePluginUi with
core-only env layers (validated by the phase-0 spikes); raw SSE route
(httpapi has no event-stream media type)
- cli: minimal command dispatcher reusing the plugin layer graph
(deliberately not effect/unstable/cli — it needs platform packages)
- react subpath: plugin router (path→hash→fallback deep-link restore +
pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase
- theme.css: the console's violet identity packaged for plugin UIs
18 bun tests incl. the two phase-0 spikes; publish workflow mirrors
sdk-publish (tag plugin-kit-v*; 0.1.0 published manually).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manual publish (no sdk-v tag — the version was published directly so the
plugin-kit and rewritten rom-manager can consume pluginStateDir/pluginIngestDir
from the registry; the previously published 0.1.1 predates the security-tiers
merge and lacks those exports).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two spikes validating the riskiest seams of the plugin-kit design before
implementation:
- HttpApi (effect/unstable/httpapi) served on Bun behind the SDK's
servePluginUi using ONLY effect-core layers (Etag.layerWeak, Path.layer,
HttpPlatform.layer + FileSystem.layerNoop) — no platform package. Auth,
schema validation, and static fallthrough all verified end-to-end.
- Browser client prefix strategy for the console proxy: both
transformClient+prependUrl AND baseUrl preserve the /plugin-ui/<id>
path prefix. Nested Schema.withDecodingDefaultKey defaults confirmed
(Effect-valued defaults; encodingStrategy "omit" restores raw shape
on encode).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second and final batch from the 2026-07-20 core-sweep lows:
client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
completes after the deadline closes with QUIT_CLOSE_CODE instead of
leaving the host lingering (virtual display up) for a reconnect that
never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
provably loss-free windows the controller itself scores as fine.
clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
an unanswered FetchRequest parked the task, waiter, and bi-stream
forever; ~100 of them exhaust the connection's bidi budget).
C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
offset (Swift/Kotlin latency math read the frozen connect-time value
~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).
FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
encode_into (was: fresh 230×200 generator + decode inversion per
lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
(x86 SIMD callees bound stores on input.len() — safe-code OOB write);
ReconstructShard's safety contract gains the len()==get().len()
clause and reconstruct_internal sizes raw slices from the slice.
transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
gamepad preference to "auto" on close.
Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the 2026-07-20 punktfunk-core quality sweep's adjudicated lows
(~/punktfunk-sweeps/punktfunk-core-2026-07-20.json), each with a
regression test:
- session: the two-lane seal's dead-worker fallback dropped the frame's
back half and returned Ok with half an access unit; the corpse lane
was also never respawned. The send-failure arm now reclaims the tail
(single-lane seals the WHOLE frame) and both failure arms drop the
lane so the next large frame respawns it. A recv-side death now
surfaces as an error instead of silent truncation.
- reassemble: Reassembler::reset() left pending_partial parked, so a
pre-flush stale partial survived Session::flush_backlog and was
delivered as the first "frame" after a jump-to-live.
- caps: resolve_codec echoed a non-conformant multi-bit preferred byte
verbatim (downstream from_wire folds it to HEVC — possibly outside
the shared set). It now isolates one bit of the intersection.
- endpoint: stream_transport_idle only floored the value; an absurd
operator-supplied idle timeout blew past QUIC's VarInt ms ceiling and
panicked host startup through the expect. Clamped to 1s..1h.
- pairing: PairRequest::encode cut the device name at a raw byte-64
boundary, splitting multi-byte UTF-8 (host showed U+FFFD forever);
it now shares Hello's char-boundary truncate_to.
The frozen-FEC-ceiling finding (reassemble.rs:109) was already fixed on
main by the sweep's high-severity commit — skipped as stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- build.rs: drop the stale `rerun-if-changed=src/client.rs` (the file
became client/ in W7; the path never fires).
- lib.rs: the crate doc's module list named 6 of ~19 modules — add the
missing ones (config/error/stats/input/reject/reanchor/render_scale/
audio/wol + the quic-gated control-plane group), without intra-doc
links to feature-gated modules.
- quic/mod.rs: the module doc still described the deleted `msgs`
module; list the actual post-split module set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
client/pump.rs was a single 1192-line async fn stacking six concerns.
It is now a 190-line orchestrator (same spawn order, same channel
wiring) over five focused modules:
- pump/handshake.rs — connect + Hello/Welcome/Start + skew handshake +
hole punch + Session construction (HandshakeOut)
- pump/input_task.rs — the gamepad snapshot/removal/arrival re-send
state machine + passthrough input forwarding
- pump/control_task.rs— the control-stream select loop (renegotiation,
probes, bitrate acks, clock re-sync, clip
metadata), bundled as ControlTask
- pump/datagram_task.rs — the datagram tag demux + rumble reorder gate
- pump/data.rs — the blocking data-plane pump (loss reports, ABR
+ capacity probe, jump-to-live detectors,
standing-latency bleed), bundled as DataPump
Every body moved verbatim (dedent + arg-struct destructures aliasing
the original binding names); no per-frame indirection added — the pump
loop, its locals, and the hot-path shape are byte-equivalent. The
Ctrl/DataPump arg structs exist only to stay under too_many_arguments.
196 lib tests pass; clippy clean on --features quic AND
--no-default-features (--all-targets); include/punktfunk_core.h
byte-identical; fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
session.rs (1203) sharpens down to the two hot-path state machines +
lifecycle (887): ReplayWindow + seq_of + their tests -> session/replay.rs;
the PUNKTFUNK_PERF trio PumpPerf/SealPerf/TimedCoder -> session/perf.rs;
the Phase-1.5 lane machinery SealLane/SealJob/seal_wire_slice/
TWO_LANE_MIN_PACKETS -> session/seal.rs. Facade pattern (session.rs stays
the parent file); pub use keeps session::{PumpPerf,SealPerf} stable and
lib.rs re-exports are untouched. Pure code motion + pub(super) bumps —
seal_frame_inner/poll_frame/poll_input bodies unchanged; the
wire-equivalence tests stay co-located with the seal path they pin.
196 lib tests pass, clippy --features quic --all-targets clean, fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quic/tests.rs (1813 lines, 43 tests) was the W7 split's leftover: the
source moved into handshake/caps/control/clock/pairing/pake/datagram/
endpoint/clipstream/io but every test stayed in one monolithic file.
Each test now lives in a #[cfg(test)] mod tests at the foot of the
module it exercises, verbatim. The two CompositorPref/GamepadPref
wire/name tests moved to config.rs (where those enums live), so they
now also run under --no-default-features. The clip_loopback and
ctrl_framing integration mods share connect_pair via a cfg(test)-only
quic/test_util.rs.
Test-only motion: 196 lib tests pass unchanged on macOS, clippy
--features quic --all-targets clean, include/punktfunk_core.h
byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ci.yml`'s clippy gate has been failing on main since the pre-0.16.0 sweep landed, and
because clippy stops at the first crate it can't compile, the visible error was only
ever the first of four. `ci.yml` is not tag-triggered, so v0.16.0 cut and shipped over
a red main; none of this reaches the release artifacts (the two E0308s are in a test
binary and a dev tool, and the rest are lints) but the gate has been blind since.
Fixed, in the order clippy surfaced them:
- clients/probe failed to COMPILE (E0308, x2). `810d918d` moved `clock_sync` onto the
resumable `io::MsgReader` but only updated the client pump, leaving the probe passing
a bare `&mut RecvStream`. The probe now wraps the control stream in a `MsgReader` at
`open_bi` and threads that everywhere — Welcome, the --remode and --bitrate watchers,
and the speed-test result read — which is also what the refactor was for: those reads
sit behind timeouts and `select!`, exactly where a straddling frame would desync the
stream for the rest of the run.
- pf-capture: `SPA_META_Cursor as u32` is a `u32 -> u32` no-op
(`clippy::unnecessary_cast`). Line 1274 already passes the same constant uncast, so
the type is not in question.
- pf-inject: `noop as usize` on the SIGUSR1 wake handler added in `986402f7` trips
`clippy::function_casts_as_integer`; goes via `*const ()` as the lint asks. Same value
in the `usize`-typed `sa_sigaction` slot.
- punktfunk-core: the `ctrl_framing` test module's `use super::*` is unused, which
`-D warnings` promotes to an error.
Verified with CI's own commands on a Linux box (this is all Linux-gated code, so a Mac
cannot check it): `cargo clippy --workspace --all-targets --locked -- -D warnings`
finishes clean, `cargo build --workspace --locked` succeeds, and
`cargo test --workspace --locked` exits 0 with no failures — including punktfunk-core's
196-test suite, which covers the control-stream framing the probe change touches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flathub remote-add has now burned its entire retry budget and failed the job on
three consecutive tag builds — v0.15.0, the v0.15.0 re-point, and v0.16.0 — each one
needing a manual re-run to land the bundle.
The cause was already root-caused here on 2026-07-11 (see the Tooling step's comment):
home-runner-1's Docker embedded resolver at 127.0.0.11 drops UDP lookups while the
shared multi-org fleet is saturated. Nothing retransmits a dropped datagram, so the
lookup simply times out. The fix then was to widen retry.sh to 10 attempts (~9 min),
which comfortably outlasts a main push's ~8-workflow fan-out — but a TAG push starts
13 workflows at once, and that burst now outlives the whole budget. Retrying harder
is chasing the symptom; the transport is the problem.
`options use-vc` moves resolution to TCP, where the kernel retransmits and a query
cannot be silently lost under load. Same resolver and search path — only the
transport changes — so internal names still resolve exactly as before. Deliberately
no additional nameservers: a public resolver in the list could answer an internal
name (git.unom.io) with NXDOMAIN. retry.sh stays as the backstop for real upstream
blips.
Applied non-fatally, because Docker bind-mounts /etc/resolv.conf and may present it
read-only: a DNS tuning that cannot be applied must not be the thing that fails a
release build. If the write is refused we warn and stay on UDP, i.e. exactly today's
behaviour.
Scoped to the flatpak workflow, where the failure is actually evidenced, rather than
applied fleet-wide on suspicion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v0.16.0's Apple leg failed at the xcframework build with "No space left on
device (os error 28)": the runner's boot volume was at 100% with 152 MB free.
None of the four `xcodebuild archive` calls passed -derivedDataPath, so each one
used the default ~/Library/Developer/Xcode/DerivedData/<name>-<hash> — and that
hash is derived from the PROJECT'S ABSOLUTE PATH. act_runner rotates its
workspace (~/.cache/act/<hash>/hostexecutor), so every rotation looked like a new
project to Xcode and minted a fresh ~760 MB tree. Nothing ever collects those:
they live outside the workspace, so act's own cleanup never sees them. 31 had
accumulated in three days (17 -> 20 July), which with the 12 GB shared
ModuleCache came to 32 GB — on a 228 GB volume already 95% full.
Pin all four archives to one path. The tree is now REUSED rather than multiplied,
which also keeps the module cache warm instead of rebuilding it per run. The new
step additionally prunes anything week-stale left in the default root, covering
both the legacy per-path trees and any other job that lands there.
Cleared by hand on home-mac-mini-1 to unblock the release (152 MB -> 31 GB free);
this is the change that stops it coming back. Note the same class of problem bit
the Windows runner the same night from the other direction — its disk-cleanup task
purges C:\t and deleted files out from under ISCC mid-pack — which is worth its
own look and is NOT addressed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MINOR, not the 0.15.1 originally planned: the 24 commits since v0.15.0 carry 7
features, and they are not incidental — they change the plugin runner's privilege
model. It stops holding the console's full-admin token (scoped plugin-token lane),
the Windows runner drops from SYSTEM to LocalService, public-registry plugin
installs are gated behind an explicit flag, and the systemd user unit is sandboxed.
Operators have workflow-visible changes to absorb (a plugin genuinely needing the
admin surface must now set PUNKTFUNK_MGMT_TOKEN; installing outside the @punktfunk
scope needs --allow-public-registry). A patch label would understate all of that,
and sdk-publish pushes this version straight to consumers.
The other 12 fixes are largely the pf-encode/zerocopy/capture/inject quality sweep
landing: teardown deadlocks, error-path leaks, a GL->CUDA copy race, an odd-height
NV12 overrun, a cursor-meta OOB read, plus the two loss-recovery gaps 0.15.0 left
open (Vulkan Video never got the LTR taint sweep; QSV's sweep skipped its modal
single-swept-slot case).
`WIRE_VERSION` stays 2 and the driver protocol stays v4 — pairings, clients and
installed drivers keep working. The C ABI moves 8 -> 9 (PunktfunkFrame grows
`received_ns`, so receipt is stamped at the session boundary rather than at the
embedder's pull); the gitignored Apple xcframework needs a rebuild for that to
reach the Apple clients. No OpenAPI path changes (45 before and after) — the new
plugin-token lane is auth-layer only — so api/openapi.json moves info.version only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There is one `ProbeState` per session and no correlation id, and two independent
requesters share it: the pump's startup link-capacity probe and the embedder's
`NativeClient::request_probe` (the shipped "Test connection" in the Windows GUI
and the Linux GTK app). The startup path had a busy check, a watchdog and a
window rebase; the embedder path had none of them, and the two collide by
default — both shipped speed tests call `request_probe` on the statement right
after `connect()`, and the pump's probe fires 2 s later, inside that burst.
Four defects, one cluster:
- The pump overwrote the shared slot unconditionally. Mid-burst it drops
`base_packets`/`base_bytes`, the pump re-snapshots them against the host's
full-burst denominator, and the user's speed test reports roughly an order of
magnitude low; if the result lands first instead, the reset wipes `done` and
the embedder's poll loop never sees its own measurement. Now it defers and
retries rather than stealing the slot.
- An unanswered embedder probe never timed out. `probe_active` gates the entire
report tick — LossReport, the ABR window feed, the standing-latency ladder and
a pending ClockResync all live inside it. A host that ignores ProbeRequest is
an anticipated configuration (the startup path was given a 6 s timeout for
exactly that) so the embedder path could latch `active` forever and silently
switch off every adaptation mechanism for the rest of the session. Now a
watchdog covers a probe of either origin.
- `request_probe` latched `active` before `try_send`, so a full or closed ctrl
channel returned `Closed` to the caller while leaving the session wedged in
the state above. It now rolls back, mirroring the startup path.
- Only the startup path rebased the ABR window past the burst. Probe filler is
counted into `bytes_received` for every accepted datagram but never reaches
the decoder, and the tick is suppressed for the whole burst, so the first
post-burst window read the burst rate as `actual_kbps`. That poisons
`proven_kbps`, a monotone high-water mark that is never decayed, which
disables the x1.5 evidence-gated climb guard for the session and authorizes a
climb into a rate the decoder has never carried. The rebase now happens on the
falling edge of any probe, and covers the loss/packet anchors too so the first
post-burst LossReport isn't divided by a filler-inflated packet count.
Also: `wants_decode_latency` advertised on two of the three terms the pump
actually requires to arm ABR, omitting `resolved_bitrate_kbps > 0`, so against
an old host that reports no rate an embedder fed decode latency to a controller
that never runs.
No wire or ABI change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`io::read_msg` frames a message with two `quinn::RecvStream::read_exact` calls,
and quinn documents `read_exact` as explicitly NOT cancel-safe: the bytes it has
already taken out of the stream live only in the future's own buffer and nothing
puts them back on drop. Both long-lived control loops drive that read from a
`tokio::select!` arm — the client pump alongside `ctrl_rx.recv()` and the resync
tick, the host alongside probe/reconfig/clip-offer channels — and neither uses
`biased;`, so any sibling that becomes ready ends the iteration and drops a
partially-progressed read. `clock_sync` has the same shape via
`tokio::time::timeout`, which can fire mid-frame before the session even starts.
A control frame only has to straddle two wakeups for this to bite: a ClipOffer
carries up to 16 kinds x 128 bytes of MIME, ~2 KB, which exceeds one QUIC packet
and is subject to the pacer; any frame whose second half is lost or reordered
does it too. Losing the consumed length prefix misaligns the stream permanently
— the next read takes two payload bytes as a length, so Reconfigured,
ProbeResult, BitrateChanged, ClockEcho and ClipState all decode as garbage and
are silently dropped, and a bogus length up to 64 KiB parks the read forever.
Mode switches, adaptive bitrate, mid-stream clock resync and clipboard are dead
for the rest of the session; only a reconnect recovers, and the log shows at
most one `warn!`.
Add `io::MsgReader`, which keeps the frame in progress in the reader rather than
the future and reads via quinn's cancel-safe `read`, and switch the three
cancelling sites to it (client control loop, host control loop, clock_sync).
The sequential handshake/pairing callers keep the plain `read_msg`, whose doc
comment now states the constraint. No wire bytes and no ABI change — only how
the same length-prefixed frames are assembled.
Tests: a frame split across two wakeups with the read cancelled in between must
resume and leave the following frame correctly framed (confirmed to fail — it
hangs on the desynced stream — against the old behavior), plus a zero-length
frame round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four defects from the punktfunk-core quality sweep, all in the data plane.
transport/udp: the hole-punch adopted the source address of ANY datagram whose
first 8 bytes matched PUNCH_MAGIC — a fixed public constant with no key, nonce
or session id — and the authenticated QUIC peer was passed only as the
no-punch fallback, so it was never used to validate. Hole-punch is the default
bring-up path (it is skipped only for a fixed --data-port), and the data socket
is an OS ephemeral, so spraying the ephemeral range during the 2500 ms punch
wait let anyone steal the video plane: the legitimate client is then filtered
out by the connect() and gets nothing, while QUIC stays healthy so no reconnect
fires. With a spoofed source the same 8 bytes aim a full-rate stream at a third
party. Take the authenticated peer IP and require the punch to match it — only
the PORT is in question (that is what a NAT remaps and what the punch exists to
discover); the client dials the same host IP as its QUIC connection, so a NAT
presents one source IP for both planes. Also budget each read from the REMAINING
window, so off-peer datagrams cannot stretch the wait past punch_timeout.
transport/udp: the punch keepalive treated every send error as fatal and broke
out of its loop permanently and silently. It holds a clone of the connected,
non-blocking data socket — exactly the socket whose transient conditions this
module defines and documents in is_transient_io. One ENOBUFS from a full wlan tx
queue or an ENETUNREACH during an AP roam killed the only thing holding the NAT
mapping open; the path recovers, video keeps flowing, and the stream dies later
when the idle timer expires the mapping during a static scene. Route it through
is_transient_io like every other send site in the file.
packet: adaptive FEC moved fec_percent live (host bands it 1..=50 while Welcome
advertises 10) but the receiver's per-block acceptance ceiling was computed once
from the negotiated percentage and never re-derived. Once FEC ramped, every
packet of a maximal block failed `total > max_total_shards`, the block never
accumulated a shard, the frame aged out, and the resulting loss drove FEC higher
still — large frames wedged at 100% loss exactly when FEC was meant to rescue
the link. Fixed on both sides, because hosts and clients update independently:
the sender clamps per-block parity to the ceiling the peer negotiated, and the
receiver sizes that ceiling from the whole clamp range rather than a stale
snapshot of it.
No wire bytes and no C ABI signature change; WIRE_VERSION and ABI_VERSION are
unchanged. Regression tests cover all three (the punch tests were confirmed to
fail without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LocalService runner can't traverse the interactive user's profile the way
the old SYSTEM runner could — so a plugin can no longer read a file an app
running as *you* produced (the acute case: the Playnite exporter's library
JSON under %APPDATA%). Confirmed on-glass: as LocalService the glob finds
nothing and the profile is un-traversable.
Add the inverse of plugin-state: <config_dir>\ingest, granted BUILTIN\Users
Modify by plugins enable (disable reverts to inherited Users:RX). An
interactive-user app drops ingest\<plugin>\… and the de-privileged runner
reads it there — the one Users-writable carve-out in the otherwise
Users-read-only tree. SDK exports pluginIngestDir(name) to resolve it; on
Linux the systemd --user runner owns the config dir so same-user producers
write there with no grant.
Accepted tradeoff: the inbox is writable by any local user (trusted-single-
user model; it feeds only a LocalService runner). Consumers must treat ingest
data as lower trust than their own state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A plain `punktfunk-host plugins add …` printed the host's startup banner and
— on Windows — ran the win32u GPU-preference hook, whose DPI-awareness probe
emits a scary `SetProcessDpiAwarenessContext … "access denied"` WARN. None of
it is relevant to a package-manager command, and the WARN reads like a failure.
Gate both behind is_management_cli(): plugins / openapi / library /
detect-conflicts / driver / web / service-management verbs skip the banner and
the DXGI hook. `service run` (the SCM-launched host) is explicitly NOT
lightweight, so the hybrid-GPU ACCESS_LOST fix it depends on stays intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LocalService runner cannot write anywhere under %ProgramData%\punktfunk
(the config dir is Users-read-only), so a state-writing plugin's saveCache /
config-edit / first-run mkdir all fail EPERM — proven on-glass (rom-manager
only looked fine because its state dir was pre-created by an admin run and a
0-title reconcile skipped the write).
Add the one writable grant the model was missing, keeping the split crisp —
code dirs RX+WA, secrets R, and now a dedicated state root RW:
- plugins enable / build-scripting.ps1: create %ProgramData%\punktfunk plugin-state and grant LocalService (OI)(CI)(M); disable revokes. Users stay
read-only, so another non-admin still can't tamper with a plugin's launch
templates.
- SDK: export pluginStateDir(name) -> <config_dir>/plugin-state/<name>. Same
path on Linux (the systemd --user runner owns the config dir, writable with
no grant), so plugins use one branch-free helper.
Plugins must persist under pluginStateDir(), not straight under the config dir.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- plugins: the Windows runner task runs as LocalService now, and
public-registry names need --allow-public-registry
- automation + SDK README: connect()'s zero-config credential is the
scoped plugin token; pairing administration and hook registration
need an explicit PUNKTFUNK_MGMT_TOKEN opt-in
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supply chain: resolvePackage() used to pass any punktfunk-plugin-* or
foreign-scope name straight to bun add — a typo or a squatted look-alike
on npmjs.org would install operator-privileged code. Only the @punktfunk
scope (pinned to the Gitea registry by the bunfig scope map) resolves by
default now; anything else throws with an explanation unless
--allow-public-registry is passed, and even then installs print a loud
warning. Removal never gates — uninstalling stays safe regardless of
origin.
systemd (user unit): free hardening for well-behaved plugins —
NoNewPrivileges, PrivateTmp, ProtectSystem=strict with ReadWritePaths=%h
(plugin state and download dirs under $HOME keep working), and
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. Plugins writing
outside $HOME, and distros that restrict unprivileged user namespaces
for user units, are handled via a documented systemctl --user edit
drop-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fileIsSafe() returned true unconditionally on win32 ('ACL check is a
follow-up'), leaving the best-effort directory DACL as the only guard on
what the runner imports and executes. The follow-up: before importing a
unit, read its SDDL (Get-Acl via the full-System32-path powershell) and
refuse loudly — exactly like the Unix mode-bits path — unless the owner
is SYSTEM/Administrators/TrustedInstaller (or the account the runner
itself runs as, mirroring Unix's 'your own file is fine') and no other
principal holds a write-capable allow ACE (write/append data, EA/attrs,
DELETE, WRITE_DAC, WRITE_OWNER, generic write/all).
The SDDL verdict is a pure exported function with platform-independent
tests; unknown ACE shapes and unreadable ACLs fail closed. Inherit-only
ACEs (templates for children) and deny ACEs don't trip it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PunktfunkScripting scheduled task ran operator-installed plugin code
as SYSTEM with -RunLevel Highest — any plugin defect was a full
compromise of the box. The principal is now NT AUTHORITY\LocalService
(minimal privileges, no password to manage, exists at boot, loopback
networking works), registered without RunLevel:
- installer: New-ScheduledTaskPrincipal -UserId 'LocalService'
- plugins enable: converges the principal idempotently (migrating tasks
an older installer or a dev box registered as SYSTEM) BEFORE starting,
then grants LocalService read — via icacls, full-System32-path — on
exactly the two SYSTEM/Admins-DACL'd files the runner's connect()
needs: the scoped plugin-token and the TLS-pin cert.pem. Never
mgmt-token. plugins disable revokes the grants; plugins status now
prints the task principal so the migration is verifiable.
- build-scripting.ps1 mirrors the convergence + grants on dev deploys.
The usage text also mentions the new --allow-public-registry gate that
lands with the supply-chain commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scripting runner used to hold the console's full-admin mgmt-token — a
plugin defect could rewrite hooks.json (arbitrary command execution) or
administer pairing. The host now mints a second persisted secret,
plugin-token (PUNKTFUNK_PLUGIN_TOKEN), and require_auth grows a third lane
for it: loopback-confined like the admin token, but plugin_may_access
carves out /hooks (read AND write), everything under /pair, /native/pair,
/native/pending, client unpair DELETEs, and other plugins' ui-credential.
Everything a plugin legitimately does (status/library/events/sessions,
its own UI lease) is untouched.
The SDK's zero-config connect() now prefers PUNKTFUNK_PLUGIN_TOKEN /
plugin-token over mgmt-token, so plugins pick the scoped credential up
automatically; a script that genuinely needs the admin surface sets
PUNKTFUNK_MGMT_TOKEN explicitly. Old hosts without a plugin-token fall
back to mgmt-token unchanged. No OpenAPI change: the lane is auth-layer
only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`punktfunk-host plugins add/remove/list` forward to the bun runner, which on
Windows is resolved relative to the running exe (<exe-dir>\bun\bun.exe +
<exe-dir>\scripting\runner-cli.js). Only the installer ever laid that payload
down, so on a deploy-host.ps1 dev box — where the service runs out of
target\release — both paths are absent and the CLI bails with "the plugin
runner isn't installed". deploy-all.ps1 was host + web console only.
Add build-scripting.ps1: it mirrors CI (bun install --frozen-lockfile
--ignore-scripts + bun build src/runner-cli.ts --target=bun, gated on the same
`attempt=` sentinel that proves the dynamic plugin import stayed a runtime
import), then lays the bundle + scripting-run.cmd + the shared bun next to every
host exe it finds — the built one and whatever the PunktfunkHost service runs —
so it is correct on a dev checkout or an installed {app}. It stops a running
PunktfunkScripting first (a live runner holds bun.exe open) and does not
silently enable the opt-in task (-EnableTask to do so).
deploy-all.ps1 is now host -> web console -> runner, always, so the host binary
and the runner bundle never drift apart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:
- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
Android decode loops), not at reassembly completion — so any client-side
standing state between the reassembler and the pull read as NETWORK
latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
the session boundary. Every embedder now uses the core stamp; the Apple
client keeps the pull instant as `AccessUnit.pulledNs` and shows the
receipt→pull wait as its own "client queue" term (detailed HUD tier from
2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
anchor on all platforms, so no historical stage shifts meaning.
- The jump-to-live detectors deliberately ignore anything under 6 queued
frames / 400 ms behind — so a small, constant, loss-free elevation (a
sub-frame standing backlog, or a stale clock offset after a wall-clock
step/slew) is carried for the rest of the session. New third detector
(`StandingLatency`, unit-tested ladder): window-MIN one-way delay
≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
gently — a free clock re-sync first (an applied re-sync re-bases the
floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
cooldown, then a loud disarm naming what it means. Loss windows reset
the run: congestion belongs to FEC/ABR, not this detector.
Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both MSIX matrix jobs failed at the v0.15.0 tag, for two unrelated reasons, both
introduced by this release. 0.13.0 and 0.14.0 shipped both packages; 0.15.0 shipped
neither.
ARM64 failed to BUILD. The PyroWave Windows un-gating (1ef0229b) moved `pyrowave-sys`
in pf-client-core out of `cfg(target_os = "linux")` into a general optional dependency
that pf-client-core's own `default = ["pyrowave"]` turns on. The ARM64 leg builds
`--no-default-features` precisely to skip it, but that only disables defaults for the
two packages named with `-p` — every internal edge kept inheriting them:
clients/windows -> pf-client-core, clients/session -> pf-client-core, clients/session
-> pf-presenter, and pf-presenter's own `default = ["pyrowave"]` -> pf-client-core.
So the vendored Granite C++ compiled on ARM64 and stopped where it always does:
muglm.cpp reaching for _MM_TRANSPOSE4_PS/_mm_storeu_ps, then
`simd.hpp:103 #error "Implement me."`.
Those three internal edges now pass `default-features = false`; the feature is enabled
only through an explicit chain (clients/session's `pyrowave` -> pf-client-core/pyrowave
+ pf-presenter/pyrowave, and pf-presenter's `pyrowave` -> pf-client-core/pyrowave), so
`--no-default-features` finally means what it says. Verified by feature resolution
rather than assertion — `cargo tree -i pyrowave-sys`:
aarch64-pc-windows-msvc, --no-default-features -> not in the graph (was: present)
x86_64-pc-windows-msvc, default features -> present
x86_64-unknown-linux-gnu, default features -> present
PyroWave is NOT dropped from the Windows client. Decode has always run in the spawned
punktfunk-session binary, whose own default keeps the feature on, and unification turns
it back on for the shared pf-client-core wherever that binary is in the build (x64,
Linux). The shell only ever offered "pyrowave" as a codec preference string — it holds
no decoder and never called one, so linking the C++ into it was dead weight even on x64.
x64 built fine and failed at PACKAGING: `makepri new` rejected the manifest with
PRI191 "Appx manifest not found or is invalid" / malformed comment syntax. The console
tile added in 2508b720 documented its flag inside an XML comment, and XML forbids `--`
anywhere in a comment body, so the literal flag spelling made the whole manifest
unparseable. Reworded, with a note to keep the next person from reintroducing it.
Confirmed by parsing both revisions after template substitution: the tagged manifest
fails at line 63 col 9, this one parses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two medium findings from the round-1 sweep, each applied to both siblings.
- create_swdevice stack-allocated the SwCreateCtx that the async PnP completion
callback writes through (result + up to 127 u16 of instance id) and then
SetEvents. The wait is bounded at 10s, so on a wedged-PnP timeout the callback
can still be PENDING: the frame is popped, the input thread reuses that stack,
and a late callback corrupts it and SetEvents an already-closed (possibly
recycled) handle. The context is now heap-allocated and reclaimed only where
the callback provably ran; on the timeout path the box is deliberately leaked
and the event left open, so a late write always targets live memory. Costs a
one-off ~264 B + one HANDLE on that rare path. Applied to the DualSense path
and its XUSB sibling in gamepad_windows.rs.
- Ds4WinPad::open swallowed a create_swdevice failure into a WARN and returned
Ok with no devnode. PadSlots::ensure then stored Some(pad) AND called
gate.on_success(), so the slot short-circuited on is_some() forever and the
capped-backoff retry that exists precisely to self-heal a transient PnP failure
never ran — the game saw no controller for the rest of the session unless the
client unplugged the pad. Now propagates, matching the XUSB sibling. Same fix
applied to steam_deck_windows.rs.
Windows .173: pf-inject 53/0. Linux .21: pf-inject 74/0 (8 ignored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two medium findings from the round-1 sweep.
- The `.process` callback dequeued a buffer INSIDE its `catch_unwind`, and every
requeue site was inside too. `newest` is a raw pointer with no Drop, so any
caught panic (update_cursor_meta / consume_frame) unwound past all three
requeues and permanently stranded that buffer. Once the stream's fixed pool
drained, `dequeue_raw_buffer` returned null every call and capture silently
wedged while still reporting negotiated/active — defeating the very
catch_unwind that was meant to keep a panic survivable. The drain loop now runs
OUTSIDE the catch (dequeue/queue are non-panicking C FFI pointer ops) and
`newest` is requeued exactly once after it, on every path: normal,
corrupted-skip, or caught panic.
- `recreate_ring` invalidated `video_conv` and `hdr_p010_conv` but not
`pyro_conv`. That converter is mode-baked — BgraToYuvPlanes selects entirely
different shaders and output formats for SDR (8-bit BT.709 → R8/R8G8) vs HDR
(scRGB→PQ BT.2020 → R16/R16G16) — and `ensure_pyro_conv` only builds when None,
so a display_hdr flip reused the stale SDR converter against a freshly
HDR-formatted pyro ring, corrupting every frame. Reachable at the documented
"Downgrade point D": a PyroWave session with client_10bit=true that opens on a
box where HDR can't enable, then flips once the display comes up.
Linux .21: pf-capture 1/0. Windows .173: `cargo check -p pf-capture` clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven medium findings from the round-1 sweep, all in pf-zerocopy. Adjudicated
against source; all zero-copy-preserving (no GPU→CPU→GPU roundtrip introduced).
- import_src (vulkan.rs) leaked a VkBuffer + VkDeviceMemory + dup'd fd on every
fallible step before the success-only src_cache.insert. A failed import is
survived by the worker and RETRIED by the caller every frame, so this leaked
per frame for the worker's whole lifetime. Now owns the dup fd via OwnedFd
(closes on early return until Vulkan consumes it at allocate_memory) and
destroys the buffer + frees memory on each error path.
- ensure_dst destroyed the old exportable buffer and nulled self.dst BEFORE
building the replacement, so a failed rebuild both dropped the working buffer
and leaked every object the partial rebuild created (raw ash handles, no Drop;
VkBridge::drop only frees the live self.dst). Now builds fully, unwinds each
error locally, and swaps only on success.
- The submit→wait→reset sequence in import_linear_nv12 / import_linear `?`-ed out
of wait_for_fences BEFORE reset_fences on a TIMEOUT/DEVICE_LOST, leaving the
shared self.cmd PENDING and self.fence IN-USE while the caller retries on the
same bridge (and ensure_dst later destroys dst.buffer assuming nothing is in
flight). Now drains the GPU (device_wait_idle) and resets the fence before
propagating — fixing the reported ensure_dst UAF at its source.
- copy_pitched_nv12_to_buffer writes height.div_ceil(2) chroma rows into a UV
plane sized at height/2, so an odd height overruns by one uv_pitch row (OOB
device write / CUDA_ERROR_ILLEGAL_ADDRESS). Added the even-dimension guard to
import_linear_nv12, matching Nv12Blit/Yuv444Blit.
- sweep_reaper only reaped already-exited workers, so a worker wedged inside a
driver call lingered forever holding its CUcontext + BufferPool (hundreds of MB
VRAM). Now force-kills a child parked past REAPER_KILL_DEADLINE (20s).
Compile + tests green on Linux .21 (RTX 5070 Ti): pf-zerocopy 17/0. The error
paths themselves are not fault-injected; the fixes are structural.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three high-severity defects from the round-1 sweep of pf-inject /
pf-zerocopy / pf-capture (adjudicated against source — all 7 reported criticals
in these crates downgraded; these were the real highs).
- pf-inject steam_gadget: `SteamDeckGadget::drop` set `running=false` then joined
the control thread, which spends steady state parked in a blocking, no-timeout
`EVENT_FETCH` ioctl that only tests `running` at its loop top. The flag never
reaches it, closing the fd can't wake an in-flight ioctl (the syscall holds a
file reference, and the fd is shared via Arc by the very threads being joined),
so the join hung — and it runs on the session input thread via
`PadSlots::sweep`, driven by the client's `active_mask`, so a remote peer
clearing its pad bit could freeze all session input. Now wakes the parked
threads with SIGUSR1 (no-op, non-SA_RESTART handler → the ioctl returns EINTR
and the loop exits), retried until each reports done and bounded (~1s).
- pf-zerocopy cuda: the GL→CUDA "sync point" was never established for the copy.
`cuGraphicsMapResources`/`UnmapResources` were issued on the NULL stream, but
the D2D copy runs on `copy_stream()`, a `CU_STREAM_NON_BLOCKING` stream exempt
from implicit NULL-stream ordering — and the GL de-tile/CSC that produced the
texture ends with only `glFlush` (no fence). So the copy could race ahead of
the not-yet-retired GL draw: intermittent stale/torn frames under GPU load, on
the default NVIDIA capture→encode path. Map, copy, and unmap now share
`copy_stream()`, so map's device-side guarantee orders the GL work before the
copy. Zero-copy preserved (no GPU→CPU→GPU roundtrip).
- pf-capture cursor meta: `update_cursor_meta` trusted three producer-written
fields (bitmap_offset, pixel offset, stride) with no bound against the metadata
region, driving OOB pointer arithmetic and an oversized `from_raw_parts` — an
OOB read that SIGSEGVs inside the PipeWire `.process` callback (uncatchable by
the surrounding `catch_unwind`). Switched to `spa_buffer_find_meta` to obtain
the region's real `size` and validate every offset with checked arithmetic
before each deref/slice, mirroring the fd-length guard the main frame path
already applies.
Compile + existing tests green on Linux .21 (real RTX 5070 Ti): pf-inject 74/0,
pf-zerocopy 17/0, pf-capture 1/0. The gadget deadlock path only executes on a
SteamOS host with raw_gadget/dummy_hcd (not reproducible on the CachyOS box), so
that fix is reasoned + compile-verified, not runtime-exercised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VulkanVideoEncoder::open_inner creates ~20 Vulkan objects across ~15
fallible steps, but all cleanup lived in the encoder's Drop — which only
runs once the value exists at the final Ok(Self). Any earlier ?/bail!
leaked everything built so far (a VkDevice + GPU memory per retried
open, and this backend is the default encode path on AMD/Intel Linux
hosts where open can fail transiently).
Factor the entire teardown sequence — unchanged — into a VkTeardown
guard whose Drop destroys any prefix of the build (vkDestroy*/vkFree*
are defined no-ops on VK_NULL_HANDLE): open_inner mirrors each object
into the guard as it is created and disarms it only at Ok(Self); the
encoder's own Drop rebuilds one from its fields, so both paths share
one sequence and cannot drift. make_frame now builds in place into a
guard-parked null Frame so a mid-build failure unwinds its partial
handles too, and make_video_image / vk_util::make_plain_image (also
used by the PyroWave backend) / build_parameters_h265 no longer leak
their own partially-created objects on failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the partial fix from the previous commit. The Windows PyroWave backend
caches its imported plane images keyed on the D3D11 texture's COM address, and
holds no reference on that texture — so once the capturer recreates its ring,
those addresses can be handed straight back out by the allocator and a
pointer-keyed cache hit returns an image bound to a texture that no longer
exists. Adding the extent to the key ruled out same-address-different-size
aliasing, but a recycle at identical dimensions still aliased.
The capturer already tracks exactly the value needed: `generation`, bumped on
every ring recreate. Plumbed it onto `PyroFrameShare` and the encoder now
flushes every cached import when it changes, which makes cache identity
independent of allocator behaviour rather than a bet against pointer reuse.
Validated on the RTX box: `pyrowave_win_smoke` (forced with `--ignored`, the
only test that actually exercises this path on real hardware) passes all ten
configurations — 1024²/720p/1080p/1440p across SDR/HDR and 4:2:0/4:4:4 — with
correct decoded chroma means, so the steady-state cache-hit path still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows direct-NVENC backend registers and encodes the capturer's textures
IN PLACE (no CopyResource), so how deep it may pipeline is a property of the
CAPTURER, not of the encoder. It was bounded only by `async_inflight_cap()` —
`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped to the output-bitstream pool
— which consults nothing about the capturer, while the comment at the
backpressure loop claimed it "keep[s] in-flight depth within the capturer's
texture ring". It never did.
The IDD-push capturer rotates `OUT_RING = 3` per delivered frame with no regard
for encode completion (its own invariant note says OUT_RING(3) > max
pipeline_depth(2)). With the default async depth of 4 the encoder can therefore
still be reading a texture the capturer has already handed out again and
overwritten: torn or mixed frames. It is visual corruption rather than UB, so it
fails silently and intermittently — the worst shape to diagnose from a field
report.
Adds `Encoder::set_input_ring_depth`, reported from `Capturer::pipeline_depth`,
and bounds the async backpressure loop by `min(async_inflight_cap(), depth)`.
For IDD-push that yields 2, matching the capturer's stated contract; backends
that copy their input, or are synchronous, ignore it.
Wired at ALL THREE encoder-creation sites (initial open, stall/resize rebuild,
ABR rebuild) and forwarded through `TrackedEncoder` — this crate has a
documented trap where an unforwarded defaulted trait method silently no-ops
through that wrapper, which has already bitten the direct-NVENC work once and
the wire-chunking probe once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:42:53 +02:00
350 changed files with 44495 additions and 7034 deletions
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe{SwDeviceClose(hsw)};
returnErr(anyhow!(
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
));
}
// The callback ran (it signalled the event), so nothing else will touch `ctx`/`event`.
// SAFETY: `ctx` came from `Box::into_raw` and is reclaimed exactly once here.
letctx=unsafe{
let_=CloseHandle(event);
Box::from_raw(ctx)
};
ifctx.result.is_err(){
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe{SwDeviceClose(hsw)};
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.