New arch.yml builds the split PKGBUILD (host/client/web, PF_WITH_WEB=1) in an
archlinux:base-devel container on every push and publishes to the pacman repos
'punktfunk' (tags) / 'punktfunk-canary' (main, X.Y.Z-0.<run#> — pkgrel allows
only digits+dots, so the run number carries the ordering). Consumers add one
pacman.conf section; no more build-it-yourself as the only Arch path.
PKGBUILD: pkgver/pkgrel env-driven (PF_PKGVER/PF_PKGREL), source=() when
PF_SRCDIR is set (a canary version has no tag to clone), stale NVENC-only
header fixed, and options=('!lto' '!debug') — makepkg's lto option injects
-flto=auto into CFLAGS, aws-lc-sys compiles its C with it, and rust's lld
cannot read GCC LTO bitcode: 'undefined symbol: aws_lc_*' at link (reproduced
minimally on Arch + rust 1.90). Full build + clean-container install
smoke-tested locally (binaries run, payload + scriptlets intact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layering is a last resort per the Bazzite docs (slows every OS update, can
block upgrades until removed); a sysext never enters an rpm-ostree
transaction, survives OS updates, and installs/updates with no reboot —
the mechanism Fedora Atomic ships via fedora-sysexts.
- build-sysext.sh wraps the built host+web RPMs into punktfunk-<V-R>-x86-64.raw:
/etc payload relocated to /usr/share/punktfunk/etc (a sysext carries only
/usr), the punktfunk-sysext helper embedded, ID=fedora + VERSION_ID pinned
(merges on Bazzite via ID_LIKE; REFUSED after a major rebase instead of
running soname-broken binaries — both behaviors validated live on Bazzite 43).
SELinux labels are baked in as squashfs pseudo-xattrs from matchpathcon:
unlabeled files run fine for user units but system daemons are DENIED
(udev couldn't read the gamepad rule under enforcing) — validated on-glass.
Refuses duplicate input package names (a stale noarch punktfunk-web next to
the x86_64 one built a chimera image with the dead node launcher once).
- punktfunk-sysext.sh: install/update/status/remove against per-Fedora-major
feeds (…/generic/punktfunk-sysext/f43[-canary]), SHA-256-verified, applies
the udev/sysctl scriptlet work + /etc copies, prints the layering-migration
hint. Live-validated on the .41 Bazzite box incl. service restart + web console.
- publish-sysext-feed.sh + rpm.yml: build + publish the image per matrix leg
(fedver 43/44), canary feeds pruned to 6, stable release assets attached.
- update-punktfunk.sh warns when the sysext shadows a layered install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The KHR variant reads 32-bit attrib pairs; the pointer-sized array fed it
garbage and every plane import came back rejected (observed on-Deck; the
new fallback ladder caught it and demoted to software exactly as designed).
Also print the real EGL error enum instead of its discriminant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ABI_VERSION was doing double duty: the embeddable C surface AND the punktfunk/1
Hello/Welcome version that hosts equality-check. The WoL feature's v3 bump added
a client-local FFI function without changing a single wire byte — and every new
client started refusing against every deployed host ("ABI mismatch: client 3
host 2", observed live Deck → Bazzite). The wire now carries its own
WIRE_VERSION (still 2); ABI_VERSION stays 3 for the C header and the mgmt API's
informational field. Bump WIRE_VERSION only when the handshake/planes actually
change incompatibly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The notarized Developer ID .dmg was SIGKILLed at launch ("Launchd job spawn
failed", POSIX errno 163) before main() ran: the sandboxed macOS app declares
the MANAGED keychain-access-groups entitlement, which AMFI only honors when an
embedded provisioning profile authorizes it. The DMG embedded none — App Sandbox
and the network/device keys are self-asserted for Developer ID, but a keychain
access group is not — so every launch was killed at spawn. Validly signed and
notarized (Gatekeeper accepted it), which is why this looked like a mystery. ⌘R
and the App Store build hid it: Xcode embeds a development / App Store profile;
the raw-codesign DMG path did not, so "⌘R == DMG" never held for this entitlement.
Embed a "Punktfunk macOS Developer ID" profile (Keychain Sharing) into
Contents/embedded.provisionprofile before codesign so its entitlements authorize
the access group, exactly like the App Store build's profile does. If the profile
isn't installed on the runner, warn and strip keychain-access-groups instead so
the app still launches via ClientIdentityStore's legacy file-keychain fallback —
a missing/expired profile can never reship the errno-163 brick again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Wake-on-LAN batch landed with lints that fail `clippy -D warnings`
(doc continuation, char-array split, io::Error::other, redundant closure)
and an ungated `mod wol;` in the Windows client, which pulls windows-only
crates into the non-Windows stub build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VAAPI decode stays; what changes is who touches the YUV. The direct path hands
the NV12 dmabuf (tiled AMD modifier since Mesa 25.1) to GdkDmabufTexture, and
GTK's tiled-NV12 import renders corrupt/gray/washed-out on the Deck. Moonlight
and mpv are clean on the same box because they import the dmabuf into their own
EGL context and convert with their own shader — video_gl.rs is that
architecture for the GTK client: per-plane EGLImages (R8 + GR88, modifier
passed through) → our YUV→RGB shader (matrix/range from the stream's CICP
signaling, unit-tested) → RGBA texture in a GdkGLContext-shared context →
fence-synced GdkGLTexture. GTK composites plain RGBA; no YUV negotiation, no
compositor CSC.
The Deck's decoder default flips back to hardware (the software stopgap is
gone); desktops keep the direct dmabuf path (offload/scan-out eligible).
PUNKTFUNK_PRESENT=direct|gl overrides either way. New failure ladder: GL
converter init failure or a convert-error streak raises a shared flag and the
session pump demotes the decoder to software with a keyframe re-request — the
same mechanism also closes the old silent-black-screen gap where a rejected
dmabuf import had no recovery at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each client learns a host's MAC from the mDNS `mac` TXT while it's awake, persists it on the saved-host record, and — when reconnecting to an offline host — sends a magic packet before connecting, plus an explicit "Wake host" action. Apple wraps the C-ABI; linux/windows call the core fn directly (linux also gains a --wake CLI mode); android via a new nativeWakeOnLan JNI export (the mDNS browse record gains a 7th mac field); decky shells out to the linux client's --wake before launching the stream.
iOS/tvOS need the managed com.apple.developer.networking.multicast entitlement (pending Apple approval), so the wake path + UI are gated off via PunktfunkConnection.wakeOnLANAvailable and the entitlement is commented out — keeping iOS/tvOS releasable. MAC-learning stays active on every platform so it lights up the moment it's ungated. macOS works today.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a runtime-free Wake-on-LAN sender in punktfunk-core (per-interface subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated, optional last-known-IP unicast) exposed both as a Rust fn and a punktfunk_wake_on_lan C-ABI (ABI v3), plus a parse_mac helper. The host enumerates its wake-capable NIC MAC(s) and advertises them in a new mDNS `mac` TXT record (routed NIC first), and best-effort detects & warns (never modifies) when the NIC isn't armed for WoL.
MAC delivery is via the unauthenticated mDNS TXT rather than the connection handshake by design: a spoofed MAC only makes a wake fail (the packet is inert; the cert fingerprint still gates the connection), and it avoids threading through the hot connect path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS reserves the controller Home/PS and Share/Create buttons for its own system gestures and never delivers them to the app unless it declares the Game Controllers capability. Add GCSupportsControllerUserInteraction=YES to the macOS target only (iOS/tvOS rely on the focus engine, so it must not be in the shared plist), alongside the existing preferredSystemGestureState=.disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tested on .173: a WDA_EXCLUDEFROMCAPTURE window (affinity readback 0x11,
confirmed active) is pixel-identically visible in the punktfunk/1 stream
across no-flag / flag-set / flag-cleared phases — the flag makes no
difference to a present-tap capture. Replace the "untested, treat as
expected" note in the IDD-push residual list with the measured result,
and correct the framing: WDA visibility matches what a person at the
screen sees (it exceeds an ordinary capture tool, not the physical
viewer).
Add the matching public-facing paragraph to the security page covering
both asymmetries — WDA windows appear (same as a physical viewer), DRM
video is blanked (less than a physical viewer) — tied back to the page's
"a client sees what someone at the machine sees" model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Steam-conflict gate scanned /sys/bus/hid/devices for non-virtual 28DE
devices, but the usbip/gadget virtual Decks present a REAL USB device (vhci
resolves through vhci_hcd, not /devices/virtual/) — so a just-ended session's
pad still detaching, or a concurrent session's live one, read as "physical
Steam controller attached" and degraded every back-to-back Deck session to
DualSense (observed live on Bazzite). Exclude our pads by their PFDK… serial
(HID_UNIQ), with the vhci_hcd path as belt and braces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDL's Steam Deck mapping delivers the pad clicks as gamepad BUTTONS with no
surface identity: the generic `touchpad` button is the LEFT pad's click and
`misc2` the RIGHT's (SDL_gamepad_db.h `touchpad:b17,misc2:b16`). The client
forwarded `touchpad` as wire BTN_TOUCHPAD — which the host maps to the RIGHT
pad click (DualSense convention) — and dropped `misc2` entirely: a left-pad
click registered on the right pad, a right-pad click nowhere, and the
mis-routed state could stick.
Clicks from a multi-touchpad pad now ride the rich plane as TouchpadEx with
their surface, reusing the surface's live contact point (click buttons carry
no position). forward_touch carries the held click through motion frames so a
touch update can't clear a click mid-press, and the flush lifts held clicks on
detach/pad-switch. A DualSense's single touchpad button stays on the button
plane unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Deck's built-in controller can never leave Steam Input ("Steam Controller"
is always-required in the shortcut's matrix; Disable Steam Input only affects
other controller brands), so the raw 28DE:1205 device is the only path to the
trackpads/paddles/gyro. Steam hides it from SDL by launching shortcuts with
SDL_GAMECONTROLLER_IGNORE_DEVICES naming every physical pad it virtualized —
clear it (and _EXCEPT) at startup while single-threaded, logging what Steam set
as field evidence. The post-attach warning now states the real condition (raw
pad never enumerated; sticks + buttons still work) instead of advising a
Steam Input toggle that doesn't exist for the built-in controller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Video (Deck): the VAAPI zero-copy path renders corrupt/gray/washed-out on the
Deck — root-caused to Mesa >= 25.1 exporting radeonsi VCN decode surfaces TILED
(the Flatpak runtime's Mesa 26 drives both the decoder and GTK's GL, and GTK's
tiled-NV12 dmabuf import mishandles it; desktop Tier-1 validations ran distro
Mesa with linear export). `auto` now resolves to software on a Deck (clean,
correct-colour, easily handles 1280x800 HEVC); PUNKTFUNK_DECODER=vaapi still
forces the hw path, with the descriptor modifier dump + GSK_RENDERER as the
bisect levers. Also reserve extra_hw_frames=4 on the VAAPI decoder: the
presenter pins mapped surfaces past receive_frame, and the fixed pool recycling
a surface the renderer still samples is intermittent block corruption anywhere.
Input (Deck): with Steam Input ON for Punktfunk, SDL sees only Steam's virtual
X360 pad — the right trackpad arrives as a plain right stick and the left
trackpad/paddles/gyro not at all, silently. The client now checks once the
post-attach enumeration settles and raises a toast + warn naming the fix
(disable Steam Input for the shortcut). The host logs a one-shot warning when
InputPlumber is running (Bazzite default) since it can grab the virtual Deck
pad and re-emit it under a different identity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v0.7.2 flatpak build failed: `error: Unknown socket type pipewire` — this
flatpak-builder toolchain (and the Deck's flatpak 1.16 override CLI) don't
accept --socket=pipewire. --filesystem=xdg-run/pipewire-0 binds the same native
socket and is the portable form already validated on-Deck (pipewire-0 appears
in the sandbox, client audio node registers, no pw-connect error). Keep only
that + --socket=pulseaudio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ship the flatpak PipeWire-socket audio fix (d1af630) to the stable channel —
a tag is required (main pushes only publish the canary flatpak branch), and
0.7.1 stable users on the Deck have no client audio until this lands. Bump
[workspace.package] + the 9 Cargo.lock workspace entries (CI builds --locked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux client speaks the native PipeWire protocol (audio.rs `pw connect`),
but the manifest granted only --socket=pulseaudio, so the sandbox had just
`pulse/native` and no `pipewire-0`. Playback + mic both died with
"pw connect (is PipeWire running in this session?)" — reproduced live on a
Steam Deck in Gaming Mode (no client audio node ever appeared).
Add --socket=pipewire (canonical) + --filesystem=xdg-run/pipewire-0 (portable
bind of the same socket). Validated on-Deck via a `flatpak override
--filesystem=xdg-run/pipewire-0`: pipewire-0 then appears in the sandbox and
the client registers its "punktfunk-client" PipeWire node with no pw-connect
error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`rpm-ostree upgrade` re-resolves layered packages only when the BASE image
changes; on a frozen Bazzite base (pinned :stable tag / paused rebase) it
reports "No updates available" and never bumps the layered punktfunk even
when newer RPMs are live in the repo — observed on the .41 host stuck at
0.6.0 while 0.7.x sat in the registry.
- Add packaging/bazzite/update-punktfunk.sh: detects the layered punktfunk
packages, refreshes rpmmd, and forces a re-resolve via
`rpm-ostree update --uninstall <pkg> --install <pkg>` (the one-transaction
idiom that actually pulls a new layered version on a static base).
- Document the trap + the fix in packaging/bazzite/README.md, including the
channel gotcha: an enabled punktfunk-canary.repo (<next-minor>.0-0.ciN)
outranks stable X.Y.Z-1, so the box silently tracks canary — enable one
channel only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut 0.7.1 to test the GTK + Decky polish batch (7eea983) on-device: the
host-click / disconnect-chord / gray-screen-recovery / leak fixes on the
Linux client, the Deck launcher perf profile, and the Decky pin/pairing
fixes. 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.7.1 auto-advances canary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the `last_kf_req.is_none_or(...)` guard to satisfy `cargo fmt --all
--check` (CI Format step).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GTK Linux client:
- hosts/library: clicking a card was dead — the handler was on
FlowBoxChild::activate (never emitted on click); bridge child-activated
→ child.activate() on the FlowBox (ui_hosts, ui_library).
- stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were
dropped because the key controller sat on the overlay, which loses focus
to the header back button after nav.push+fullscreen — move it to the
window and remove it on teardown.
- video: a mid-session VAAPI decode error rebuilt a software decoder but
never requested a keyframe, so under the infinite GOP the picture stayed
gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware
decoder, and demote to software only after repeated failures.
- stream: fix a per-session Capture↔overlay reference cycle that leaked the
overlay subtree + the Arc<NativeClient> on every session end — hold the
overlay weakly.
- stream: accumulate the fractional wheel remainder so precision-scroll
(Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped.
- gamepad library: keep the launcher smooth on the Deck — freeze the aurora
and trim the visible card range (fewer 3D offscreen passes) on low-power.
- gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to
diagnose an empty controller list on the Deck.
- cli: --connect host:<badport> silently did nothing; default to 9777 + warn.
- css: add the missing .pf-neutral pill rule; fix the clipped most-recent
accent (inset outline instead of a corner-clipped box-shadow bar).
Decky plugin:
- surface the on-screen library browser: label the host-row Games button.
- fix silent pin data-loss — the detached Games modal captured a frozen
pins array, so pinning a second game clobbered the first; mirror pins in
a ref and track the modal's pinned ids locally for a live label.
- route pair-required hosts through the pairing modal from the fullscreen
Stream button (parity with the QAM panel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 now
(scripts/ci/pf-version.sh), so no canary-base edit is needed — cutting v0.7.0
auto-advances canary to 0.8.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The surfaced install command
flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref
failed with "No such ref 'app/io.unom.Punktfunk/x86_64/stable'". The stable
commit's objects are on the server, but the repo *summary* (what flatpak reads
to resolve refs) listed only canary.
Root cause: each CI run builds a fresh SINGLE-branch local OSTree repo,
build-update-repo regenerates the summary from that one branch, and rsync
uploads it without --delete. Objects for both channels accumulate, but the
summary is overwritten every run and only names that run's branch. Canary runs
on every main push, stable only on tags — so a tag published stable, then the
next canary push clobbered the summary back to canary-only.
Fix: seed the local repo from the live server (rsync repo/ DOWN) before the
build, so it carries every published branch; the build only adds this run's
commit and the regenerated+signed summary keeps both channels. Single shared
repo kept (no URL/Caddyfile change; existing installs fixed transparently).
Adds a refs log after build-update-repo as a clobber tripwire. Also adopts
scripts/ci/pf-version.sh for the canary base (see previous commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every release workflow hardcoded a canary base version (0.5.0 in
Apple/Android/rpm/flatpak/deb, 0.3 in windows-msix/windows-host/decky) that
had to be hand-bumped on each stable release and wasn't. With stable at
v0.6.0, every canary was a version *behind* stable — e.g. the Apple canary
showed up on TestFlight as 0.5.0 while 0.6.0 was already published.
Add scripts/ci/pf-version.{sh,ps1} (bash + pwsh twin) as the single source of
truth: stable = the vX.Y.Z tag; canary = latest stable tag with minor+1,
patch 0 (v0.6.0 -> 0.7.0), so canary is always exactly one minor ahead of the
newest release with zero maintenance. Falls back to the workspace Cargo.toml
version when no tag is fetchable. All workflows now eval/call it and format
their own channel suffix off $PF_BASE; only the canary branch changed, stable
branches and per-channel suffixes are untouched. channels.md drops the old
manual "bump the canary base" release step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mic uplink handed the host pure digital silence on a multi-channel
interface: AVAudioConverter's N→stereo downmix takes channels 0/1, but a
pro interface puts the mic on ONE higher discrete channel. Fold the input
to a mono bus ourselves instead — pick the mic's channel (or sum all) and
resample that to the encoder's 48 kHz stereo, so the silent 0/1 downmix
never happens.
- New "Microphone channel" setting (macOS): Auto (sum every channel — a
lone hot mic passes at full level) or pin 1-based channel N. Picker
appears only for multi-channel devices, driven by the device's input
channel count.
- Diagnostics that make this class of failure self-naming next session:
log the actual live capture device + format + fold mode, warn on a
silent UID fallback, and a one-shot silence tripwire on the EXTRACTED
signal (WARN on 10 s of zeros, else peak dBFS).
- foldToMono extracted as a pure, unit-tested helper (pin / sum-clamp x
interleaved / deinterleaved / mono / out-of-range).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The steady 5 ms mic-test cadence never trips host-side buffering bugs:
the WASAPI crackle (fixed in the previous commit) only reproduced under
a real client's bursty input tap. --mic-burst paces the tone the same
way (two 20 ms Opus packets every 40 ms), so recording the host mic and
counting silence gaps regression-tests the jitter buffer headlessly.
Validated against the fixed Windows host on the lab box: 15 s of bursty
tone, zero mid-stream gaps >=3 ms (gaps confined to the first 40 ms
priming window).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mac → Windows mic passthrough crackled heavily while the identical
stream was clean on the Linux host. Cause: clients push mic audio in
BURSTS on their own clock (the Mac input tap yields ~two 20 ms Opus
packets every ~42 ms) while the WASAPI render loop pulled a block every
~10 ms device period and greedily drained whatever was queued, padding
the rest with zeros — the queue sat near-empty and most periods
inserted mid-stream silence. The Linux backend has absorbed this since
day one with its priming jitter buffer; the WASAPI loop had none.
Port the same semantics: emit silence until ~48 ms is buffered (covers
the worst inter-burst gap), then play from the cushion (zero-filling
only a momentary shortfall), re-prime only after a genuine full drain
(client went quiet). Queue cap raised 80 → 120 ms for burst headroom;
steady-state added latency ≈ the 48 ms cushion.
Diagnosed live on .173: probe tone recording from CABLE Output proved
the endpoint wiring, then the burst-vs-period math explained the
crackle. Build-verified on Windows; on-glass listen pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 3122506):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).
- gamepad.rs menu mode: the worker holds the active pad open while idle
(WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
translates it through a pure MenuNav state machine: edge-triggered
buttons, held-state snapshot on entry/detach (the escape chord that ends
a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
(gsk rotate_3d), dense overlapping side shelves with paint-order
restacking (gtk::Fixed draws in child order), opaque card faces + a
darkening veil for the recede (translucency would bleed the stack
through). The strip lives in an External-policy ScrolledWindow because
a bare gtk::Fixed measures its TRANSFORMED children and inflates the
page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
— nav transitions froze mid-slide in headless captures); shared poster
fetch extracted to library::spawn_art_fetch.
Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the 0xCF host-timing plane (99cb8dd) on all four GUI clients: each
keeps a bounded pending ring of receipt samples keyed by pts, matches the
host's per-AU capture→sent reports against it, and the HUD equation becomes
= host 3.1 + network 6.7 + decode 2.1 + display 2.3
falling back to the combined `= host+network …` term whenever no timing
matched the window (old host / datagram loss) — same total, one split
fewer, never a misleading zero. Apple additionally gains the split as the
only equation line under the stage-1 fallback presenter (receipt is
presenter-independent), a `nextHostTiming` wrapper with its own plane lock,
and a unit-tested `HostNetworkSplitter`; Android extends the JNI stats
array 16→18 doubles (0–15 unchanged); Windows/Linux thread the split
through `Stats` into the HUD and the headless/debug logs.
Docs updated: design/stats-unification.md Phase 2 → implemented (wire
format, fallback semantics), and the docs-site matrix's Sunshine "Host
processing latency" row is now a direct match (ours includes the paced
send; avg vs p50).
Verified here: linux client clippy -D warnings green on the live tree,
windows stub check + hand-verified diff, android cargo-ndk arm64 check
green, apple loopback test extended (needs the rebuilt xcframework + swift
test on the mac). On-glass: pending on all platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decky client batch:
- Pinned games / library picker: per-host game grid (GamePickerModal),
pin/unpin, one-tap streams surfaced on the Hosts tab and QAM
(usePins/streamPin/resolvePinHost, new src/library.tsx).
- Self-update + client-update plumbing (main.py check_update, hooks.ts
applyUpdate) with a CA-bundle-resolving SSL context and per-channel
manifest polling; steam.ts / punktfunkrun.sh launch tweaks.
- scripts/test-backend.py harness for the backend RPCs; README refresh.
Fix: the fullscreen page wrapped <Tabs> in an overflow-visible box, so
Valve's L1/R1 tab slide + autoFocusContents scrollIntoView panned
#GamepadUI itself — the whole Steam UI slid left until a tab was clicked.
Clip the Tabs wrapper (overflow:hidden), matching Valve's own Tabs
containers. (On-glass verification pending — Deck offline this session.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unified-stats equation's host+network stage was one opaque number
because the wire carried nothing but pts_ns. Now the host reports its own
share per frame: when the client's Hello sets VIDEO_CAP_HOST_TIMING (0x08),
the send thread emits a 13-byte 0xCF datagram — [tag][pts_ns u64][host_us
u32] — right after the AU's last packet leaves the socket, so host_us =
capture→fully-sent (capture read/convert, encode, FEC+seal, paced send)
against the same anchor the wire pts carries. Clients correlate by pts_ns
and derive network = (received + clock_offset − pts) − host_us; the two
terms tile per frame by construction.
Back-compat is free in all four combinations: old clients ignore unknown
datagram tags, old hosts ignore unknown cap bits (client keeps the combined
stage). The hardened data-plane format is untouched — this rides the
established QUIC side-plane pattern (0xC8…0xCE). NativeClient ORs the bit
in unconditionally and exposes next_host_timing(); the C ABI gains
PunktfunkHostTiming + punktfunk_connection_next_host_timing (additive).
The synthetic host emits 0xCF too, so pure-loopback protocol tests cover
the plane.
The probe reports the split (host_p50/p95_us · net_p50/p95_us) and is our
direct analogue of Sunshine's "host processing latency" — ours additionally
includes the paced send.
Validated on loopback (synthetic host + probe, debug build): 240/240 AUs
matched, host_p50 6.5 ms + net_p50 6.4 ms ≈ capture→received p50 13.0 ms.
Core suite + new 0xCF roundtrip/truncation test green; host+core+probe
clippy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One stat model everywhere (design/stats-unification.md): four measurement
points (capture/received/decoded/displayed), three stages that tile the
interval exactly, and a HUD that shows the addition explicitly —
end-to-end 14.2 ms p50 · 19.8 p95 · capture→on-glass
= host+network 9.8 + decode 2.1 + display 2.3
replacing each client's ad-hoc mix of overlapping absolutes (the Apple HUD's
three arrow lines that looked sequential but weren't), mean-vs-median decode
times (Windows/Linux), missing same-host-clock flags (Windows/Linux), and
three different names for the same capture→received measurement (probe's
"reassembled", Apple/Android's "client", Windows/Linux's post-decode "lat").
Per client: Apple threads receivedNs through the VT decode via the frame
refcon bit pattern so the decode stage exists at all (stage-1 fallback
honestly degrades to a capture→received headline); Windows carries
FrameTimes through the existing frame channel to the render thread and adds
e2e p50/p95 post-Present; Linux stamps received at AU pop and rides
decoded_ns on DecodedFrame to the paintable-set site; Android pairs receipt
stamps with MediaCodec output buffers via the codec's pts round-trip (JNI
stats array 14→16 doubles, indexes 0-13 unchanged). fps now uniformly counts
received AUs; lost/(received+lost) per window, hidden at zero.
docs-site gains "Understanding the Stats Overlay": what each line means, why
the equation only approximately sums (percentiles), and a line-by-line
Moonlight/Sunshine matrix — including that Moonlight has no end-to-end
number and its "network latency" is an ENet control RTT, so punktfunk's
headline must not be compared against any single Moonlight line.
Verified here: linux client + probe + core check/clippy/fmt green, android
native cargo-ndk arm64 check green. Pending: Windows CI + on-glass, swift
test on the mac, on-device Android.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by a live boot-order test (host started before the user session's
PipeWire): PwMicSource::open returned Ok before the daemon connection was
attempted, so a PipeWire that wasn't running surfaced as an instantly-dead
instance instead of an open failure — and the pump churned
open→die→reopen at heartbeat rate (1 Hz "virtual mic ready" log spam)
instead of backing off.
- PwMicSource::open now has a bring-up handshake (mirrors the Windows
backend): ready only after connect + stream connect succeed, so a
down daemon is an open ERROR and the pump's backoff engages.
- The pump triages deaths: an instance that lived >= 5 s (a one-off
daemon restart) reopens immediately with the backoff reset; one that
died right after opening counts as a failed open and backs off
(2 s → 60 s cap). New pump test rapid_death_backs_off.
Re-validated live: host started with PipeWire stopped → throttled
"unavailable" warns, zero churn; daemon started → mic node up on the
next retry; exactly one pump + one loop thread (no leak).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mic passthrough silently died on real hosts. Root causes, all fixed:
- No liveness anywhere: a PipeWire restart (Linux) or any WASAPI device
error (Windows) killed the backend worker; push() fed the dead queue
for the rest of the host's life. VirtualMic now has a liveness
contract (push -> bool, alive(), discard()) and the new shared
audio::MicPump reopens with backoff, probing on an idle heartbeat so
the mic heals BETWEEN sessions too. Validated live: systemctl restart
pipewire -> node back in ~0.5 s, tone flows through the reopened
backend.
- Lazy creation: the mic device didn't exist until the first 0xCB
frame, but games bind their capture device at launch and never
re-follow. The pump opens eagerly at host start (node exists with
zero clients, elected default source).
- Windows headless dead-end: with VB-CABLE as the ONLY render endpoint
(exactly what the installer ships), the anti-echo guard rejected the
cable as the default render endpoint -> mic permanently dead. The new
wiring_plan (pure, unit-tested on every platform) assigns the mic its
endpoint FIRST (cable reserved for the mic), points the loopback at a
DIFFERENT endpoint, and the capture side now yields (explicit
endpoint or honest error) instead of the mic dying. Plan recomputed
per (re)open — endpoints churn at boot/logon/driver installs.
- Stale bursts: buffered audio from a previous session played into a
newly-attached recorder (observed live). Timestamped chunks + a
consumer-gap check in the process callback age everything past 1 s.
The Linux node mechanism stays the stream-based Audio/Source with
RT_PROCESS + priority.session: the canonical null-audio-sink adapter
recipe was tested on this box (PipeWire 1.6.2) and never gets a clock
(QUANT 0 -> pure silence), and WirePlumber reroutes a feeder targeting
it to the default sink (echo). Decision documented in the module docs.
Live-validated on this box (synthetic host + probe --mic-test,
pw-record): eager node, both attach orderings, PipeWire-restart
self-heal, post-session silence. Windows side compile/CI + on-glass
validation pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DWM presents a display only when something dirties it. On an idle desktop a
perfectly healthy session sat at E_PENDING: the driver attached but no
first frame ever landed, so wait_for_attach's 4 s gate failed the open (and
a mid-session ring recreate hit the same stall against the 3 s
recover-or-drop). A real client escaped only because its own input soon
dirtied the desktop; a headless probe / input-less connect never did.
kick_dwm_compose() injects two net-zero 1 px relative mouse moves via
SendInput — pf-vdisplay has no hardware-cursor plane, so a cursor move is
composited into the frame, a guaranteed real present onto the IDD
swap-chain (the mechanism --input-test always relied on; the pointer ends
where it started). Wired into wait_for_attach (first kick at 600 ms, then
every 800 ms) and, rate-limited, into the GB1 recovery window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass the lazy (first-session) claim let a second host started while the
freshly-restarted service sat idle win the mutex and ADD a monitor on the
real driver — priority backwards. The claim is now a process-global,
retryable slot (a failed claim is not memoized, so it heals once the other
instance exits), and `serve` claims it before any client can connect;
ensure_device keeps the lazy claim for standalone punktfunk1-host runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass the SCM service creates Global\punktfunk-vdisplay-manager as
SYSTEM, so a second elevated-admin host's CreateMutexW fails ACCESS_DENIED
(the implicit open is checked against the SYSTEM DACL) before the
ALREADY_EXISTS branch can fire — right refusal, wrong message. Map it to
the same loud "another instance is live" error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch C of the audit's medium tier (M7+M8+M9):
- M7: GameStream sessions now run the same begin_idd_setup dance as
punktfunk/1 before creating the shared monitor. A GS connect could
previously ADD/reconfigure the monitor while a native session was
mid-build (and vice versa), and its sealed-channel delivery replaced the
native ring (newest-wins) — each plane could freeze the other. GS has no
cooperative stop plumbing, so it registers a flag nobody reads: a later
session signals it, waits the 3 s grace, then force-preempts — the
intended handover.
- M8: the linger-expiry teardown now runs UNDER the state lock. Running it
outside let a concurrent acquire see Idle and ADD+isolate while the old
monitor's pinger-join / CCD-restore / REMOVE were still in flight — a
failed or de-isolated session exactly at the expiry boundary. A racing
acquire now waits the few teardown seconds instead. Lock order stays
state → device everywhere; the pinger takes only the device lock.
- M9: a named mutex (Global\punktfunk-vdisplay-manager) makes a SECOND host
process fail its vdisplay open loudly instead of firing a startup
CLEAR_ALL that razes the live host's monitors mid-stream (the admin
footgun the shared watchdog then masked).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch B of the audit's medium tier (M4+M5+M6):
- M4: adapter_init_finished now reads AdapterInitStatus (was ignored) and
only stashes the adapter on NT_SUCCESS, per the MS sample. A failed async
init previously produced a HUSK adapter: monitors created on it arrive
but the OS never assigns a swap-chain — every session black-screens with
no visible cause (the exact signature live fault-injection produced after
a WUDFHost kill). Unset adapter → ADD fails cleanly (host-retryable) and
a re-entrant D0 retries the init; the status is now in the debug log.
- M5: pooled_device checks GetDeviceRemovedReason on a cache hit — a TDR'd
device was returned for its LUID forever (SetDevice fail-loop, black
virtual display until device teardown); now it falls through to a fresh
create.
- M6: an AvSetMmThreadCharacteristicsW failure no longer aborts the worker
before draining (which stalled the monitor and leaked the WDF swap-chain
object) — continue unprioritized like the MS sample; revert only if MMCSS
actually engaged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The "Punktfunk" shortcut is no longer hidden: it now ships committed
artwork (grid/wide/hero/logo/icon, generated by scripts/gen-steam-art.py
— a pure-stdlib SDF renderer drawing the lens mark + a monoline
"punktfunk" wordmark) applied via SetCustomArtworkForApp /
SetShortcutIcon. Existing installs are unhidden and re-arted once per
ART_VERSION; relaunching the library entry streams to the last host.
- Updates cover the flatpak CLIENT too: check_update compares the
user-scope installed commit against its remote, applyUpdate runs
`flatpak update --user` first (awaited) and the plugin reinstall —
which reloads the panel — last; docs spell out the sudo-less --user
update ("sudo flatpak update" silently skips per-user installs).
- Fullscreen page: DialogButton stretches to 100% width in the gamepad
UI, so the Stream/Pair/Refresh/… actions filled whole rows — sized to
content + right-aligned now; the header drops its Update button (About
tab + QAM banner keep the flow) and the back button gets a real 40px
hit target.
- Settings: the disable-Steam-Input note also shows for Automatic — on a
Deck that now forwards the built-in controller as a Steam Deck pad
(paddles/trackpads/gyro), which needs Steam Input off for the shortcut.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- "Automatic" gamepad type resolves to the virtual Steam Deck pad on Deck
hardware (env SteamDeck / DMI Jupiter|Galileo): the built-in 28DE:1205
identity is invisible at Hello time — the Valve HIDAPI drivers run
in-session only and Steam Input shadows the pad with its virtual X360 —
so auto always fell through to Xbox 360. "steamdeck" is now also
selectable in Settings.
- Chrome-less launches flatten the window CSS (border-radius/box-shadow)
and fullscreen at startup: gamescope never ACKs the xdg fullscreen
state, so adwaita kept the floating-CSD rounded corners + shadow
visible over the stream.
- Gaming-Mode --connect launches quit on session end, so Steam ends the
"game" and the Deck returns to Gaming Mode — previously the app popped
to its own hosts page, stranding the user fullscreen and making the
escape chord read as broken.
- The capture hint is controller-aware; the chromeless hint teaches the
hold-chord ("hold L1+R1+Start+Select to leave") and a quick chord press
re-flashes it.
- Colour bisect for the reported off-colours on the VAAPI dmabuf path:
graphics offload defaults OFF under gamescope (a subsurface hands the
NV12 CSC to the compositor), PUNKTFUNK_OFFLOAD=1|0 overrides, and each
colour-signaling change logs whether GDK accepted the BT.709-narrow
color state (fallback = GDK's BT.601 dmabuf default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three levers to lower and steady decode latency on Snapdragon (Adreno) devices:
- ADPF (Adaptive Performance Framework): a new dlsym-resolved hint session
(native/src/adpf.rs; API-33+, resolved at runtime so there's no build-time
link dependency and libpunktfunk_android.so still loads on API 31/32) tells
the CPU governor the video pipeline runs a per-frame real-time workload, so it
keeps those threads on fast cores at high clocks. It now covers all three
latency-critical threads — the pf-decode feed/drain/present loop, the core
data-plane pump (UDP receive + FEC reassembly), and the audio thread — via a
new generic hot-thread registry on NativeClient (register_hot_thread /
hot_thread_ids; the pump self-registers). The session is built lazily on the
first presented frame, since ADPF createSession rejects a set containing any
not-yet-live tid.
- operating-rate -> Short.MAX ("as fast as possible"): pushes the Qualcomm
decoder to run each frame at max clocks instead of merely sustaining the
display rate at a power-saving clock that adds per-frame decode latency.
- appCategory="game": makes the app eligible for OEM Game Mode / Game Dashboard
performance profiles.
The core registry is cross-platform (gettid on Linux/Android, a no-op
elsewhere) — no Android-specific pollution of the shared core. Host workspace +
64 core tests green; Android arm64-v8a + x86_64 (platform 31) build + clippy
clean. On-device Snapdragon validation pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>