deploy-all.ps1 chains deploy-host.ps1 + build-web.ps1 so a full redeploy is
one command; stops before the web step if the host build/start fails.
build-web.ps1's post-restart check was probing http:// while web-run.cmd
serves HTTPS-only, so it always reported a false failure - probe https via
curl.exe (no -SkipCertificateCheck on the Windows PowerShell 5.1 this runs
under) with a short retry loop for the task's cold start instead.
Three Deck-test findings in the Vulkan presenter:
MAILBOX over FIFO when the surface offers it (PUNKTFUNK_PRESENT_MODE=
fifo restores the old default): both are tear-free, but an arrival-
paced presenter blocking in FIFO's present queue eats most of a vblank
whenever gamescope holds images for its composite pass or cadence
drifts against refresh — the display stat flipping 1 ms → 11-13 ms.
VK_EXT_hdr_metadata wired up: the host's 0xCE mastering metadata (or
the Windows presenter's generic HDR10 baseline until it arrives) is
pushed via vkSetHdrMetadataEXT whenever an HDR10 swapchain is (re)-
created. Compositors key their HDR-app signaling on this — colorspace
alone leaves gamescope treating the app as SDR (no SteamOS badge).
And the OSD's HDR tag now reports the display path, not the stream: a
PQ stream tone-mapped onto an SDR surface shows HDR→SDR, so the next
test distinguishes "badge missing" from "HDR never engaged".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two hardenings against runaway vibration (one Deck session buzzed on a
~2 s cadence): the DualSense output-report parse now also treats
valid_flag2 COMPATIBLE_VIBRATION2 (data[39] BIT2) as rumble-carrying —
a writer hardcoding the ≥2.24-firmware convention previously had its
rumble INCLUDING stops silently ignored, and a missed stop re-sends
stale nonzero state forever via the host's 500 ms refresh. And the
client's SDL rumble duration drops 5 s → 1.5 s: long enough that a
couple of lost 500 ms refreshes don't gap genuine rumble, short enough
that stale state dies on its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quinn's default 1 MiB datagram FIFO holds tens of seconds of Opus; on a
congested link the audio/rumble/input planes built a standing delay that
never drained while video (own latest-wins UDP path) stayed live —
observed on the Deck as audio and rumble lagging together by the same
experience-destroying amount. quinn sheds oldest-first at the cap, so a
4 KiB bound makes the plane latest-wins at the source: ~200 ms of stereo
Opus worst case, and sustained congestion costs concealable drops
instead of lag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stats overlay verbosity tiers (Off/Compact/Normal/Detailed, 3-finger tap
cycles live, old Boolean pref migrated) and the unified-spec line-4
reliability counters: lost/FEC windowed from the connector's cumulative
totals, skipped from the client's own newest-wins drops. Adds the
fec_recovered_shards accessor to NativeClient, mirrored from the
data-plane pump like frames_dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rustfmt 1.9.0 aligns standalone // lines out to the column of a preceding
trailing comment, so the two-line fullscreen note placed right after
`set_captured(...); // ...` failed `cargo fmt --check`. Make the first note a
leading comment so no standalone comment follows a trailing one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-stream keyboard shortcuts were only reachable deep in Settings > Input.
Move them to a dedicated Help screen reached from a Help button on the host
list (mirrors the Licenses screen — a hook-free page routed from root), and drop
the reference card out of Settings so there's one home for it.
Fullscreen (F11) was a window-style toggle that stream stop never undid, so
disconnecting while fullscreen left the GUI borderless-fullscreen. Split
toggle_fullscreen into is_fullscreen/enter_fullscreen/exit_fullscreen sharing a
module-level SAVED_PLACEMENT, and have input::uninstall() call exit_fullscreen()
on teardown. exit_fullscreen is a no-op when nothing was saved, so it's safe on
every stream stop and idempotent with F11 — fullscreen is now streaming-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rich input (gyro at the pad's report rate, trackpad contacts) rode a second
mpsc channel that the input thread only drained after its main channel's
4 ms recv timeout — so during pure-gyro aiming (no button/pointer traffic,
the common case) every motion sample ate up to 4 ms of added latency and
quantization before reaching the virtual pad. At the Deck's 250 Hz sensor
cadence that is a full sample period of jitter.
Both planes now share one ClientInput channel: the thread wakes the moment
either arrives and applies rich input immediately. The 4 ms timeout stays
as the feedback-pump tick.
Also adds a debug-level motion-cadence metric (inter-arrival p50/p95/max
per 5 s window) so gyro delivery is measurable end-to-end instead of
'feels floaty'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Deck client asking for 'steamdeck' on a Windows host resolved to Xbox360,
whose apply_rich is a no-op — gyro and both trackpads silently discarded.
Windows already ships a full DualSense backend (UMDF driver, touchpad +
motion, wire units 1:1), so pick_gamepad now folds SteamDeck -> DualSense
there.
The three near-identical DualSense-family appliers (Linux UHID, Windows
DualSense, Windows DS4) are hoisted into one shared
dualsense_proto::DsState::apply_rich, with two mapping upgrades for Steam
dual-pad clients everywhere:
* the Deck's two pads SPLIT the single DualSense touchpad — left pad ->
contact 0 on the left half, right pad -> contact 1 on the right half —
mirroring the physical thumb layout and the split-pad zones games and
Steam Input already use (the left pad was previously dropped outright)
* TouchpadEx pad clicks now press the touchpad-click button (persisted
in DsState::touch_click, OR-ed in by both serializers; previously
dropped by every DualSense-family backend, Linux included)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both Deck trackpads were inverted on the virtual pad (first live Deck-to-Deck
session): the client sends SDL's screen-convention coordinates (+y down)
onto the wire, but the Deck's raw report fields are stick convention (+y up)
— and Steam Input parses our report as real Deck hardware — while the host
applier passed the value straight through.
Pin the wire meaning down (quic.rs: TouchpadEx/Touchpad are screen
convention, +x right +y down — what every client capture API produces) and
translate to device-raw in the host applier, the layer that knows the
quirk: steam_proto's apply_rich now negates y (saturating: -32768 has no
i16 negation) for both TouchpadEx surfaces and the DualSense-standin
Touchpad arm, which carried the same latent inversion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Steam Input only adopts the virtual Steam Deck controller when it arrives as
a real USB device (raw_gadget or usbip/vhci transports); the UHID fallback
has no USB interface and Steam ignores it. On a stock host neither
precondition for usbip held: vhci-hcd isn't loaded, and its sysfs
attach/detach files are root-only while the host runs as a user service —
so every session silently fell back to UHID and 'no controller appears on
the host' (live-debugged 2026-07-08: client events all arrived, the pad
existed, Steam just refused it).
Ship both preconditions with the host packages:
* modules-load.d/punktfunk.conf loads vhci-hcd at boot (rpm/deb/arch;
Bazzite gets it via the RPM payload + a modprobe in the sysext
post-merge hook)
* a udev rule in 60-punktfunk.rules grants the input group write on
vhci_hcd's attach/detach whenever the device appears
Verified end-to-end on a live host: usbip in-process attach, hid-steam
binds all three interfaces, 'Steam Deck' + motion evdev appear, and Steam
adopts the pad (client-mode grab + its own virtual X360 emission).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The submit waits the Vulkan-Video frame's decode-complete timeline semaphore
at FRAGMENT_SHADER, but the acquire barrier ran its layout transition
(VIDEO_DECODE_DST/DPB -> SHADER_READ_ONLY) from srcStage TOP_OF_PIPE — which
the wait does not cover, so no dependency chain formed and the transition
could execute while the decode queue was still writing the image. The race
window is real: the pump ships each frame to the presenter BEFORE its
decode-complete wait (that wait is stats-only).
On RADV the transition physically touches the image (metadata /
decompression), so the race rendered green/yellow block corruption exactly
at freshly-decoded regions — the Steam Deck 'artifacts around the moving
cursor' bug (the cursor is the only damage on an idle desktop). NVIDIA
treats the transition as a no-op, which is why the identical code looked
clean on the dev box.
srcStage is now FRAGMENT_SHADER, matching the semaphore's wait mask — the
standard acquire pattern (same as swapchain acquires) that chains the
transition after the decode signal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesa gates RADV's video-decode support — the VK_KHR_video_decode_* device
extensions AND the decode-capable queue family — behind
RADV_PERFTEST=video_decode (Steam Deck / VANGOGH included). Without it the
presenter's device advertises no decode queue, Decoder::new's auto chain
skips Vulkan Video, and the session silently lands on VAAPI.
punktfunk-session now appends the token to RADV_PERFTEST before the driver
loads (all streams run through this binary, so every launch path and package
is covered). RADV-only knob: other drivers ignore it, and a Mesa where
decode is already default just no-ops. Verified on the Deck: decode_qf=2,
H264/H265/AV1 caps, first frame path="vulkan-video", 90 fps at 1.9 ms decode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The linux-client-screenshots job red-lit on a single scene — the first one
(`hosts`) — with "client never signalled PF_SHOT_READY", while the other six
captured fine (~1-2s each). Root cause: the first client launch pays a large
one-time cold-start under software rendering (llvmpipe GL-shader + fontconfig
cache build, plus first-run client-identity generation into the shared scratch
HOME), ~25s on the CI runner; the relm4 shell rebuilt in 0.8.3 tipped it just
over the 20s PF_SHOT_READY cap. Every scene rendered correctly — the cap was
simply too tight for cold start. Raise it to ~60s (warm scenes still break out
in ~1-2s, so the happy path is unchanged). Best-effort job; it never blocked the
release, but now it goes green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RPM %files %doc referenced design/implementation-plan.md, but the design/
docs live in the separate planning repo now — the file isn't in the public tree,
so rpmbuild failed at %files ("File not found: .../implementation-plan.md") once
the build got that far. Ship the two docs that actually exist (README.md +
packaging/README.md). Pre-existing spec rot, surfaced by the 0.8.3 release run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The re-architecture split the Linux client into two binaries (shell + Vulkan
session streamer) and added the pf-ffvk crate, whose build.rs runs bindgen over
FFmpeg's libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>). Two release
regressions fell out of that on the desktop-package legs:
1. Missing build dep. The Arch build (archlinux:base-devel + its own pacman list)
fatal-errored at `vulkan/vulkan.h file not found`. Add the Vulkan dev headers
wherever the client compiles from a self-managed dep set: arch (PKGBUILD
makedepends + arch.yml), rpm (spec BuildRequires + rpm.yml + fedora image),
deb.yml (belt-and-suspenders; the rust-ci image already bakes libvulkan-dev),
and the screenshots job. The flatpak builds offline against the GNOME SDK, so
install Vulkan-Headers into /app as a pinned module and point pf-ffvk's bindgen
at it via PF_FFVK_VULKAN_INCLUDE.
2. Only the shell shipped. arch/deb/rpm built and installed just punktfunk-client;
the shell execs its sibling punktfunk-session for a connect, so desktop
streaming would break exactly as Decky's did. Build and install BOTH binaries
in all three, and declare the runtime Vulkan loader the session binary dlopens
(vulkan-icd-loader / vulkan-loader / libvulkan1 — not a DT_NEEDED, so
shlibdeps/auto-requires can't see it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo fmt --all --check` (ci.yml) was red on main: the client re-architecture
commits and origin's windows-shortcut commit landed with rustfmt violations
(e.g. a 104-char .with_context line in hyprland.rs, an unsorted mod block in
vdisplay.rs, the input.rs `{`-placement CI flagged). Reformat the tree so the
fmt gate passes; no functional changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 0.8.3: the native Linux client is re-architected — the GTK4 shell + GL
presenter give way to a two-binary design (phases 0–6). A relm4/libadwaita
desktop shell (host discovery, pairing, library) now hands desktop connects to a
dedicated Vulkan session binary, punktfunk-session: an SDL3 window + ash
presenter carrying a Skia "console" UI — stats OSD, capture HUD, and a
gamepad-navigable coverflow library with --browse. The split lands the streaming
wins the GL path couldn't: VAAPI dmabuf → Vulkan zero-copy import + CSC (no GL
round-trip), Vulkan Video decode on the presenter's own device for NVIDIA
hardware decode (via a new pf-ffvk bindgen shim over FFmpeg's Vulkan hwcontext),
and HDR10/P010 end to end. The console background is now an animated mesh gradient
matching the Apple client's MeshGradient. Along the way: a resize crash fixed
(never vkDeviceWaitIdle while the pump decodes), keyframe recovery when the
decoder produces no output, decode-stage stats that measure GPU completion, an
Alt+Enter fullscreen alias, pointer-lock + per-iteration motion-flush input
fixes, and the host-card hover highlight made concentric.
Packaging + Decky: the flatpak now builds and ships BOTH client binaries (shell +
session) so the Deck's stream/browse paths survive the split — the session
binary's Skia is fetched offline from a pinned, sha256-verified archive; the
mgmt/library port 47990 is opened on the LAN firewall profiles; and the plugin's
install-from-URL uses the unom.io/pf-decky short link.
Other clients + core: Windows gains the two missing stream shortcuts
(Ctrl+Alt+Shift+S stats, F11 fullscreen) plus an in-stream shortcut reference on
both clients; Android promotes the low-latency pipeline to default and attaches
its APK to CI; the host coalesces keyframe-request storms into one IDR per
cooldown; the core jumps to live on a standing receive backlog instead of
ratcheting latency; and two security bumps clear the audits (crossbeam-epoch
RUSTSEC-2026-0204, vulnerable web transitive deps).
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 14 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so with 0.8.3 the newest stable it stays at 0.9.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The console UI's animated background was a port of the Apple client's LEGACY
fallback — four drifting radial blobs. Apple's shipping look is a 4×4
MeshGradient. Replace the blob field with an SkSL shader that reproduces it: a
smooth bicubic (separable cubic-Bézier) blend of the same 16 sRGB mesh colours,
the four interior control points driving a bounded (weight-normalised) domain
warp so the bright brand-violet pools drift while the frame edges stay pinned —
then the same ±8°/~5-min hue sway, elliptical vignette, and vertical legibility
scrim as the Swift composite(). Same [u_res, u_t] uniforms, so the draw path is
untouched beyond the rename (draw_aurora → draw_background, aurora → mesh).
Verified: the SkSL compiles on Skia's CPU frontend (unit test), and CPU-raster
renders at several time points show the dark-cornered field, the centred bright
pool, warm-left/cool-right temperature split, and the slow interior drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The re-architected Linux client is now two binaries: the punktfunk-client shell
execs its sibling punktfunk-session (ash/Vulkan presenter + Skia console UI) for
--connect/--browse. The Decky plugin's stream and browse paths launch the shell
with exactly those flags, but the flatpak built and installed only the shell, so
streaming and the gamepad library from the Deck failed at exec with
"punktfunk-session: No such file" (pair/wake/library still worked — the shell
handles them in-process).
Build and install both binaries. The session binary pulls in Skia (skia-safe),
whose build script downloads a prebuilt libskia — dead in the offline sandbox —
so point skia-bindings at a pinned, vendored archive via SKIA_BINARIES_URL=file://
(read directly, no curl); the tarball rides along as a sha256-pinned flatpak
source. Widen the flatpak CI path filters to the session binary's crates
(linux-session, pf-presenter, pf-console-ui, pf-client-core) and fix the moved
library.rs path in the Decky error-classifier comment.
All other plugin↔client contracts (flags, pairing/library output, config files,
env vars, exit codes, the 47990 mgmt port) already match — no changes needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FlowBoxChild draws the hover/selection highlight AROUND the card
(it wraps it with its own padding ring), so its default corner radius
ran visibly tighter than the card's 12px inside it. The host grids now
scope a 15px radius onto their children — card radius + the ring — so
the highlight's corners run concentric with the card's.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client now advertises VIDEO_CAP_10BIT|HDR and carries the result all
the way to glass:
- csc_rows is bit-depth exact (10-bit limited code points differ from
8-bit by ~half a code) and folds in the P010/X6 MSB-packing factor;
new 10-bit white/black tests.
- The CSC shader grows a params block: mode 0 passes the transfer
through (SDR as-is, or PQ onto an HDR10 swapchain); mode 1 tonemaps
PQ→SDR in-shader (ST.2084 EOTF, 203-nit reference white exposure,
BT.2020→709, soft maxRGB rolloff, sRGB encode) for desktops without
an HDR surface. PUNKTFUNK_TONEMAP_PEAK tunes the rolloff.
- The presenter probes VK_EXT_swapchain_colorspace + an HDR10/ST.2084
10-bit surface format and flips modes in-band with the stream's PQ
signaling: fence-quiesce, then CSC pass + video image (10-bit
A2B10G10R10 intermediate — PQ in 8 bits bands) + overlay pipe +
swapchain rebuild through the deferred-destroy rules.
- P010 decodes through all three paths: Vulkan Video (X6 multiplanar
pool, R10X6 plane views), VAAPI dmabuf (R16/RG1616 plane imports),
software (swscale as before).
- session pump advertises the caps; the host still gates Main10 behind
its PUNKTFUNK_10BIT policy.
Probed on glass hardware: the KDE/NVIDIA surface exposes
A2B10G10R10+HDR10_ST2084, so true PQ passthrough is available there.
Known v1 gaps: software-decode PQ shows untonemapped (8-bit RGBA
carries the transfer baked); the SDR overlay composites unscaled onto
an HDR10 surface (dim OSD); no vkSetHdrMetadataEXT yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vkDeviceWaitIdle's external-sync rule claims EVERY queue on the device;
with Vulkan Video the session pump concurrently submits FFmpeg decode
work to the shared device's video queue, so the resize path's wait-idle
(and the video-image/staging rebuilds') raced it — observed as a crash
on window resize mid-stream (software/VAAPI never touched the device
from the pump, which is why this only appeared now).
Mid-session quiescing is now fence-only ( waits the single
in-flight fence, which covers every command buffer WE submitted), and
the replaced swapchain + its per-image semaphores/overlay targets are
parked in a retire list — the presentation engine may still hold the old
swapchain's final semaphore wait, which no fence covers — and destroyed
after the next present on the successor completes a fence cycle. The one
legitimate device-wide idle left is teardown, and the run loop now JOINS
the pump thread first (SessionHandle carries the JoinHandle; quick — the
pump notices stop within its 20 ms receive timeout).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Vulkan path's receive_frame returns at SUBMISSION (~0.1 ms) — the
hardware decodes asynchronously, so the decode stat was truthful but
measured the wrong boundary. The pump now ships the frame to the
presenter FIRST, then waits the frame's timeline fence (vkWaitSemaphores
resolved through the shared device's proc chain) and stamps
received→decode-COMPLETE — true NVDEC time at zero pipeline cost, since
the presenter's own GPU wait is what actually gates sampling. Software/
VAAPI keep their synchronous stamps.
Also: Alt+Enter joins F11 as the fullscreen toggle (some keyboards' Fn
layer sends a media key for plain F11 — observed on glass as
'F11 only works with shift'); the shell's shortcuts panel lists both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two capture bugs compounding into 'sluggish and not captured':
1. flush_motion was never called from the run loop — the GTK client's
frame-clock tick flushed pending motion every frame, and the port
lost it. Pure mouse movement only reached the host when a click/key/
scroll happened to flush it; the host cursor simply didn't follow.
Now one coalesced MouseMove per loop iteration.
2. Capture forwarded ABSOLUTE letterboxed coordinates with no pointer
confinement (GTK parity, the plan's deferred 'stage-2' item): the
visible local cursor outran the host cursor by the full e2e and
escaped the window. Capture is now real pointer lock — SDL relative
mouse mode (hidden, confined, raw deltas) with relative MouseMove on
the wire, so the host cursor is the only cursor.
Also: focus-gain re-engages after an auto-release (Alt-Tab undoes
itself; the startup focus race can no longer strand the session
uncaptured) while a chord release stays released until the user opts
back in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The silent settings-driven software path cost a debugging round on the
first Vulkan Video glass test (stale decoder=software from the VAAPI-
broken-on-NVIDIA era) — now it says so. Module docs updated: auto is
vulkan → vaapi → software.
Validated on glass (RTX 5070 Ti, HEVC 4K@144): decode 11 ms → 0.1 ms,
e2e p50 ~115 ms → 8.6 ms (the old number was software-decode queueing,
not clock skew), 144 fps locked, 2686 frames, zero errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FFmpeg's Vulkan Video decoder now runs on the PRESENTER's own VkDevice —
the decoded VkImage feeds the existing CICP CSC pass directly: zero
copy, no interop, and NVIDIA gets hardware decode for the first time
(its VAAPI is unusable by design). One decode architecture for every
vendor going forward; VAAPI-dmabuf and software remain the fallbacks
(auto: vulkan → vaapi → software; PUNKTFUNK_DECODER=vulkan pins it).
Presenter: instance 1.3; probes VK_KHR_video_queue/decode_queue + codec
extensions, a VIDEO_DECODE queue family (+ its codec caps via
QueueFamilyVideoPropertiesKHR), and the samplerYcbcrConversion/
timelineSemaphore/synchronization2 features — all enabled at device
creation when present, exported as a VulkanDecodeDevice handle bundle.
Decoder: AVVulkanDeviceContext built over those handles via pf-ffvk
(features chain, extension lists, deprecated queue indices + the qf[]
map); get_format supplies OUR frames context with MUTABLE_FORMAT so the
presenter's per-plane views are legal; output is DecodedImage::VkFrame
carrying AVVkFrame/frames-ctx pointers plus the lock fns.
Present: R8+R8G8 plane views over the multiplanar image, the live sync
state read under the AVVulkanFramesContext lock, a timeline-semaphore
wait(sem_value)/signal(sem_value+1) folded into the submit, layout/
queue-family/sem_value written back per FFmpeg's contract, and the frame
guard parked in the retire queue until the fence. CSC pass + video
framebuffer are now unconditional (NVIDIA has no dmabuf-import path).
Verified on the RTX 5070 Ti: device creates with decode_qf=3,
caps=DECODE_H264|H265|AV1|VP9; swapchain unaffected. Live stream
validation next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ffmpeg-sys-next binds a curated header list that omits every
hwcontext_*.h, so AVVulkanDeviceContext/AVVulkanFramesContext/AVVkFrame
— the surface that lets FFmpeg's Vulkan Video decoder run on the
presenter's own VkDevice and hand the decoded VkImages back — had no
Rust bindings at all. pf-ffvk generates them at build time from the
SYSTEM headers, which is the only ABI-safe option: the struct layout
depends on FF_API_* deprecation gates resolved in libavutil/version.h
(confirmed: FFmpeg 8.1 still carries the FIXED_QUEUES fields). Ships
ash<->vulkan.h handle conversions; opaque AVHW*/AVFrame types keep
ffmpeg-sys-next the owner of the core surface (pointer casts across).
Needs vulkan-headers (Arch) / libvulkan-dev (CI, added);
PF_FFVK_VULKAN_INCLUDE overrides for cross builds. Verified: bindgen
layout tests pass, av_vk_frame_alloc links and zero-initializes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under infinite GOP the pump only re-requested an IDR when the
reassembler's drop count climbed. A lost initial IDR (or a mid-GOP join)
delivers complete-but-undecodable delta frames instead — the reassembler
never drops, so recovery never fired and the stream froze on the last
good frame while libavcodec flooded stderr with missing-reference
errors. Reproduced at 4K@144 (large IDRs, higher loss); lower modes hid
it. Now a 3-frame no-output streak (~50 ms) forces a fresh IDR,
throttled and re-armed across the request→IDR round trip. Verified on
glass: 4K@144 recovers and holds. Also quiets libavcodec's raw stderr
(it bypassed tracing) to fatal-only, PUNKTFUNK_FFMPEG_LOG restores it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GTK client becomes a relm4 component tree: AppModel owns the window,
trust gate (rules 1-3), and the spawned session child's lifecycle as
typed messages; the hosts page is a child component with a
FactoryVecDeque of host cards — the HostsCallbacks Rc<dyn Fn> bag, the
busy Cell, and the Rc<RefCell<HostsUi>> cross-page pokes are gone.
Trust/settings/library dialogs stay plain GTK, invoked from update with
a ComponentSender.
Deleted with the in-process presenter: ui_stream.rs, video_gl.rs,
ui_gamepad_library.rs, launch.rs, the khronos-egl dep, and the
PUNKTFUNK_LEGACY_PRESENTER hatch. Every stream (and the console library)
now runs in punktfunk-session; the shell spawns it for card connects and
exec's it for --connect/--browse so the Decky wrapper keeps working. The
request-access flow gains --connect-timeout + a CancelHandle that kills
the child. Screenshot scenes re-pointed onto the components (verified:
hosts + library self-capture; the dialog scenes present correctly and
capture under a GL renderer); the gamepad-library scene is gone with the
page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The run loop grows a browse mode: the console library idles between
streams in ONE window (no gamescope handoff), overlay actions launch
sessions via run_browse's callback, session end returns to the library.
The Overlay contract gains menu routing (MenuEvent → haptic pulse),
action draining, and session-phase edges.
pf-console-ui ports the GTK launcher wholesale: the coverflow's springs,
cursor arithmetic and recede/tilt constants move verbatim with their
tests (plus new projection tests — focused-card centering, the inner-
edge-recedes corridor); paint order is draw order (the gtk::Fixed
restack hack is gone); the aurora renders as an SkSL runtime shader at
full rate on every box (the 30 Hz CPU-upscale path and its frozen-on-
Deck fallback are deleted — the generated SkSL is compile-tested);
titles/scenes shape through textlayout (CJK-safe). Poster art streams
in as encoded bytes through the shared model and decodes renderer-side.
The session binary wires --browse host[:port] [--mgmt PORT]: KnownHosts
lookup (unpaired renders the pair-first scene), library + art fetch on
threads, PUNKTFUNK_FAKE_LIBRARY dev hook, and a session_params helper
shared with --connect. The minimal build refuses --browse cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The §6.1 presenter↔console-UI contract lands: pf-presenter exposes its
device (SharedDevice) and composites at most one premultiplied-alpha
quad per frame (new overlay.frag + LOAD render pass over the swapchain;
zero cost while the overlay returns None). pf-console-ui implements it
with skia-safe on the shared VkDevice: DirectContext via the ash
dispatch chain, a ring of two offscreen render targets (one-frame-in-
flight safe), damage-driven redraws — the OSD re-renders at 1 Hz, the
hint on capture toggles, nothing per-frame. Skia never touches the
swapchain. The session binary carries it behind the default ui feature:
4.9 MB stripped without, 10 MB with (measured).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Faults every dmabuf import so the failure-streak → force_software →
software-decode recovery is exercisable on healthy hardware — the plan's
§8 phase-2 acceptance requires demoting via a deliberately faulted
import rather than waiting for a broken driver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
start_session_with routes desktop windowed connects to the spawned
Vulkan session binary (--connect --fp, --launch, --fullscreen from the
stream setting); spawn.rs bridges its stdout contract into the shell —
spinner until {"ready":true}, banner from the error/ended JSON line,
exit 3 + trust_rejected routed to the re-pair PIN ceremony, TOFU pins
the advert fingerprint only once the child proves it on a real connect.
The in-process GTK presenter stays for PUNKTFUNK_LEGACY_PRESENTER=1,
Gaming-Mode/--fullscreen and --browse launches (no second toplevel under
gamescope until phase 4 moves the console UI), the request-access flow,
and any spawn failure (silent fallback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decoder's NV12 dmabuf imports per-plane (R8 + GR88, explicit DRM
format modifier, dedicated dup'd-fd import, FOREIGN→graphics acquire)
and a fullscreen-triangle render pass converts it into the presenter's
video image with the CICP-driven coefficients ported from video_gl.rs
(same tests, plus a rows-vs-matrix agreement check). SPIR-V is committed
(shaders/build.sh regenerates) so builds and CI need no toolchain. The
import extension set is probed at device creation; unsupported boxes and
3-failure streaks demote the decoder to software via the existing
force_software contract. The session binary now honors the Settings
decoder preference instead of forcing software.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
punktfunk-session streams one --connect session in an SDL3 window: ash
swapchain with a transfer-only letterboxed blit of the software-decode
path (no graphics pipeline until the phase-2 dmabuf/CSC pass), the
ui_stream input-capture state machine on SDL events (scancode→VK table
cross-checked against the evdev one), gamepads via a new caller-pumped
GamepadService mode (SDL video owns the main thread here), and the
shell↔session stdout contract: {"ready":true}, per-window stats:
lines, JSON error + exit codes 0/2/3/4. Strict trust — no pin, no
connect. Design: punktfunk-planning linux-client-rearchitecture.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads, keymap, trust
store, mDNS discovery, library client and Wake-on-LAN move verbatim from
clients/linux into crates/pf-client-core, shared with the upcoming Vulkan
session binary (punktfunk-planning: linux-client-rearchitecture.md).
The GTK client re-exports them at the crate root so every existing
crate::-path keeps resolving; its manifest drops the moved-only deps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows was missing two of the four stream shortcuts the GTK client has:
Ctrl+Alt+Shift+S (toggle the stats overlay live) and F11 (toggle fullscreen).
Add both to the low-level keyboard hook — S flips a HUD_VISIBLE atomic seeded
from Settings::show_hud at install (Settings is the default, the key overrides
it for the session, matching GTK), F11 drives a borderless-fullscreen toggle on
the window HWND and re-locks the pointer geometry for the new client rect. Both
are consumed locally, never sent on the wire.
Surface the full key set in two places, on both clients:
- in the UI: a read-only "In-stream keyboard shortcuts" reference card in the
Windows Settings > Input section (the counterpart of the GTK Shortcuts
window), plus the expanded HUD hint; the Linux keyboard hint gains F11.
- at stream start: a bottom-centre banner listing the shortcuts for the first
few seconds of every session, independent of the HUD setting. Linux gets the
matching start-flash of its capture hint (capture engages on map and hid it,
so the keys were never shown until the first release).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The APK was only reachable via the generic registry (or attached to a Gitea
Release on a vX.Y.Z tag) — a main-push canary or a PR/dispatch run surfaced no
downloadable APK on the run page itself. Add an upload-artifact step (v3, per
Gitea's GHES-identifying artifact backend, like apple.yml) that grabs whichever
APKs were built — the signed universal release APK on a main/tag push, else the
debug APK — so any run is a one-click sideload download.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The async decode loop (burst-feed + present-newest-per-vsync, the Apple client's
discipline) plus the per-SoC tuning was gated behind an experimental,
default-off toggle after the 5dc24a0 overhaul regressed on some phones. That
regression was the receive-side latency ratchet the async loop fed — a standing
queue that only grew — now fixed in the shared core (FrameChannel jumps to live
instead of accumulating it), so the fast pipeline is the default again.
Default the master toggle on via a fresh pref key (low_latency_mode_v2),
mirroring the migration da376b31 used to flip it off: a new key re-defaults every
install — including ones persisted off under the old key — to on, so the
promotion reaches users who had saved settings, not just fresh installs. Both
stale keys are abandoned unread. Toggle-off still restores the original
synchronous decode pipeline byte-for-byte as a per-device escape hatch.
Drop "(experimental)" from the settings labels and fix the now-stale default-off
wording in the native + Kotlin docs. No decode-path routing change — run_async is
reached simply because the toggle now defaults on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clients request a keyframe on every FEC-unrecoverable frame and keep asking
until the IDR actually arrives and decodes — a full round-trip on a link that is
already behind. The host answered every request with a full IDR and only
rate-limited under intra-refresh (opt-in, off by default), so on the default
path a Wi-Fi loss burst produced a 20-40x bitrate spike storm that deepened the
very loss it was recovering from — the second half of the periodic double-jolt.
Coalesce a request storm into at most one forced IDR per cooldown ALWAYS: serve
the first immediately (a genuinely wedged decoder still recovers at once), then
suppress for the window (2 s under intra-refresh's healing wave, 750 ms for a
full-IDR recovery — long enough to swallow one recovery event's round-trip echo,
short enough to re-issue a lost IDR promptly). Seed the cooldown at session open
and stamp it on both rebuild paths so the cold-open / post-rebuild storm
coalesces into the IDR the fresh encoder already emits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embedder-facing frame queue was a 16-deep sync_channel whose try_send
dropped the NEWEST access unit on overflow — backwards for a live stream (keeps
stale, discards fresh), a ~266 ms floor that could not self-drain (producer and
consumer both run at frame rate, so any depth a burst injects is conserved
forever — the latency ratchet), and a silent reference-chain break the loss
counters never saw. The clock-based flush meant to catch it was gated on the
skew handshake and never even drained that queue.
Replace it with a purpose-built FrameChannel (VecDeque + Condvar) exposing
depth() and clear(). Pre-decode AUs are reference-chained under the host's
infinite GOP, so they are never dropped mid-stream; instead, when the embedder
falls persistently behind, the pump JUMPS TO LIVE — flush_backlog() + clear the
queued AUs + request a keyframe — so decode re-anchors cleanly at an IDR.
Two cooldown-gated detectors, both suspended during a speed test:
- clock-based (existing): > FLUSH_LATENCY behind the skew-corrected clock for
FLUSH_AFTER_FRAMES straight; also catches kernel/reassembler backlog.
- clock-free (new): the hand-off queue sat >= QUEUE_HIGH without draining to
QUEUE_LOW for STANDING_FRAMES straight. Works on same-clock / no-handshake
sessions where the clock path is disarmed — the direct "the embedder can't
keep up" signal. A transient Wi-Fi clump drains in a few frames and never
trips it.
Bounded (90-frame hard cap, drop-oldest memory backstop) and diagnosable (each
jump logs queue_depth / flushed_datagrams / dropped_frames). next_frame's
external Timeout/Closed contract is unchanged, so every native client inherits
the fix. Adds 5 FrameChannel unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mgmt REST API has bound 0.0.0.0:47990 by default since ae51276 so paired
clients can browse the game library over mTLS, but every packaged firewall
opener still excluded 47990 and the docs still claimed it was loopback-only.
On any host with an active firewall (ufw/firewalld) the LAN game-library
feature was silently broken.
Add 47990/tcp to the native firewall profiles (punktfunk.ufw [punktfunk-native]
+ punktfunk-native.xml) and correct the stale "loopback-only by default" text
across the debian/arch/bazzite READMEs and the docs site (incl. the factually
wrong --mgmt-bind default in host-cli.md, 127.0.0.1 -> 0.0.0.0). Opening the
port adds no admin exposure: off-loopback mgmt::require_auth serves only the
read-only status/library allowlist to a paired client cert; the bearer-token
admin surface stays loopback-only regardless of the bind.
Windows was already sound (shared parse_serve binds 0.0.0.0; service.rs already
firewall-opens 47990) — add a clarifying comment so the rule isn't mistaken for
accidental over-exposure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full git.unom.io package URL is painful to read/type on a Steam
Deck's on-screen keyboard; unom.io/pf-decky now redirects to it
(added in unom/infra's Caddyfile). Canary/pinned links stay long-form
since only /latest gets a short link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Force patched undici/dompurify/postcss/esbuild/js-yaml pulled in
transitively via @unom/ui's payload/vite/storybook chains, and bump
vite 7.3.5 -> 7.3.6 so esbuild resolves consistently to 0.28.1
instead of splitting across 0.27.7/0.28.0. Closes all 26 bun audit
findings (3 high, 17 moderate, 6 low).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Release 0.8.2: native AMF encoder reconnect reliability on Windows AMD — a
second connection no longer comes up black (the encoder now Flushes before
Terminate so a reconnect's overlapping teardown can't strand AMD's VCN encode
session, self-heals with a full context rebuild when a reconnect still wedges,
and logs a per-context bring-up number + first-AU line so a silent wedge is
visible); a native data-plane hardening pass that keeps the stream alive across
real Wi-Fi links; Android streaming wake/Wi-Fi locks that actually engage plus a
console-UI polish pass (per-controller glyphs, scrollable dialogs, animated
forms); and gamescope-takeover survival on Bazzite's SDDM session supervisor.
Also a Linux-client crash fix — the FlowBox activation cycle that stack-
overflowed on every host-card click.
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 9 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag (scripts/ci/pf-version.sh), so
cutting v0.8.2 auto-advances canary to 0.9.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second connection to a Windows AMD host came up black with nothing in the
logs. The native AMF encoder's teardown never Flushed before Terminate, so a
reconnect whose teardown overlapped the new session (a client may not signal an
explicit exit, so session 1 tears down late — on the reconnect preempt grace or
the QUIC idle timeout) left AMD's limited VCN encode-session slot occupied. The
new session's Init then opened onto a wedged session that returns AMF_OK but
never emits an AU. NVENC has no equivalent per-session cap, so NVIDIA never
showed it. Recovery couldn't help either: the stall watchdog re-Init'd the SAME
context, which can't clear a context/VCN-level fault, so it looped a dead
context until MAX_ENCODER_RESETS ended the session.
Reliability:
- Component::drop now Flushes before Terminate (mirrors reset() and the design
doc), releasing the VCN session cleanly so the next session's Init gets a free
slot.
- reset() escalates to a FULL context teardown once an in-place re-Init has run
without producing an AU (resets_without_output >= 2), so a wedged reconnect
self-heals via a fresh CreateContext+InitDX11 within the reset budget instead
of re-initing a dead context in a loop.
Logging (the failure was silent):
- Per-context bring-up sequence number (context #N) — distinguishes a first
connection from a reconnect's fresh context.
- A one-shot "AMF produced its first AU on this context" line; its absence after
a context #N bring-up is the smoking gun for a silent VCN wedge.
- Terminate result logged on drop for both the component and the context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>