Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Gitea Arch registry signs its DB + packages, so 'SigLevel = Optional TrustAll' fails
non-interactively (pacman still needs the key to verify). Document the one-time
pacman-key import instead; install is then signature-validated under pacman's default
SigLevel (verified end-to-end: clean archlinux container -> repo sync -> install,
'Validated By: Signature').
Also cache /usr/local/cargo/git in arch.yml: the workspace pulls clients/windows'
git-pinned windows-reactor/windows deps to resolve, cloning windows-rs (huge) every run
otherwise — same registry+git cache deb.yml uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 (94b5f48) 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 (57ae00a) 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, 8470419):
`--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 (449a67c) 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>
Fault-injection on-glass showed a killed/crashed WUDFHost leaves the devnode
"started" but HOSTLESS: PnP Status OK, no WUDFHost process, zero device-
interface instances — is_available() then fails every future session at the
vdisplay::open gate (and a reopen inside VdisplayDriver::open finds nothing),
until something cycles the device. Port reset-pf-vdisplay.ps1's adapter
disable→enable step in-process (restart_vdisplay_device): the open gate now
uses ensure_available() (cycle once + bounded re-probe; a genuinely
uninstalled driver — no adapter devnode — still fails fast), and
VdisplayDriver::open retries open_device over a short arrival window after a
cycle, covering the manager's reopen path too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch A of the audit's medium tier (M1+M2+M3):
- M1 driver-death detection: a dead WUDFHost stops publishing, which at the
ring is indistinguishable from an idle desktop — SDR sessions streamed a
frozen frame forever (next_frame's 20 s bail is unreachable once anything
presented). The ChannelBroker's process handle now doubles as a liveness
probe (SYNCHRONIZE at OpenProcess); while no fresh frame arrives,
try_consume polls it (rate-limited) and fails the capturer, landing in the
session's bounded in-place rebuild.
- M2 reopenable control device: the manager's OnceLock-cached handle is now
a retire/reopen DeviceSlot — a gone-classified IOCTL failure (driver
upgrade / WUDFHost restart; pinger, create, or REMOVE) retires the handle
and the next use reopens + re-handshakes. Retired handles are deliberately
kept alive forever: bare-HANDLE holders (pinger, ChannelBroker) rely on
never-closed, and a retired handle only fails IOCTLs. CLEAR_ALL runs on
the FIRST open only (a reopen races live-ish sessions); acquire retries
the monitor create once after a reopen. The JOIN path now probes the
active monitor's WUDFHost pid and preempts a DEAD monitor instead of
handing the rebuilding session its stale target — without this the whole
recovery chain starved to the rebuild budget.
- M3 interface discovery: enumerate ALL interface instances with an
SPINT_ACTIVE filter (a Code-10 devnode at index 0 no longer shadows the
live interface), HDEVINFO behind RAII (error paths leaked one per probe),
the raw device handle wrapped before GET_INFO (leaked on handshake
failure), and the detail-sizing result guarded before the cbSize write.
- pf-driver-proto: SetFrameChannelRequest doc now states the real
adopt-on-success contract (the old wording invited a driver-side
close-on-error — a cross-process double-close against the host's reap).
- install: pf_vdisplay_present() passes /connected so a phantom devnode
can't suppress creating a live ROOT node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five verified findings from the IDD-push/pf-vdisplay deep audit:
- Keyed-mutex acquire (BOTH endpoints): AcquireSync returns WAIT_TIMEOUT
(0x102) / WAIT_ABANDONED (0x80) as SUCCESS-severity HRESULTs, which the
windows-rs Result wrapper erases — a busy slot read as "acquired", so
driver and host could race the same ring texture (torn frames) and the
designed busy-skip backpressure was dead code. Both sides now classify
the raw vtable HRESULT; WAIT_ABANDONED counts as acquired (ownership
transfers — refusing it would wedge the slot forever).
- Host SDR hot path leaked one ID3D11VideoProcessorInputView per converted
frame: the D3D11_VIDEO_PROCESSOR_STREAM ManuallyDrop field suppressed the
release after VideoProcessorBlt. Released by hand now, success or not.
- Driver leaked IddCx's per-acquire surface reference (from_raw_borrowed on
a TRANSFERRED reference — the MS sample Attach/Reset's it): the swap-chain
surface set survived swap-chain destruction, the likely true root cause of
the ~50 MB-per-reconnect VRAM loss that device pooling only mitigated.
Now adopted via from_raw (publisher or not) and dropped pre-Finished.
- PUNKTFUNK_IDD_PUSH removed: capture is unconditionally IDD-push, but the
vdisplay manager still gated the lingering-monitor preempt (and render
pin) on the knob, whose default was OFF — dev/CLI runs reused a lingering
monitor whose IddCx swap-chain is dead (black reconnect). The preempt and
the render-GPU pin are now unconditional; host.env comments no longer
promise the removed DDA/WGC fallback.
- Driver D3D device: dropped D3D11_CREATE_DEVICE_SINGLETHREADED (unsound
since DEVICE_POOL shares one device across processors) and the pooled
immediate context is now SetMultithreadProtected — two concurrent
monitors' workers otherwise race an unlocked context (UB in the UMD).
No wire-contract change (pf-driver-proto untouched); the driver fixes take
effect on the next pf-vdisplay redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PublicFwParam doc comment contained a literal code-constant token; Inno's
{ } comments don't nest, so its closing brace ended the comment early and the
trailing text parsed as code ("'BEGIN' expected", compile aborted). Reworded to
avoid the literal braces + added a warning note. Verified: the [Code] section
has no other nested-brace-in-comment traps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Punktfunk Host" identity work added winresource to the host crate but
didn't update the lock, so every --locked CI job failed to resolve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two comment em-dashes I added tripped the installer-run ASCII guard (PS 5.1
mis-parses non-ASCII on non-UTF-8 locales).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-box A/B on the .173 Ryzen 7000 iGPU (720p60, real composition via input
injection — an idle virtual desktop composes ~1 fps and gives meaningless
encode timings): the encode-time-first `quality=speed` preset + explicit `bf=0`
cut host-side encode_us from ~36 ms to ~19.5 ms.
The blocking-poll idea from the prior commit was WRONG and is reverted to a
single non-blocking receive (default PUNKTFUNK_FFWIN_POLL_MS=0): libavcodec's
hevc_amf holds ~2 frames before releasing the oldest (needs frame N+2 to flush
N), so a spin between submits provably never yields the owed AU — verified with
a 150 ms cap pegging at exactly 150 ms across every usage preset and pipeline
depth. That ~2-frame buffer is inherent to the libavcodec wrapper, not host
scheduling; the real latency lever is a direct AMF SDK encoder (the AMF
analogue of the direct-NVENC path), tracked as the next AMD work item. The
env knob is retained for a future VCN/driver where a bounded spin can help.
Also measured and rejected: PUNKTFUNK_ZEROCOPY=1 on AMF is ~2x WORSE (68 ms vs
36 ms) — the D3D11 import path adds sync overhead beyond the readback it saves,
so the system-memory default stays. GPU-priority elevation is already
process-wide (dxgi.rs), so it covers the iGPU encode session with no change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blocking poll landed but wait_us pegs at exactly the 2-frame-period cap:
AMF holds the AU ~2 frame periods regardless of retrieval. Field knobs to
bisect on-box (usage preset × poll cap) without rebuild cycles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The session loop's pipeline deferral was designed around direct NVENC, whose
poll() BLOCKS in lock_bitstream; libavcodec's AMF wrapper is truly async
(EAGAIN until the ASIC finishes), so a single non-blocking receive quantized AU
retrieval to the submit cadence: +1–2 frame periods flat (~43 ms p50 at 720p60
on the Ryzen iGPU vs ~3.5 ms of actual encode). FfmpegWinEncoder now tracks
in-flight frames and, while an AU is owed, spin-polls with short sleeps bounded
to ~2 frame periods (an overloaded encoder degrades to next-tick pickup instead
of stalling capture). Also: quality=speed (latency-first, iGPU-class VCN),
explicit bf=0 (h264_amf defaults >0 on RDNA3+), AMF low-latency submission
mode (FFmpeg ≥6.1, ignored on older).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firewall (the service.rs core landed in efb1ba2): scope the web-console rule
(TCP 47992) to Domain+Private by default with a `--allow-public-network` opt-in
that deletes-then-re-adds the rule, and add the installer "Allow connections on
Public networks" task (unchecked) forwarding the flag to `service install` and
`web setup`. Default is now trusted-networks-only; Public is explicit.
Vulnerability disclosure: SECURITY.md (report to security@punktfunk.com, scope,
SLAs, safe harbor), a Gitea issue-template contact link, a README security line,
and a Reporting section on the docs Security page.
Docs: the Security page now documents the Private/Domain firewall default (and
how to fix a misclassified-Public network / opt in); removed internal design-doc
and CLAUDE.md links from the user-facing docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two disk-write fixes:
- pf-xusb/pf-dualsense no longer write C:\Users\Public\pf*-driver.log
unconditionally — the file log is now opt-in (debug builds, or the
PFXUSB_DEBUG_LOG / PFDS_DEBUG_LOG system env var), mirroring the audit-§4.4
fix pf-vdisplay already got: a release driver never writes the world-writable
Public file (info-leak/DoS surface), and the per-report OUTPUT/SET_STATE hex
dumps stop being a sustained per-rumble disk-write path during gameplay.
OutputDebugStringA stays unconditional; the host's driver-silence WARN and
the gamepad-driver-health failure-mode table now say the log is opt-in.
- service.log/host.log get one-generation rotation: at each (re)open a file
over 10 MB is renamed to .old, so a crash-restart loop or a RUST_LOG=debug
left in host.env can't grow the append-forever logs without bound. Rotation
runs only before an open (never under a live appender — host.log's handle
lacks FILE_SHARE_DELETE, so a racing rename harmlessly fails).
Windows CI compile/clippy pending (drivers workspace + host are not
Linux-cross-checkable); rides along with the next pad-driver redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev deploy built --features nvenc only, so a web-console GPU preference
pointing at an AMD/Intel adapter made every session die at encoder open
(NV_ENC_ERR_NO_ENCODE_DEVICE) — the exact "can't connect" just hit on the RTX
box's Ryzen iGPU. The script now enables amf-qsv when FFMPEG_DIR (machine env,
process env, or C:\Users\Public\ffmpeg) has a dev tree, and copies the FFmpeg
runtime DLLs next to the exe after a successful build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
punktfunk-host.exe embedded no icon or version resources, so Task Manager and
Explorer showed a bare lowercase exe name with a generic icon. build.rs now
embeds the branded .ico + FileDescription "Punktfunk Host" / ProductName
"Punktfunk" via winresource (same pattern as the Windows client and the tray;
Linux packaging builds skip the block). The tray gets a matching "Punktfunk
Tray" description, and the SCM display name moves off lowercase
"punktfunk streaming host" to "Punktfunk Host" (applied idempotently by
`service install` on upgrade).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local per-box assistant instructions (incl. internal environment detail) don't
belong in the published tree; the file stays on disk, now gitignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4f3cd24 split the wrong expression — act's log masking hid the real line number.
The unmasked retry pinpointed it: the pointerSection footer, a ten-segment
string + chain with an isPad ternary nesting four more, evaluated inside the
ViewBuilder. Moved the copy into a plain computed String built with +=
statements (linear to type-check); no text change. The two remaining 5-6
segment chains in Settings are compiled by the passing macOS slice, so they
are proven cheap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Section's iOS content (resolution wheel + 3-way refresh rows + bitrate
rows) as ONE ViewBuilder expression hit "the compiler is unable to type-check
this expression in reasonable time" — failing exactly one build slice, the iOS
archive, so swift test (macOS) and the tvOS/macOS archives never saw it and the
0.6.0 iOS TestFlight upload soft-failed. Extracted iosResolutionWheel /
iosRefreshRows / bitrateRows; no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Open web console" entry was gated on {exe dir}\web\web-run.cmd (Windows)
/ the punktfunk-web unit file (Linux) — which misses consoles run from a repo
checkout (the RTX box, caught on-glass) and shows a dead entry while an
installed console is stopped. The poller now probes https://127.0.0.1:<web
port>/ each cycle (any HTTP response = up, transport failure = down) and the
menu follows live on both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sticky h-dvh sidebar: long pages scroll the content, not the nav — the flex
stretch was pushing the language switcher below the fold; overflow-y-auto keeps
the nav usable on short viewports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OS floor is now enforced at install time (MinVersion=10.0.22621 with an
explanatory [Messages] override): pf-vdisplay is built against IddCx 1.10, and
on Windows 10 (incl. LTSC) / Win11 21H2 the device fails start with Code 10
STATUS_DEVICE_POWER_FAILURE (field-reported). Docs (site requirements/install/
windows-host pages + README) state the floor; new docs-site Security page.
Installer also gains the trayicon task (punktfunk-tray.exe file + HKLM Run key,
post-install launch as the signed-in user, upgrade taskkill + uninstall
--quit/taskkill choreography before file deletion), and the wizard/cleanup
text/port sweeps move off the stale :3000 web-console references to :47992
(cleanups sweep both for upgrades from old installs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.
Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).
Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.
Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nvenc build linked nvEncodeAPI64.dll's entry points at load time, so a
--features nvenc binary hard-crashed on any box without the NVIDIA driver
(AMD/Intel). Entry points now come from a runtime LoadLibrary table
(encode/windows/nvenc.rs load_api); a missing DLL just falls through the
encoder auto-detect to AMF/QSV/software. The generated import lib and all its
plumbing (gen-nvenc-importlib.ps1, nvenc.def, PUNKTFUNK_NVENC_LIB_DIR,
setup-build-env wiring) are gone.
Live-validated on the RTX 4090 box (NVENC session, 7000+ frames).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).
Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.
driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).
On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The uploader only caught HTTPError — a URLError (TLS "EOF occurred in
violation of protocol", the failure that dropped two release uploads on
2026-07-02) or a Google 5xx killed the job outright. Retry those with
3/9/27 s backoff; 4xx still fails fast. The edits API is transactional
until commit, so re-sending is safe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test burst 3×100 1200 B datagrams into an undrained loopback socket: at
~2.5 KB kernel truesize each, the default ~212 KB rmem holds only ~80, so on
a starved CI runner (parallel release builds) the kernel silently dropped the
overflow and the recv loop could never reach 300 — surfacing as WouldBlock
after the 3 s timeout. Size the burst (3×20) to fit the default buffer even
with zero concurrent draining, and give recv a starvation-tolerant 10 s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two touch clients had exactly complementary gaps: iOS forwarded fingers
ONLY as raw wire touches (no way to drive the host cursor from the touch
screen), Android had the two mouse modes but no passthrough. Both now share
one three-way "Touch input" setting: Trackpad (default) / Direct pointer /
Touch passthrough.
iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1
(same px-based acceleration curve; tap=click, two-finger tap=right-click,
two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats
HUD via the shared hudEnabled default); direct-pointer mode maps through
the aspect-fit letterbox; the previous always-on behavior lives on as the
passthrough option. The mode latches per gesture (a Settings change never
splits one gesture across models), touchesCancelled releases held state
without synthesizing a click, and session stop flushes a mid-drag button.
Settings picker on iPhone + iPad next to the iPad-only pointer-capture
toggle. Deliberate default change: trackpad, not passthrough.
Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host
already injects real touch on every backend — libei touchscreen, wlroots,
KWin fake-input, SendInput); streamTouchPassthrough forwards every finger
with stable ids and lifts still-held contacts on teardown; the trackpadMode
Boolean becomes the TouchMode enum (old pref migrated on load, never
rewritten) with a Settings dropdown.
Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS
Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin
app+kit compile + unit tests. On-glass feel of the iOS ballistics and
Android passthrough against a touch-aware app still pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-caused fixes from on-Deck testing (owner + first external tester):
- System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI
driver clears the built-in controller's "lizard mode" (trackpad-mouse,
clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog
(SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that
driver at startup and held every pad open app-lifetime. The Valve HIDAPI
hints are now enabled only while a session is attached, and only the active
pad is opened (Settings enumerates via SDL's ID-based metadata getters, no
open). Close/detach hands the hardware back; the watchdog restores lizard
mode within seconds. This also unblocks click-to-capture on the Deck (the
dead trackpad made "input not passed through" a symptom, not a cause).
- Washed-out colors from a Windows host with an HDR desktop: the host ships
Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR;
this client rendered everything as BT.709 narrow. Colour signaling is now
read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives
the GdkDmabufTexture color state, the software path's swscale matrix/range
plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps
correctly on SDR displays, mid-session SDR↔HDR flips included. Regression-
tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265).
- Streams start fullscreen by default (Settings toggle; F11 / the controller
chord lead out, and the pointer at the top edge reveals the header while
input isn't captured — a Deck desktop has no F11). Gaming-Mode launches
(--fullscreen / Deck env) build the stream page with NO header bar at all:
gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed
on is_fullscreen() could leave the title bar drawn over the stream.
- Game Mode settings were uneditable: GTK popovers are xdg_popups, which
gamescope never maps for nested apps — every ComboRow dropdown flashed and
died. Under gamescope the preferences dialog now uses in-window selection
subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a
stock ComboRow on desktops. Covered by an in-process GTK test
(choice_row_modes, #[ignore]d — needs a display).
- Forwarded-controller pin persists across restarts (Settings::forward_pad,
stable vid:pid:name key — SDL instance ids are per-run) and survives
disconnects; automatic selection skips Steam Input's sensor-less virtual
pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck.
- "Punktfunk" branding in the About dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from live debugging on the Deck:
- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
Python has no usable default CA paths, so every HTTPS fetch failed with
CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
openSUSE), then certifi if importable. Verification stays on; the check
stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
exec bits into a root-owned dir the unprivileged backend can't chmod. The
Steam shortcut now launches the runner through /bin/sh with the script as a
%command% argument — no exec bit needed, existing shortcuts migrate on
reuse, the chmod attempt is gone.
UI/structure:
- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
(forces past the cache, always toasts an outcome), setup-guide link, leave-
chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
log-crate events arrive through the tracing-log bridge under the shim target
"log" — normalize them back to the real module path (NormalizeEvent) so the
console's target column and the noise gate see mdns_sd::… , and suppress the
bridge's log.* bookkeeping fields like the stderr fmt layer does.
Gate known-chatty third-party DEBUG targets (mdns-sd DEBUG-logs every
unparseable multicast packet — one AirPlay device floods thousands of entries
per hour) to INFO-and-up in the ring, so ambient LAN noise can't evict the
tail the ring exists to preserve. stderr under RUST_LOG is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a pad drives a stream, GamepadCapture now sets EVERY element's
preferredSystemGestureState to .disabled (restored to .enabled on unbind).
iOS/macOS attach system gestures to several controller buttons — share/create
took a LOCAL screenshot instead of reaching the game, and only the Home
element was opted out before. With the gestures claimed, the already-wired
chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad
(the Steam-overlay button) / the PS bit on the virtual DualSense.
Also fold the share/create/capture element (GCInputButtonShare) into the
back/select wire bit — clone pads like the GameSir G8 expose their screenshot
button only as the share element, not buttonOptions (OR onto the same bit, so
double-exposed pads are harmless). The G8's other extra button (M) is a
firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).
- Finite 4 s segments, never infinite events — a leaked player self-silences;
steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
failure; renderer publishes health transitions and the test panel shows
them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
queue/ticker teardown smoke test
Stuck-rumble streaming repro revalidation on glass still pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A moved release tag re-fires the publish workflows, and the Gitea
registries reject duplicate uploads with 409 (deb pool, rpm group, and
the generic packages' versioned URLs; the channel aliases already
pre-deleted). Delete any prior copy of the exact version before
uploading (404 on first publish tolerated), so a republished tag
overwrites instead of wedging — v0.5.0's retag left stale no-port-change
artifacts published and every re-run red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port 3000 collides with half the dev-server ecosystem; 47992 sits next
to the mgmt API (47990) in the punktfunk port family. Updates the run
scripts, systemd/scheduled-task units, Dockerfile, Windows firewall
rule + installer, packaging, and every doc that referenced :3000.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot comparison now normalizes info.version on both sides and
compares structurally — a version bump alone can never invalidate the
checked-in spec again (the 0.5.0 release tripped on exactly this; the
API surface is what drift-control protects). Snapshot regenerated so
the docs-site copy shows the current version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto main picked up the pre-fix loop{match} variant of the
async retrieve drain — the exact shape the Windows clippy gate rejects
(run 6722 failed on it; the while-let form passed run 6724 on the CI
branch). Restore the gated form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gamepad drivers have no IOCTL plane (hidclass gates the stack), so
until now the host had ZERO visibility into whether a driver ever
bound: a pad could be "created" with no driver installed and nothing
was logged. Two health fields are carved from reserved shm space
(layout-compatible; pf-driver-proto pins the offsets): driver_proto —
stamped by pf-xusb at device add + per serviced XInput IOCTL (movement
= the game-visible path) and by pf-dualsense/DS4 from its ~125Hz timer
— and driver_heartbeat. Host-side, every pad owns a DriverAttach
watcher fed from the existing service() poll: INFO on attach (WARN on
proto mismatch), and after 3s of silence ONE diagnosis WARN combining
a cached pnputil /enum-drivers store check, the devnode's CM problem
code (CM_Locate_DevNodeW/CM_Get_DevNode_Status on the instance id now
captured from the create callback, with plain-language hints: 28 = not
installed, 52 = signature/Memory Integrity, …) and the driver's debug
log path. Also fixes a real bug both SwDeviceCreate wrappers shared:
the 10s WaitForSingleObject result was ignored and the callback
HRESULT zero-initialised, so a PnP timeout read as SUCCESS (now E_FAIL
init + explicit timeout error). Failure-mode table:
design/gamepad-driver-health.md.
Linux workspace green; Windows host + drivers CI-compile only, on-box
recipe at the bottom of the design doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remote debugging without shell access: a tracing layer tees every
event at DEBUG-and-up — independent of the RUST_LOG filter gating
stderr/host.log, so console-side debugging never needs a restart —
into a bounded in-memory ring (log_capture.rs, 4096 newest entries,
OnceLock singleton like config()), installed at both init sites
(stderr path in main, the Windows service file path). The mgmt API
serves it cursor-paged at GET /api/v1/logs?after=&limit= — bearer-only
and deliberately NOT on the mTLS cert allowlist (log lines can name
client identities and host paths). The web console grows a Logs page
(follow/pause · min-level filter · text search · eviction-gap badge);
polling self-paces: a non-empty page advances the after-cursor (new
query key → immediate refetch, drains backlogs), an empty page idles
at the 2s interval. OpenAPI regenerated; ring pagination/eviction,
layer wiring, and the authed route are unit-tested; Storybook story
included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client end of the "host doesn't see my gamepad" triage chain: a
new ControllersScreen lists every InputDevice Android classifies as a
gamepad/joystick (name, VID:PID, source classes, the punktfunk pad
type it resolves to, rumble test) plus an "Other input devices"
section — a pad behind a BT→USB adapter (the Pico 2W tester case)
often enumerates with the adapter's identity or not as a gamepad at
all, and this makes that visible on the device instead of over a bug
report. A live input test (button chips + axis bars + raw last-keycode
line) consumes pad events via new MainActivity probe hooks ahead of
the focus-nav remap; hold B 1.2s to exit since the pad can't reach the
toggle while captured. Gamepad grows pads()/isPad() (firstPad
generalized).
Kotlin compiles green (kit + app); on-device validation pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gpu-contention plan's §5.B lever: today submit and the blocking
lock_bitstream share one thread, so under a GPU-saturating game the
pipeline serializes on the WDDM scheduling wait (1000/17ms ≈ 59fps —
the depth-1 collapse; the old 'deeper pipeline just stacks latency'
result was a same-thread implementation, not a disproof). Async mode
opens the session enableEncodeAsync=1, registers an auto-reset
completion event per pool bitstream, and moves the wait+lock+copy+
unlock onto an internal retrieve thread feeding poll() through a
channel — the exact split the NVENC guide mandates. Register/map/unmap
stay on the encode thread; teardown drops the job channel, joins the
thread, THEN destroys the session. In-flight depth is bounded by
PUNKTFUNK_NVENC_ASYNC_DEPTH (default 4, hard cap POOL-1) — both for
output-buffer reuse and because NVENC encodes the capture ring's
textures in place. Idle latency cost ≈ 0 (same-tick pickup); under
contention completed frames queue instead of stalling capture.
CI-compile validated only — on-glass A/B under game load on the RTX
box still pending (box offline).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_VIDEO_DROP now also covers the native data plane (N% of
sealed wire packets discarded before send in paced_submit — the same
FEC-test knob the GameStream path has; no netem/root needed), and the
probe grows the real clients' recovery trigger: the data loop publishes
the session's unrecoverable-frame count and the control task sends
RequestKeyframe when it rises (100ms poll = natural coalescing).
Together these make the IDR-vs-intra-refresh recovery A/B runnable
against any host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nvclocks.rs -> gpuclocks.rs. PUNKTFUNK_PIN_CLOCKS=1 now also pins every
amdgpu card's power_dpm_force_performance_level to high for the host
lifetime (prior level restored on exit) — the measured AMD encode-
latency lever: VCN per-frame time doubles when a 60fps paced trickle
lets clocks sag (8 -> 4.4ms/frame at 1440p on the 780M with clocks
hot). Root-gated by sysfs ownership; non-root degrades to a logged
recipe (validated live on the AMD box). Opt-in stays deliberate:
box-wide power-management override, wrong on battery/Deck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chasing the 8ms submit at 1440p on the 780M: the sampled PUNKTFUNK_PERF
split (push/pull/send) shows desc+buffersrc at ~5us, hwmap-import+VPP
CSC at ~0.2-0.5ms, and avcodec_send_frame owning the rest — so neither
a VA-surface import cache nor CSC overlap would help. Two facts landed:
(1) async_depth>=2 in libavcodec's vaapi_encode is a structural
+1-frame latency (frame N's packet only materializes when N+1 queues;
measured 18ms vs 8.3ms p50 at depth 1) — depth 1 stays the default,
PUNKTFUNK_VAAPI_ASYNC_DEPTH exists for pixel rates beyond the ASIC's
serial budget, and poll() now does a bounded in-flight wait so a deeper
depth still ships the AU as soon as the ASIC finishes. (2) The residual
send_frame block tracks GPU CLOCKS, not the ASIC: ~8ms/frame at a 60fps
duty cycle vs ~4.4ms at 120fps pacing vs 3.5ms back-to-back (270fps CLI
benchmark, even at -async_depth 1) — the clock-sag fix lands in
gpuclocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A/B'd on the Bazzite box (RTX 5070 Ti, KWin 6.6, driver 595, 1080p60
over the LAN): pixel-correct decode (full desktop, no tint/banding),
latency-neutral idle (p50 1.47ms RGB vs 1.52ms NV12, both 2400/2400
frames), CPU-neutral — and it deletes NVENC's internal RGB->YUV CSC
from the SM/3D engine a game saturates (video 40%+SM 15% -> video
26%+SM 2% measured on Windows). Matches the Windows host default.
PUNKTFUNK_NV12=0 restores the RGB feed; LINEAR/gamescope captures are
unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the easy-scene p99 lever (host-latency plan Tier 1B):
CudaNoStablePerfLimit application profile (no root; NVIDIA's supported
opt-out of the CUDA/NVENC P2 memory-clock clamp, raw key 0x166c5e=0 per
open-gpu-kernel-modules#333, shipped for obs/Discord in R595) installed
into ~/.nv/nvidia-application-profiles-rc.d/ keyed on procname, opt-out
PUNKTFUNK_NV_PROFILE=0; and PUNKTFUNK_PIN_CLOCKS=1 arming an NVML
SetGpuLockedClocks(TDP, UNLIMITED) core-clock floor (base floor, boost
headroom — never a max pin) held for the host lifetime, reset-on-start
self-healing a crashed run's stale pin, NO_PERMISSION degrading to a
logged sudoers/oneshot recipe. libnvidia-ml is dlopen'd like libcuda —
no link-time dependency, clean no-op off NVIDIA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Intra-refresh (opt-in PUNKTFUNK_INTRA_REFRESH=1 until on-glass
validated): NVENC runs a moving intra band + recovery-point SEI
(gop_size becomes the wave period, ffmpeg forces the real GOP infinite;
default fps/2, PUNKTFUNK_IR_PERIOD_FRAMES overrides; ENOSYS latches a
fallback to IDR-only). Clients request a keyframe on every
FEC-unrecoverable frame, so under intra-refresh the session glue serves
the first request instantly and suppresses the rest for a 2s window —
the wave heals loss without the 20-40x IDR spike cascade. VAAPI/software
keep IDR recovery.
Instrumentation: the wire pts now anchors at the PipeWire delivery stamp
(client-measured latency covers delivery + queue age, not just
submit->glass; repeats/synthetic stamps fall back to now), encode_us
keeps its submit->AU meaning via a separate inflight stamp, and a new
'queue' stage (delivery->submit age of fresh frames) rides
PUNKTFUNK_PERF and the web-console stats samples.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GameStream video thread ran unboosted on Linux and the send thread
only got the Windows MMCSS call; both now use boost_thread_priority
(Linux nice -10/-5, Windows HIGHEST/ABOVE_NORMAL + session tuning).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_ZEROCOPY unset now defaults ON when the encode backend is
VAAPI — a stock AMD/Intel install gets the LINEAR-dmabuf -> GPU-CSC path
instead of three full-frame CPU touches (measured on the 780M at 1440p:
0.8s vs 7.9s CPU per 600 frames, pixel-identical). NVENC stays opt-in.
A dmabuf offer the compositor never accepts latches a one-shot downgrade
so the pipeline rebuild renegotiates on the CPU offer; explicit =1 keeps
erroring loudly. The EGL->CUDA importer is no longer built on VAAPI
backends (an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi now correctly
takes the passthrough instead of producing CUDA frames the encoder
rejects), and a VAAPI session landing on the CPU path warns with the
reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gen12+/Arc iHD exposes ONLY EncSliceLP, so the default open fails with
'no usable encoding entrypoint'. Try full-feature first (AMD unchanged,
validated on the 780M), retry low_power=1, cache the mode per codec;
PUNKTFUNK_VAAPI_LOW_POWER pins it. Probes inherit the ladder. Docs note
the Intel HuC firmware requirement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump windows-reactor + windows to a4f7b2cb (from b4129fcc) for the new
PointerEntered/PointerExited events; migration is mechanical renames only
(SymbolGlyph->Symbol, placeholder->placeholder_text, on_changed->
on_text_changed/on_toggled, on_menu_item_clicked->on_item_clicked,
on_ready->on_mounted). New runtime model: reactor lost its build.rs, so the
client build.rs stages the WinAppSDK bootstrap via
windows-reactor-setup::as_framework_dependent() and main calls
windows_reactor::bootstrap() (missing either = 0x80040154 at launch);
staged filenames unchanged, so pack-msix and the MSIX manifest are untouched.
- Host tiles: WinUI pointer-over fill (ControlFillSecondary) via the new
pointer enter/exit events, hover id in root state (backend-wired handlers
bypass the reconciler flush, like the flyout clicks).
- Settings: stock NavigationView sidebar (Windows-Settings pattern) with
Display/Video/Input/Audio/About panes, built-in back arrow, wide content
column, and a per-section content slide-up tween. The section card is
KEYED by section: an in-place diff across sections re-sets a reused
ComboBox's items (clearing WinUI's selection) but skips selected_index
when the values compare equal, rendering a blank selection - the key
forces a remount. Card titles/descriptions dropped; per-control guidance
moved to hover tooltips (ToolTipService).
- New "Show the stats overlay (HUD)" setting (show_hud, default on),
honored mid-stream via the 400 ms HUD re-render.
- Add-host modal: entrance fade + slide-up tween (scrim fades with it).
- Self-initiated disconnect (Ctrl+Alt+Shift+D -> Ended(None)) returns to
the host list silently instead of raising the error banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Settings gains a GPU selector (shown only on multi-GPU boxes): the picked
DXGI adapter drives decode + present, persisted as Settings.adapter and
applied at the next stream - gpu.rs now caches the shared device keyed by
the resolved preference (env PUNKTFUNK_ADAPTER > Settings > the window's
monitor's adapter) so a change needs no app restart.
- Ctrl+Alt+Shift+D disconnects the session (consumed locally, captured or
released): the hook releases capture and trips the session stop flag,
plumbed through the stream-page handoff; the pump winds down and the UI
navigates back to the host list.
- Stream HUD extended: codec chip (HEVC/H.264/AV1), display-side line from
the render thread (presents/s + capture-to-decoded vs capture-to-on-glass
p50), session line (host name, duration, network-lost frames, skipped
backlog frames), and both shortcut hints incl. the new disconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wlroots injector: the virtual keyboard keymap now defers to the standard
XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS env vars (libxkbcommon
built-ins as fallback) instead of hardcoding evdev/pc105/us, matching the
libei path where the session compositor's own keymap applies. Android:
Keymap gains the same positional-key coverage for non-US layouts (+ tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decode+present rewrite (first real pixels on glass for this client):
- Decode: FFmpeg D3D11VA on NVIDIA/AMD/Intel. get_format now only returns
AV_PIX_FMT_D3D11 and lets libavcodec build the decode pool from
hw_device_ctx (hand-built frames contexts failed three different ways:
NVIDIA rejects DECODER|SHADER_RESOURCE arrays, BindFlags=0 fails texture
creation, Intel rejects non-128-aligned HEVC surfaces at the first
SubmitDecoderBuffers). A DXVA profile probe before the hwdevice commits
hardware-vs-software up front instead of burning the opening IDR;
extra_hw_frames covers the frames the client holds.
- Present: the decoded slice is copied with ONE display-size-boxed
CopySubresourceRegion (a planar slice is a single subresource in D3D11;
the old two-copy D3D12-style code silently no-opped - the black screen)
into a sampleable NV12/P010 texture, per-plane SRVs + YUV->RGB shaders.
- New dedicated render thread (render.rs): presenting is decoupled from the
XAML thread; frame-latency-waitable swapchain + SetMaximumFrameLatency(1),
newest-wins drain after the wait, crossbeam frame channel with pts for a
capture->presented p50 log.
- HiDPI: pixel-sized buffers + SetMatrixTransform(96/dpi) - was blurry at
125/150 % scaling.
- Software fallback now feeds the same shaders (swscale -> NV12/P010 planes
-> two dynamic plane textures); ps_rgba/X2BGR10 path deleted, hw/sw colour
math identical.
- Adapter selection for hybrid boxes: PUNKTFUNK_ADAPTER > the window's
monitor's adapter > default; PUNKTFUNK_D3D_DEBUG=1 debug layer.
- Session pump: request_keyframe at start and on hw->sw demotion (infinite
GOP would otherwise sit on a black screen).
Validated live on the Arc Pro + RTX 3500 Ada laptop against the local
Windows host: 60 fps D3D11VA on both vendors, software path, GUI on glass.
Also: embedded app icon (build.rs winresource + WM_SETICON, MSIX
Square44x44 targetsize assets, pack-msix stages them) and the hosts-page
tile rework (tap-to-connect tiles with sibling overflow menu - fixes
forget-also-connects - in-tile rename editor, add-host modal via root state).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First-party punktfunk clients send US-positional VKs (the physical key's
US-layout VK), GameStream/Moonlight clients send layout-semantic VKs
(Sunshine's model). The SendInput injector previously resolved everything
through the SYSTEM service's layout - on a German host that is the y/z swap
and u-umlaut-on-o-umlaut scramble. GameStream ingest now tags its key events
KEY_FLAG_SEMANTIC_VK (stripped from punktfunk/1 wire events so a network
client can't flip the convention); the injector maps semantic VKs under the
foreground app's layout and positional VKs through a fixed scancode table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new crate::gpu (compiled on all platforms so the OpenAPI doc stays
platform-independent): DXGI / sysfs GPU inventory with reboot-stable ids
(PCI vendor:device + occurrence — LUIDs are per-boot), persisted auto/manual
preference (<config>/gpu-settings.json, atomic temp+rename with in-memory
rollback), one selection with precedence console preference >
PUNKTFUNK_RENDER_ADAPTER > max VRAM and graceful fallback when the preferred
GPU is absent, plus a live "in use" record (RAII session guard wrapped around
every encoder open_video returns)
- fix: windows_gpu_vendor derived the encoder backend from DXGI adapter 0
instead of the selected render adapter — on a hybrid box (e.g. Intel iGPU at
index 0 + NVIDIA dGPU) the backend could disagree with the GPU the capture
ring / IddCx render pin sit on. The NVENC 4:4:4 probe now also runs on the
selected adapter (was: OS default), the codec/4:4:4 probe caches are keyed
per selected GPU (were process-lifetime OnceLocks), and an explicit
PUNKTFUNK_ENCODER conflicting with the selected GPU's vendor warns up front
- mgmt API: GET /api/v1/gpus (inventory + mode + preferred + next-session
selection with reason + in-use GPU/backend/session-count) and
PUT /api/v1/gpus/preference (validates mode/gpu_id before writing);
openapi.json regenerated; the vdisplay render pin now also engages for a
console preference (not just the env pin)
- web console: GPU card on the Host page — list with vendor + VRAM,
Automatic / Prefer controls, Preferred / Next session / "In use · backend"
badges, missing-preferred-GPU warning and env-pin note; en + de messages
- Linux: a matched manual preference picks the VAAPI render node and the
NVENC-vs-VAAPI auto choice; auto mode is exactly the previous behavior
Validated live on the hybrid laptop (RTX 3500 Ada + Intel Arc Pro, which
enumerates twice — the occurrence ids disambiguate): enumerate, prefer,
bad-id 400, restart persistence, auto-restore keeping the stored pick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quick-xml 0.39.4 enters only via wayland-scanner, a build-time proc-macro
that parses trusted crate-shipped protocol XML at compile time — never a
shipped binary, never runtime/attacker-controlled input, so neither DoS is
reachable. wayland-scanner 0.31.10 (latest) pins quick-xml ^0.39; the fixes
land only in >=0.41, so there is nothing to bump to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Modern branded wizard: WizardStyle=modern dynamic windows11 (Inno >= 6.6,
plain-modern fallback for older compilers; CI provisioning upgrades a
pre-6.6 Inno). Brand-mark wizard side panels + header tiles (100-200% DPI)
and a multi-size punktfunk.ico (SetupIconFile + Apps & Features), generated
AND committed by branding/gen-branding.ps1 from the canonical brand geometry.
Gotcha encoded in the script: ISCC rejects all-PNG icons, so entries <= 64px
are classic DIBs (PNG only at 128/256), and the ICO is load-verified.
- VB-CABLE actually ships now: windows-host.yml never set VBCABLE_DIR, so every
published installer silently omitted the virtual mic (broken mic passthrough
in the field). CI provisions the pinned, SHA-256-verified official Pack45
(provision-windows-punktfunk-extras.ps1) and the pack now FAILS on a
supplied-but-invalid dir instead of shipping mic-less again. Attribution per
VB-Audio's bundling grant surfaced in the visible wizard task text (vendor,
vb-cable.com, donationware) on top of the licenses notice.
- GameStream (Moonlight) compat is a wizard task (checked by default) ->
service install --gamestream=on|off writes PUNKTFUNK_HOST_CMD=
serve[ --gamestream] into host.env. Only the two canonical values are ever
rewritten - a hand-customized command line survives upgrades. Silent
installs: /MERGETASKS="!gamestream".
- Driver uninstall (field report: our virtual-device drivers survived
uninstall): new `driver uninstall [--gamepad]` removes the pf-vdisplay
device node(s) + the pf-vdisplay/pf-dualsense/pf-xusb driver-store packages,
wired into [UninstallRun] after service uninstall. Locale-safe by
construction: devices matched on unlocalized VALUES (never pnputil's
localized labels), packages found by INF content scan - validated against a
German-locale box ("Instanz-ID:" parse; 7/7 punktfunk INFs matched, no
foreign hits). VB-CABLE is deliberately left installed (shared third-party
component with its own uninstaller).
Installer compile, cargo check/clippy/fmt, and the ASCII locale gate are green;
the wizard look + uninstall flow still need one on-glass pass on a disposable
box (this box runs the live host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structure: split the 1400-line app.rs into per-screen app/ modules (mod=root/
router, hosts, connect, pair, speed, settings, licenses, stream, style) with
shared card/header/busy-page builders and setting_combo/toggle helpers; the
re-render rule (thread-driven state lives in root use_async_state, flows down
as props) is now documented at the module root.
Parity features the other clients already had:
- "Native display" resolves the real monitor mode at connect
(MonitorFromWindow -> EnumDisplaySettingsW; was a hardcoded 1080p60)
- per-host network speed test: saved-host card button + a results screen
(probe burst -> goodput/loss -> ~70% recommended bitrate applied in one
tap; stale runs invalidated by generation) and `--headless --speed-test`;
the bitrate setting becomes a free-form NumberBox so the recommendation
round-trips
- forget host (ContentDialog confirm -> KnownHosts::remove_by_fp)
- settings: forwarded-controller picker (pads/pinned/set_pinned now wired),
gamepad type, host compositor, capture-system-shortcuts; the previously
dead Settings.compositor / inhibit_shortcuts are honored (shortcuts off =
Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally)
- click-to-recapture after a Ctrl+Alt+Shift+Q release; the HUD hint tracks
the live capture state
Perf: the input hook caches lock geometry (clip rect + contain-fit scale) at
engage instead of GetClientRect per WM_MOUSEMOVE; the audio jitter ring trims
via drain() and reuses the render scratch buffer.
Validated on the bare-metal box: --discover, synthetic-host loopback E2E
(TOFU -> clock skew -> HEVC negotiate -> D3D11VA init -> session end),
speed-test E2E, and the WinUI shell rendering in the console session via
PsExec (SSH/session-0 cannot create windows, pre-existing 0x80070005).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit:
Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split
along the same seams.
The gamepad mode is couch-complete, and now on macOS too (the living-room
Mac case), not just iOS/iPadOS:
- GamepadSettingsView: a console-style, fully controller-navigable settings
screen (X from the launcher) — up/down moves focus, left/right steps values
(clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a
one-line description. Backed by GamepadMenuList, the vertical sibling of
GamepadCarousel, and SettingsOptions — the option lists hoisted out of
SettingsView statics and shared by the touch, tvOS and gamepad settings.
- GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad
— field rows open an on-screen controller keyboard (dpad grid, A types,
X backspaces, B done); the launcher carousel ends in an Add Host tile, so
the dead-end "add one with touch first" empty state is gone.
- Launcher polish: contextual hint bar with the pad's real button glyphs,
controller name + battery chip, one shared console chrome.
- GamepadScreenBackground: an animated aurora (TimelineView-driven drifting
blobs in the brand's violet family, breathing radii, slow hue shift,
legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a
.metal library only bundles reliably in one of the two build systems (SPM vs
the xcodeproj's synced folders) these sources compile under.
- macOS port: settings/add-host/library present as sized sheets (a macOS sheet
takes its content's IDEAL size, and the GeometryReader-driven screens
collapsed to nothing), NSScreen-based mode lists, scroll indicators .never
(the "always show scroll bars" setting overrides .hidden), tray scrims so
scrolled rows dim under the pinned title/hints, extra title clearance, and a
PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/
library render-verified live on a real Mac + LAN hosts.
- GamepadMenuInput: X button support, and (re)start now snapshots held buttons
so a controller handoff press never fires twice (the B that closed the
keyboard no longer also cancels the screen underneath).
- Cleanups: one "Connection failed" alert in ContentView instead of one per
home screen; HostDiscovery.advertises/unsaved shared by both home screens.
- host: can_encode_444 stub for the non-Linux/Windows host build (the macOS
synthetic-source loopback used by the Swift tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- library.rs + ui_library.rs: the host's unified game library over the
management API (the Apple LibraryClient/LibraryView ported) — mTLS with the
paired identity, host verified by its pinned cert fingerprint (ureq + rustls,
unified with the workspace rustls 0.23); posters load async with monogram
placeholders, and picking a title starts a session that asks the host to
launch it (the library id rides the Hello).
- app.rs (~800 lines lighter) splits into cli.rs (argv/headless
pairing/--connect/screenshot scenes), launch.rs (mode resolve + session
worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN /
delegated-approval dialogs); ui_hosts/ui_stream reworked around the split.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- native: the 756-line session.rs becomes session/{mod,connect,input,planes}.rs
around a SessionHandle (connect lifecycle + trust, input plane shims, plane
start/stop + stats drain).
- Decode-stats sampling is HUD-gated (nativeSetVideoStatsEnabled): with the
overlay hidden the decode thread skips the per-AU clock read + lock; enabling
resets the measurement window.
- audio: the AAudio open path is a per-sharing-mode try_open closure — the
realtime callback state (ring, prime, free-list) is rebuilt per attempt, so a
failed exclusive-mode try can't leak state into the shared-mode retry.
- Kotlin: ConnectScreen/StreamScreen slimmed by extracting ConnectDialogs,
StatsOverlay and TouchInput.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rounds out codec negotiation across the last three clients — each advertises what it can decode,
builds its decoder from the resolved Welcome.codec, and exposes a "Video codec" preference picker.
**Windows** (Rust, mirrors Linux): `decodable_codecs()` + `ffmpeg_codec_id()`; the D3D11VA and
software FFmpeg decoders (and the mid-session D3D11VA→software demotion) open the negotiated codec
instead of hardcoding HEVC; settings gain a `codec` field + reactor ComboBox; `--codec` CLI flag.
**Apple** (Swift/C-ABI): AnnexB is now codec-aware — a `VideoCodec` enum drives H.264 vs HEVC NAL
parsing / parameter-set extraction (`CMVideoFormatDescriptionCreateFromH264ParameterSets` for H.264,
no VPS) and AVCC repacking; `PunktfunkConnection` advertises H264|HEVC via `punktfunk_connect_ex7`,
reads `resolvedCodec` (`punktfunk_connection_codec`), and threads `videoCodec` into the stage-1/2
pipelines + `VideoDecoder`; SettingsView "Video codec" Picker (auto/HEVC/H.264). AV1 is left out
(hosts don't emit it on the native path, and it's not an AnnexB codec). Test call sites updated.
**Android** (Kotlin + Rust JNI): the JNI `nativeConnect` gains `preferredCodec`; the native decode
loop picks the AMediaCodec MIME (`video/hevc`|`video/avc`) from `connector.codec` and advertises
H264|HEVC; Settings `codec` field + Compose dropdown.
Core/host/probe/Linux clippy + tests green (unchanged from 2a). Windows/Apple/Android compile on
their platform CI (this Linux box can't build them — Windows toolchain / Xcode / the Android NDK's
opus-cmake toolchain). All follow the Linux client's validated pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a client-selectable **preferred codec** and wires the core + ABI + probe + Linux client to
negotiate and decode it. (Windows/Apple/Android follow in 2b.)
**Core:**
- `Hello.preferred_codec` (a single CODEC_* bit, 0 = auto) — a soft hint appended after
`video_codecs`. `resolve_codec(client, host, preferred)` now honors the preference when the host
can also emit it, else falls back to precedence (HEVC > AV1 > H.264). Roundtrip + preference tests.
- `NativeClient::connect` takes `video_codecs` + `preferred_codec`; `NativeClient.codec` exposes the
resolved `Welcome.codec`.
- ABI: `punktfunk_connect_ex7` (adds the two codec params; `ex6` delegates to it advertising
HEVC-only) + `punktfunk_connection_codec` getter + `PUNKTFUNK_CODEC_{H264,HEVC,AV1}` constants
(drift-guarded against the wire values). Header regenerated.
**Host:** passes `hello.preferred_codec` into `resolve_codec`.
**probe:** `--codec h264|hevc|av1|auto` sets the preference (still advertises it can decode all
three); the dump extension already follows the resolved codec.
**Linux client:** advertises the codecs FFmpeg can actually decode (`decodable_codecs()`), threads
the user's `codec` setting as the preference, and builds the decoder — both the software and VAAPI
paths, plus the mid-session VAAPI→software demotion — from the negotiated `Welcome.codec` instead of
hardcoding HEVC. New "Video codec" dropdown in Preferences (Automatic/HEVC/H.264/AV1).
Live-validated on the dev box: probe `--codec hevc` against a software (H.264-only) host resolves to
H.264 (graceful soft-preference fallback), no failure. clippy + core (57) + host (133) tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of codec negotiation, and the Linux software H.264 encode path it unblocks.
**Codec negotiation (core `quic`):**
- `Hello.video_codecs` (bitfield: CODEC_H264/HEVC/AV1) — the client advertises what it can
decode; appended as a trailing byte (older client → 0 = HEVC-only, back-compat).
- `Welcome.codec` — the single codec the host resolved and will emit; trailing byte (older
host → HEVC).
- `resolve_codec(client, host_capable)` picks the shared codec (precedence HEVC > AV1 > H.264)
or `None` → the host refuses honestly rather than sending an undecodable stream.
- Roundtrip + back-compat tests; cbindgen exports the CODEC_* constants.
**Software encoder (host):**
- The openh264 `OpenH264Encoder` (was Windows-only) is now built on Linux too — it's
platform-agnostic (consumes CPU RGB `CapturedFrame`s, statically-bundled openh264). `openh264`
moved to the shared linux+windows Cargo target.
- `PUNKTFUNK_ENCODER=software` selects it: `open_video` gains a `software` branch (H.264 only),
and `session_plan::resolve_encoder` / `capture::gpu_encode` resolve `EncoderBackend::Software`
→ `output_format().gpu = false`, so the portal capturer delivers CPU RGB. Explicit-only (auto
never picks it — a box with a dead driver still has /dev/nvidiactl and would mis-resolve NVENC).
**Host codec resolution (`punktfunk1`):**
- The native path no longer hardcodes HEVC: it resolves the codec from the client's advertised
set ∩ the host's capability (`Codec::host_wire_caps`: software→H.264, else HEVC), threads it
through `SessionPlan.codec`, and opens the encoder + validates reconfigures at that codec. A
software host + HEVC-only client is refused with a clear error.
- 4:4:4 is gated on HEVC (it's HEVC-only).
**Probe:** advertises H264|HEVC|AV1 and logs the resolved codec.
Validated on the GPU-less dev box: negotiation is live end-to-end (probe advertises 0x07 → host
resolves H.264 → Welcome reports it → plan = Software/H264), and the openh264 unit test (CPU RGB →
AnnexB IDR) now runs on Linux. Full capture→encode still needs a GPU on this box — every
compositor screencast path (KWin GL, gamescope VK_EXT_physical_device_drm, wlroots EGL) requires
one; software render (llvmpipe/pixman) can't be captured — so this box exercises negotiation +
encoder, not live capture. The software path unblocks GPU-less-*encode* boxes that still have a
display GPU. Phase 2 (clients advertising real codecs + decoding per Welcome.codec) is a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client-initiated launches (Hello.launch / GameStream applist) were only
wired to gamescope's bare-spawn path via the process-global
PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never
read by kwin/mutter/wlroots (launch was a silent no-op there), and was
unreachable on gamescope anyway because apply_input_env unconditionally
defaulted to the managed session (which bails on non-Bazzite/SteamOS
boxes and ignores the launch command in all its modes).
- Thread the launch per-session: resolve the library id at handshake,
carry it on SessionContext (Windows: id; else: resolved command), and
hand it to the backend instance via set_launch_command — the global
env write is gone (the env stays as an operator fallback in spawn).
- Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested):
managed only when session-plus/SteamOS infra exists, attach for an
explicit request or a foreign (non-host-descendant) gamescope, else
bare spawn — which nests the launch and is now reachable on plain
distros instead of the guaranteed managed-mode bail.
- launch_session_command: one launch entry point for both planes once
capture is live — desktop compositors plain-spawn into the retargeted
session (the virtual output is primary); managed/attached gamescope
spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY
discovered from /proc (steam:// URIs also forward over Steam's own
pipe). launch_is_nested gates bare spawn against double-launching.
- GameStream unified onto the same dispatch; also nests library-id
picks into gamescope (previously only apps.json cmd was nested).
Validated live on the dev box up to the missing-GPU wall: handshake
resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the
command nested. On-glass validation (kwin spawn on the streamed output,
Bazzite/Deck managed forward) pending GPU reattach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The punktfunk/1 virtual microphone was created as a plain Audio/Source with
no session priority, which caused two failures — both diagnosed live against a
Bazzite host on PipeWire 1.4.10:
1. It was never WirePlumber's default source, so any app recording the *default*
input (games, Discord, arecord) heard silence. This is the Linux analogue of
the Windows host forcing the default recording endpoint (audio_control.rs).
2. The real killer on PipeWire 1.4.x: a *non-default* Audio/Source recorded via
`--target` never gets a driver assigned — the {source, recorder} group stays
orphaned (pw-top QUANT/RATE 0, driver-node None), so the RT process() callback
never fires and even an explicitly-selected mic is pure silence. PipeWire 1.6
drives any recorded source regardless, which is why the host worked on a 1.6
box but not the 1.4.10 Bazzite host.
Fix: advertise a high priority.session on the source so WirePlumber elects it as
the default source and keeps it driven. Reproduced with a faithful standalone
copy of the node on the same 1.4.10 daemon: no priority.session -> silent,
priority.session set -> audio. Only overrides WirePlumber's auto default; a
user's explicit default.configured.audio.source still wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild configuration.md into a complete PUNKTFUNK_* reference (verified
against config.rs, the host.env templates, and the env read sites): core,
gamescope/session-following, compositor, video quality, gamepads, audio,
Windows host, auth/paths, perf tuning, diagnostics, and client-side knobs.
Rework bazzite.md: it now documents both Steam Gaming Mode (gamescope) and
the KDE Plasma desktop with auto-detect/session-following, attach vs managed,
and the Desktop screencast + kde-desktop-setup.sh input grant — previously it
only described the managed gamescope model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the client READMEs to be accurate and inviting to first-time
visitors, and fill in the gaps where crates and tools had none.
- Rewrite clients/{apple,android,decky} READMEs (features-first, trim
dense internal narrative; drop the stale "one session at a time" /
"renegotiation not implemented" section from the Apple README).
- Add READMEs for clients/{linux,windows,probe}, which had none.
- Add crate READMEs for punktfunk-host, punktfunk-core, pf-driver-proto.
- Add brief READMEs for tools/{loss-harness,latency-probe}.
- Fix packaging/README duplicate "Option B" heading (bootc -> Option C).
- Fix docs-site/README stale docs/ -> design/ reference.
- De-stale packaging/windows/drivers/pf-dualsense README (drop "M0 spike"
/ external-checkout framing; reflect in-tree workspace + shipped +
installer-bundled + multi-pad), keeping the driver-authoring lore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpenAPI info.version tracks the crate version, so the 0.4.2 bump
(0604c4f) left api/openapi.json stale at 0.4.1 and would redden
mgmt::tests::openapi_document_is_complete_and_checked_in. The API surface is
unchanged since the last regen (ecbbff5 already refreshed it for the new
library endpoints), so this is the version string only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The [workspace.package] version (inherited by every crate via version.workspace)
lagged at 0.4.1 — bump it to 0.4.2, the release being cut, and refresh the 8
workspace entries in Cargo.lock to match (CI builds --locked). This is a patch
release (Windows CI fixes + Apple gamepad UI); the canary base fallbacks stay
at 0.5.0, already one minor ahead of the 0.4.x stable line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FFMPEG_DIR alone satisfies the linker, but the test binary needs the actual DLLs
on PATH at runtime. The daemon's own env (project-env.ps1, written by the
punktfunk-extras provisioning step) only takes effect on daemon *restart*, so a
freshly cloned/registered runner's first-ever job runs before that file has ever
been written, let alone picked up - confirmed live as STATUS_DLL_NOT_FOUND on the
new home-windows-runner-1's first real CI run. Setting PATH via GITHUB_PATH here
makes the workflow self-sufficient regardless of daemon restart timing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Windows CI runner (home-windows-runner-1, vmid 210) is now provisioned/owned by
unom/infra and can be rebuilt or joined by additional windows-amd64-labeled runners at
any time - a manually-dispatched provisioning workflow has no way to target a specific
runner instance, so it could land on an already-provisioned box instead of the one that
needed it. Replace windows-drivers-provision.yml / windows-punktfunk-provision.yml with
scripts/ci/ensure-windows-toolchain.ps1, a shared idempotent pre-flight (WDK/cargo-wdk,
FFmpeg, Inno Setup, ARM64 rustup target) that every Windows workflow now runs at job
start - a fast no-op once already provisioned, so any runner self-heals on first real use.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both Windows publish steps threw on any non-zero curl exit, so re-running a vX.Y.Z
tag (e.g. after a force-push) failed at the versioned generic-registry path —
that path is immutable and 409s a re-upload of an already-published version. The
channel alias right below already delete-then-reuploads to dodge this; mirror that
intent for the versioned path by reading the HTTP status and treating 409 as a
no-op. The MSIX/installer still build, sign, and attach to the release fine — this
only unbreaks the redundant re-publish on a tag re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rust-toolchain.toml floats `channel = "stable"` + requests rustfmt/clippy. When a
newer stable lands upstream, that makes rustup try to update the baked, minimal-
profile `stable` toolchain in place during %build, and the builder image's
OverlayFS rejects the staging rename with EXDEV ("Invalid cross-device link"),
failing the RPM build (started the day Rust 1.96.1 shipped). A release build needs
no rustfmt/clippy, so pin RUSTUP_TOOLCHAIN=stable to use the installed toolchain
as-is — no channel re-resolve, no component add, no update. Scoped to the RPM
%build; ci.yml/deb.yml (rust-ci image) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpenAPI `info.version` tracks the crate version, so the 0.4.1 bump (4563a04)
left api/openapi.json stale at 0.3.0 and reddened
`mgmt::tests::openapi_document_is_complete_and_checked_in`. The API surface is
unchanged since v0.4.0, so this is the version string only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ba39b08 capitalized the spec Name to `Punktfunk` in a branding sweep, but
build-rpm.sh writes the git-archive tarball as lowercase `punktfunk-<v>.tar.gz`
(prefix dir likewise). `%{name}` drives Source0, `%autosetup -n`, and the
`%{_datadir}/%{name}` install path, so the capital-P both broke the build
(rpmuncompress: `Punktfunk-<v>.tar.gz: No such file`) and would have renamed the
published package + its share dir vs every prior release. RPM names are lowercase
by convention; v0.3.x / v0.4.0 shipped as `punktfunk`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The [workspace.package] version (inherited by every crate via version.workspace)
lagged at 0.3.0 through the 0.4.0 release — bump it to 0.4.1, the release being
cut, and refresh the 8 workspace entries in Cargo.lock to match (CI builds
--locked).
Also advance the CI canary-base fallbacks (deb/rpm/flatpak/android/release
workflows + build-rpm.sh) from 0.3.0 to 0.5.0 so main/canary builds sort one
minor ahead of the latest stable line, per the documented channel convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.
Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
Bun entry, launchers, systemd units, and packaging.
Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
2.5.1; all lint issues fixed cleanly.
Also includes this branch's in-progress host, Apple client, packaging, and CI changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI `cargo fmt --all --check` flagged fetch_image's base64/header chains (added in
12c7ec9 — clippy was run, fmt --check was missed). Pure formatting, no logic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the GameStream/Moonlight plane up to the native plane's capability parity.
HDR (Windows only):
- New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native
policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so
Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301).
- Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to
OutputFormat::resolve, so a client HDR request proactively enables advanced color
on the per-session virtual display (PQ flows even from an SDR desktop). The
encoder bit depth now derives from the captured frame format (gs_bit_depth) rather
than a hard-coded 8 that mislabeled the already-Main10 HDR stream.
Game library in /applist:
- The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of
Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the
store-qualified library id. Launch routes through the existing security-reviewed
launch_title/launch_command via library::launch_gamestream_library — a client can
only pick an existing title, never inject a command.
- /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve
appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo;
data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the
host HDR capability.
4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows
IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false);
the gate is documented so it lights up once IDD-push full-chroma capture lands.
Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library
shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests
cover the HDR mask, stable-id, dedup, and data-URL paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the outdated Windows Host page:
- Capture is IDD direct-push only — drop the stale Windows.Graphics.Capture +
Desktop Duplication claim and the (removed) monitor-capture fallback; the
pf-vdisplay driver is now required.
- Install link points at the Gitea release (where the signed installer is
attached) instead of the package registry.
- Brand prose as "Punktfunk" (executables/paths/protocol/URLs/service names
stay as-is).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GameStream video path (open_gs_virtual_source) ran the Linux compositor-
detection state machine on every platform. On Windows detect_active_session()
returns None and vdisplay::detect() bails ("could not detect compositor ...
XDG_CURRENT_DESKTOP=''"), killing the video thread right after RTSP PLAY — so a
Moonlight client paired, negotiated, then black-screened and dropped.
The native punktfunk/1 path already guards this (resolve_compositor returns a
placeholder Compositor on Windows, since vdisplay::open ignores the compositor
arg there and always uses the pf-vdisplay IddCx backend). Mirror that guard in
the GameStream path: short-circuit to a placeholder on Windows, keep the Linux
session detection (apply_session_env/apply_input_env) under cfg(not(windows)).
Validated live: Moonlight -> this box now creates the pf-vdisplay virtual
monitor, attaches the IDD-push ring, and NVENC streams 5120x1440@240.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tvOS archive failed compiling PunktfunkKit: a recent presenter HDR change
dropped the `#if os(macOS)` guard around the EDR calls and applied them "on all
platforms", but `wantsExtendedDynamicRangeContent`, `CAEDRMetadata`, and
`CAMetalLayer.edrMetadata` are all explicitly unavailable on tvOS.
Wrap the EDR usage (and the makeEDR helper, whose return type is the unavailable
CAEDRMetadata) in `#if !os(tvOS)`. macOS + iOS keep the reference-white-anchored
EDR path unchanged; tvOS now sets only the rgba16Float pixel format + itur_2100_PQ
colour space and lets its compositor tone-map from those. The 0xCE grade is still
cached on tvOS (harmless), it just can't be pushed to the layer there.
tvOS Simulator build: BUILD SUCCEEDED (PunktfunkKit Swift compile, the step that
failed). macOS build + test green (49 tests); iOS compiles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The iPad pointer lock engaged but a Magic Keyboard trackpad went dead the
moment a second pointer (a Universal Control "V-UC Automouse") was connected —
on-device PUNKTFUNK_INPUT_DEBUG logs showed only ONE GCMouse attached (whichever
was GCMouse.current), so the other device's motion handler was never installed.
InputCapture.start() now attaches a handler to EVERY GCMouse.mice(), not just
GCMouse.current, so a trackpad and a second mouse both drive (each GCMouse
delivers its own deltas through its own handler). New arrivals still come via the
GCMouseDidConnect observer.
Also gate the WHOLE UIKit indirect-pointer path (motion, buttons AND scroll) on
!gcMouseForwarding, not just motion+scroll: under pointer lock GCMouse owns
buttons too, and the trackpad/mouse also emit UIKit indirect-pointer events
pinned at the locked position — without the gate a click double-sent (GCMouse +
UIKit). The two paths are now exact mirrors on `gcMouseForwarding` (== locked).
Removes the investigation-only diagnostics (attachedMiceSummary/hasGCMouse, the
per-event UIKit pointer/scroll logs, the GCMouse attach/became-current logs);
the pre-existing `pointer lock isLocked=… captured=…` debug line is restored.
iOS compiles against the SDK; macOS swift build + test green (49 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows virtual mic worked only with manual Sound-settings fiddling: on a
headless host (no real audio output) BOTH the desktop-audio loopback and the
virtual mic must run on virtual cables, and on DIFFERENT ones or the loopback
re-captures the injected mic (echo). The Steam pair gives only one usable cable
(Steam Streaming Speakers loopback is silent — validated), so the mic + loopback
collided and echoed, and when the default playback happened to be the mic device
the anti-echo guard reported the mic "unavailable".
Host now auto-wires the devices at startup (audio/windows/audio_control.rs,
ensure_wired_once, hooked from open_audio_capture/open_virtual_mic): default
playback = a loopback-capable render that is NOT a cable and NOT the dead Steam
Speakers (real output > Steam Streaming Microphone); default recording = the mic
capture (VB-Cable "CABLE Output" preferred). Uses a hand-rolled IPolicyConfig
vtable (the only way to set a default endpoint; not in windows/wasapi crates).
Opt out with PUNKTFUNK_KEEP_DEFAULT. wasapi_mic candidates now prefer "cable
input". Validated live: from a deliberately-wrong start (playback=CABLE Input)
the host corrected both default endpoints at the OS level.
A Windows audio endpoint can only be created by a kernel-mode driver (no UMDF
path — ACX is KMDF-only), so we cannot self-sign our own like the UMDF gamepad/
display drivers. Instead the installer bundles + silently installs the official
base VB-CABLE (VB-Audio donationware, vendor-signed → loads with no test-signing,
redistributed under VB-Audio's bundling grant): install-vbcable.ps1 (seed the
VB-Audio cert into TrustedPublisher, run -i -h) + an installaudiocable task,
gated on -VbCableDir/$env:VBCABLE_DIR (the package binary is not in the repo).
Attribution in packaging/windows/licenses/VB-CABLE-NOTICE.txt. .iss compiles
with the path enabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two phases of pf-vdisplay (IddCx virtual display) lifecycle work, both validated on-glass on the RTX box.
Phase 1 — fix the long-standing IOCTL_ADD 0x80070490 (ERROR_NOT_FOUND) wedge that ghost-monitor
slot-budget exhaustion produced under ADD/REMOVE churn (the reset-script/reboot recurring failure).
Validated: 43 reconnect-churn cycles, 0 wedges, monitor-node count flat at 1.
* driver: on IddCxMonitorArrival failure, tear the created-but-not-arrived monitor down with
WdfObjectDelete + reclaim its id — the asymmetric-with-the-create-failure-path leak that exhausted
the 16-monitor MaxMonitorsSupported budget; recover MONITOR_MODES from lock poisoning instead of
failing closed (defensive; the driver builds panic=abort).
* host: collapse the build-retry churn — hold ONE monitor lease across all build attempts and preempt
only on Lingering (not Active), so a cold start does 1 ADD not 8; reap not-present "punktfunk"
monitor PDOs on startup (the reset-script step-2 logic, in-process) and self-heal a detected
0x80070490 by reaping + retrying ADD; force-preempt a stuck-Active prior monitor on the
begin_idd_setup timeout (the safety net the Lingering-only preempt would otherwise drop).
Phase 2 — give each client (keyed by its cert FINGERPRINT) a STABLE virtual-monitor id (1..=15) so
Windows reapplies that client's saved per-monitor config (DPI SCALING) across reconnects, and two
clients never share/bleed config. Validated: distinct clients -> distinct ids (1, 2); the driver
honors the host's id (echoed resolved == preferred).
* proto: rename AddRequest._reserved -> preferred_monitor_id (offset 20) and AddReply._reserved ->
resolved_monitor_id (offset 12) — byte-compatible (offset asserts), NO PROTOCOL_VERSION bump, so a
pre-Phase-2 driver degrades gracefully to auto-id (the host detects it via the resolved echo).
* driver: create_monitor honors a host-supplied preferred id via resolve_id (range 1..=15, never
collides with a live monitor) and seeds the EDID serial + IddCx ConnectorIndex + ContainerId from it.
* host: a persisted LRU fingerprint->id map (%ProgramData%\punktfunk\pf-vdisplay-identity.json),
threaded to add_monitor via a set_client_identity no-op trait method (Linux/GameStream unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove DXGI Desktop Duplication (DuplCapturer), Windows.Graphics.Capture
(WgcCapturer), the two-process SYSTEM+helper relay (virtual_stream_relay /
HelperRelay / DesktopWatcher / composed_flip), and the five source files that
implemented them. IDD direct-push is now the sole Windows capture path; the
session topology is always SingleProcess.
Deleted files: wgc.rs, wgc_relay.rs, desktop_watch.rs, composed_flip.rs,
windows/wgc_helper.rs (+ wgc-helper subcommand in main.rs).
dxgi.rs is kept but carved to shared GPU primitives only (make_device,
HdrP010Converter, VideoConverter, install_gpu_pref_hook, WinCaptureTarget,
pack_luid) — ~2237 lines of DDA-only code removed; imports cleaned.
capture.rs: IDD-push open failure fails the session cleanly (no fallback).
Adds capturer_supports_444() — returns false on Windows (IDD-push 4:4:4 is a
follow-up), replacing the stale single_process gate in 4:4:4 negotiation.
session_plan.rs: CaptureBackend{Dda,Wgc} and SessionTopology::TwoProcessRelay
removed. config.rs: no_helper/force_helper/no_wgc/capture_backend/secure_dda
removed. merged_env_block relocated from wgc_relay to windows/interactive.rs.
Linux cargo check clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HDR toggle in Settings → Display. Persisted (hdr_enabled, default on); the
host is advertised HDR only when the toggle is on AND the panel can present
HDR10 (displaySupportsHdr), so SDR panels never get PQ they'd mis-tone-map.
The toggle is disabled/greyed on non-HDR displays (ToggleRow gained `enabled`).
- Extend the stats HUD with a video-feed line, e.g.
"HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0". nativeVideoStats now returns 14
doubles (appends bitDepth, CICP primaries/transfer, chroma_format_idc from the
negotiated Welcome); older/shorter layouts just omit the line.
- Lock the stream to landscape while streaming (SENSOR_LANDSCAPE), restoring the
prior orientation on exit. The activity declares configChanges=orientation, so
it re-lays-out in place with no stream restart; harmless no-op on TV.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete +
all CI checks green on Linux + adversarially reviewed; on-glass validation pending:
- usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for
non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so
Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake
→ sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop.
- Backed by a vendored, libusb-free trim of the `usbip` crate
(crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb
removed; interrupt-IN paced by bInterval).
- Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID,
with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs.
- Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted
serial consolidated into steam_proto.rs; the raw_gadget backend reuses them.
- Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord
(L1+R1+Start+Select) >=1.5s end the session (short press still exits
fullscreen); the chord state resets across sessions.
Also bundles in-progress work already staged in the tree:
- host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend
places absolute coordinates correctly under display scaling.
- docs: design/README index entries + design/controller-only-mode.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Write design/steam-deck-passthrough-plan.md — the build plan to ship exact Steam
Deck pass-through from the Linux client (incl. the Steam + QAM buttons) plus a
virtual Deck on any Linux host. Key validated facts captured so the next session
doesn't re-investigate:
- Client capture is ALREADY correct: SDL3 maps Steam->Guide, QAM->Misc1; the
client forwards BTN_GUIDE/BTN_MISC1; the host maps them to btn::STEAM/btn::QAM.
Only precondition: Steam Input disabled on the client (the Decky UX).
- Shippable host transport = usbip + vhci_hcd (in-tree + signed everywhere, no
module build, no MOK) — PROVEN on Bazzite: Steam promotes the usbip interface-2
Deck (XInput slot + X-Box pad), identical to raw_gadget on SteamOS.
- Build steps: refactor steam_gadget.rs into shared Deck-logic + a transport
trait; add the usbip transport (vendor-trim the usbip crate to drop rusb/libusb,
in-process vhci attach); transport-select raw_gadget->usbip->UHID/DualSense;
client leave-shortcut (controller chord + Ctrl+Alt+Shift+D); serial polish.
Also checks in the working usbip Deck PoC (packaging/linux/steam-deck-gadget/
usbip-poc/) for the next session to build on. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The virtual Steam Deck is validated glass-to-glass on a Deck: it appears as a
distinct second Steam controller, a held A drives Steam's overlay ("Resume
Game"), and a button press registers in a real game (confirmed in-game).
gadget_preferred() now defaults ON for SteamOS hosts (/etc/os-release ID=steamos
or ID_LIKE), OFF elsewhere where the universal UHID path stays the default;
PUNKTFUNK_STEAM_GADGET=1/0 forces it. A Deck-as-host with a physical Deck never
reaches this path — resolve_gamepad's conflict gate degrades SteamDeck → DualSense
first, so the two-Deck case never happens in production (it was only a test-rig
confound on the dev Deck).
The feature is complete: a virtual Steam Deck that Steam Input recognizes +
promotes, churn-free, with input flowing to games. Workspace clippy/fmt/test
green. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The virtual Deck's gamepad evdev was churning (destroyed + recreated) because
Steam kept re-probing: GetControllerInfo reads HID feature reports, and the gadget
served zeros for them. Captured the real contract off a physical Deck
(packaging/linux/steam-deck-gadget/get_deck_attrs.c, hidraw HIDIOCGFEATURE — usbmon
truncates to 32B) and implemented it in steam_gadget.rs::feature_reply:
- 0x83 GET_ATTRIBUTES_VALUES: [83, 2d, 9×(attr-id, u32-LE)] — product id 0x1205, a
per-instance unit serial (0x0a/0x04, so a gadget never collides with a real Deck
or another gadget), and the capability attrs (0x09=0x2e, 0x0b=0x0fa0, rest 0).
- 0xAE GET_STRING_ATTRIBUTE: [ae, len, attr, ascii] — serial (attr 1) / board
serial (attr 0).
- other commands (0x87 settings): echo the last write.
Validated on the Deck: 1 connect / 0 disconnect / 1 gamepad evdev (was constant
churn), Steam activates the gadget cleanly (no GetControllerInfo failed, no zombie)
and emits its X-Box 360 pad. usbmon on the gadget's bus confirms our state reports
(pressed button at byte 8) are delivered on the interrupt-IN and consumed by
hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode the input chain is proven
end-to-end. Remaining: a foreground-game confirmation of Steam Input's XInput
mapping, then default the gadget on for SteamOS.
Workspace clippy/fmt/test green. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the proven raw_gadget virtual Deck to a Rust host gamepad backend, the
SteamOS-only transport that gets Steam Input to actually promote the Deck.
- inject/linux/steam_gadget.rs (new): SteamDeckGadget — a userspace raw_gadget
emulator of the real 3-interface USB Deck (mouse=0/keyboard=1/controller=2,
28DE:1205) on a dummy_hcd loopback UDC, descriptors captured from a physical
Deck, answering every control transfer incl. the HID feature reports. Driven by
the same steam_proto::serialize_deck_state as the UHID pad; rumble feedback via
parse_steam_output. The raw_gadget UAPI is funneled through 4 documented ioctl
wrappers (the crate denies undocumented unsafe).
- inject/linux/steam_controller.rs: the manager pad is now a DeckTransport enum
(Uhid | Gadget); ensure() prefers the gadget when PUNKTFUNK_STEAM_GADGET=1
(best-effort modprobe dummy_hcd+raw_gadget), gracefully falling back to the
universal UHID SteamDeckPad. write/pump/heartbeat dispatch through the enum.
Validated on a real Deck via a static musl harness that #[path]-includes the
module: enumerates, hid-steam binds + reads our serial + creates the Steam Deck +
Motion Sensors evdevs — identical to the C PoC. Caught a real portability bug:
raw_gadget's no-arg ioctls (RUN/CONFIGURE/EP0_STALL) reject a non-zero `value`
with EINVAL, and on musl an omitted ioctl vararg is a garbage register — so they
must pass an explicit 0.
Opt-in (default off) while the Steam GetControllerInfo feature contract is
hardened (to stop the gamepad-evdev churn). Workspace clippy/fmt/test green. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Deck, a pressa build shows hid-steam polls our interface-2 interrupt-IN
endpoint and our 64-byte state reports are delivered ("STREAM: first input report
delivered"). The report format is already validated (M1 serializer on-box + M2's
EVIOCGKEY/EVIOCGABS test on the same hid-steam decode). The "Steam Deck" gamepad
evdev forms but is transient (hid-steam recreates it as gamepad_mode toggles —
Steam keeps re-probing because the PoC serves the serial but not Steam's full
GetControllerInfo attribute set, on a heavily-churned test Deck), so a stable live
EVIOCGKEY catch of the held A wasn't obtained. Delivery + format proven; the
evdev transience is a feature-report-completeness gap the host backend resolves.
Doc §11. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The interface-2 wall is climbed. packaging/linux/steam-deck-gadget/deck_raw_gadget.c
is a raw_gadget userspace emulator of a real 3-interface USB Steam Deck (28DE:1205,
mouse=0/keyboard=1/controller=2) on a dummy_hcd loopback UDC, with descriptors
captured verbatim from a physical Deck and full HID feature-report handling.
Live on a real Deck (SteamOS 3.8.11): hid-steam reads our serial (PFDECK000),
creates the Steam Deck + Motion Sensors evdevs, and Steam Input PROMOTES it —
controller.txt "Interface: 2 ... device opened ... reserving XInput slot 1" +
"input: Microsoft X-Box 360 pad 1". Stable (1 connect, 0 disconnects, no zombie);
the kernel Steam Deck evdev is then grabbed by Steam Input which exposes its own
X-Box pad, exactly like a real Deck. First time a virtual Deck is fully Steam-Input
promoted (UHID can't — it has no USB interface number, so Steam filters it).
Also includes the configfs f_hid variant (configfs_gadget_up/down.sh) — the minimal
reproducer that proved interface 2 makes Steam open+XInput-reserve the device, but
f_hid can't serve feature reports so Steam dropped it as a zombie.
Gotchas documented in the README: 7-byte vs 9-byte endpoint descriptor, no-data OUT
controls acked via zero-length EP0_READ (not WRITE, else error -110), streamer must
not start before SET_CONFIGURATION is acked. SteamOS-host only (needs dummy_hcd +
raw_gadget). Recognition proven; feeding real client reports + a host backend is next.
Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Deck (which ships dummy_hcd + raw_gadget + configfs f_hid), a pure-shell
configfs gadget stood up a real 3-interface USB Deck (kbd=0/mouse=1/controller=2,
28de:1205) on a dummy_hcd loopback UDC. hid-steam bound all 3 interfaces, and
crucially Steam PROMOTED the interface-2 controller: "Local Device Found ...
Interface: 2 ... Steam controller device opened for index 14 ... Steam Controller
reserving XInput slot 1" — exactly where the interface -1 UHID Deck was filtered.
It then failed only at feature-report exchange (f_hid can't serve HID GET_REPORT:
"steam_send_report: error -32", "couldn't get controller details ... zombie
controller"), and no gamepad evdev formed for the same reason. So interface 2 is
necessary AND sufficient for Steam to open+XInput-reserve the Deck; the remaining
piece is serving feature/output reports, which raw_gadget can (full control,
like UHID). Next: a raw_gadget 3-interface Deck emulator. Doc §11. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Definitive hardware test (Bazzite running Steam): a virtual DualSense (UHID,
054c:0ce6, Interface: -1) is FULLY promoted by Steam — controller.txt logs
"Local Device Found 054c 0ce6 DualSense Wireless Controller", then "Controller
using HIDAPI driver vid=0x054c pid=0x0ce6" and loads configset_controller_ps5.vdf
(our calibration/pairing/firmware feature blobs read back). The SAME Interface:
-1 that the Deck is rejected at is accepted for the DualSense.
So the wall is specifically the Deck's MULTI-INTERFACE requirement (Steam must
pick interface 2 among kbd/mouse/controller), NOT a UHID limitation. The
DualSense path delivers real Steam Input (gyro + touchpad + glyphs + bindings)
for a streamed Deck/SC client; it loses only Deck glyphs, the 2nd trackpad, and
the 4 back grips as distinct Steam-Input paddles (M5 folds them to buttons).
Full Deck-identity Steam Input would need interface 2 -> a USB gadget (dummy_hcd
+ configfs HID, controller on interface 2). Feasible but heavy/non-portable:
dummy_hcd isn't built on Bazzite/Deck/dev-box, so it'd be a per-kernel build +
(on immutable SteamOS/Bazzite) a package-layer + reboot per host.
Doc-only (design §11). Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardware finding (a SteamOS Deck @ .253 + a Bazzite host @ .41, both running
Steam, via a minimal C UHID probe on Bazzite): a UHID virtual Steam Deck binds
the kernel hid-steam and creates the evdevs (so kernel-evdev + SDL-hidapi
consumers see the full grips/trackpads/IMU surface), but Steam Input will NOT
manage it. Steam's controller.txt enumerates it ("Local Device Found, 28de 1205,
Product Punktfunk Steam Deck") but logs Interface: -1 and never promotes it (no
28de:11ff XInput pad). The physical Deck on the same logs is Interface: 2 — a
real Deck is a 3-interface USB device (kbd 0 / mouse 1 / controller 2) and Steam
binds the controller on interface 2; a single UHID device has no USB interface
number, so Steam reads -1 and filters it out. (The feared 0x83/0xA1 attribute
probes never fired — it's an interface filter, not a probe-reject.)
Consequences (design §11):
- The virtual Deck's value is non-Steam / SDL games on Linux (grips + trackpads
+ gyro via evdev / SDL HIDAPI), NOT Steam Input.
- The virtual DualSense stays the Steam-Input path everywhere (Steam recognizes
a single-interface DualSense); M5's paddle-fold carries the back grips.
- M7 (a Windows UMDF virtual Deck) is NOT recommended: same interface filter,
and Windows has no kernel-hid-steam evdev fallback, so nothing would consume
it; the existing Windows virtual DualSense already covers that case.
- M0-M6 is not wasted: the protocol/wire + client capture feed the DualSense
path too, and the virtual Deck is the best option for non-Steam Linux games.
Doc-only (design/steam-controller-deck-support.md): added §11, updated the status
+ pending-validation. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Don't present a virtual Steam (28DE) pad on a host that already has a physical
Steam controller — the host's own Steam Input would then manage two Decks and
confuse player assignment.
- physical_steam_controller_present(): scans /sys/bus/hid/devices for a 28DE HID
device on a real (non-/virtual/) path.
- degrade_steam_on_conflict() in resolve_gamepad: a resolved SteamDeck /
SteamController with a physical Steam controller attached degrades to DualSense
(then the M5 uhid ladder); PUNKTFUNK_STEAM_FORCE=1 overrides (e.g. a remote-only
box with no competing Steam Input).
Validated on real hardware (a SteamOS Steam Deck @ .253 + a Bazzite host @ .41,
both running Steam):
- Conflict confirmed: the Deck-as-host already has its physical 28DE:1205 AND
Steam's 28DE:11FF XInput output pad live; a 2nd virtual 28DE = two Decks.
- Bind robustness: the virtual Deck binds hid-steam on a SECOND kernel (Bazzite
6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1 fix).
- Criterion-4 (running-Steam recognition) PARTIAL: a userspace consumer (Steam/
SDL) engaged the virtual Deck (opened the hidraw, ran the lizard-disable +
settings sequence the kernel's Deck path skips) but emitted NO 28DE:11FF XInput
pad on the desktop — so Steam recognizes it enough to manage lizard mode but did
not promote it to a managed XInput controller (likely needs a Big-Picture/game
context, or a richer device; the 0x83/0xA1 attribute probes never fired, so it
wasn't a probe-reject either).
- The heuristic itself checks TRUE on the Deck, FALSE on Bazzite.
Workspace clippy/fmt/test green. Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the rich Steam inputs from silently dropping when the resolved backend
isn't the virtual hid-steam device, and fix a cross-device motion-scale bug.
- inject/proto/steam_remap.rs (new, pure + unit-tested):
* motion_wire_to_deck — the wire carries DualSense-convention units (20 LSB/
deg.s gyro, 10000 LSB/g accel — what every client capture emits), but the
Deck's hid-steam report wants 16 LSB/deg.s + 16384 LSB/g. The Deck backend
now rescales (gyro x16/20, accel x16384/10000): a real Deck<->Deck gyro/
accel correctness fix (the DualSense/DS4 backends consume the wire 1:1).
* fold_paddles + RemapConfig (PUNKTFUNK_STEAM_REMAP=paddles=drop|stickclicks|
shoulders, default drop) — the DualSense + DS4 managers fold a client's back
grips onto standard buttons rather than dropping them (those pads have no
back-button HID slot; the uinput Xbox pad already exposes them as Elite
paddles BTN_TRIGGER_HAPPY5-8).
- resolve_gamepad: a runtime degrade ladder — a UHID backend (DualSense / DS4 /
Steam Deck) on a host where /dev/uhid isn't writable now falls back to the
uinput Xbox 360 pad instead of a dead controller (the device-create would
just fail). Separate from pick_gamepad's compile-time platform check, so the
existing pick_gamepad tests are untouched.
- Delete the throwaway M0/M1 spike (src/bin/steam_uhid_spike.rs) — M2's
#[ignore]d backend test subsumes its validation, and removing it frees
steam_proto to reference steam_remap cleanly.
On-box backend test still green; workspace clippy/fmt/test green (incl. the new
steam_remap tests). Deferred as optional RemapConfig growth: gyro->mouse /
trackpad->stick synthesis on an Xbox target (no slot — documented drop today).
Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the client side of the Steam Controller / Steam Deck pipeline.
- C-ABI (core abi.rs): PunktfunkRichInputEx — a size-prefixed superset of
PunktfunkRichInput that can express the second trackpad (surface), a distinct
click vs touch, signed coords + pressure — plus
punktfunk_connection_send_rich_input2 (the struct_size ABI-skew-guard
precedent). The only way a C client (Apple/embedders) can emit a TouchpadEx;
the legacy struct + send_rich_input stay byte-for-byte. punktfunk_core.h
regenerated.
- Decky (clients/decky): a "Steam Deck" gamepad type in Settings + an unmissable
Disable-Steam-Input instruction shown when it's selected (in Game Mode Steam
Input holds 0x1205, so the SDL HIDAPI Steam driver can't open the Deck's
controls until the user disables Steam Input for the shortcut). Plus a
best-effort, feature-detected disableSteamInputForShortcut() in launchStream —
never blocks/throws; the manual toggle is the documented source of truth.
- Apple parity (PunktfunkConnection.swift): GamepadType.steamController/steamDeck
(wire 5/6) + name parsing, so the resolved type round-trips. Capture is blocked
(GameController never surfaces a 0x28DE HID device).
- Android parity (Gamepad.kt): PREF_STEAMCONTROLLER/STEAMDECK + the Valve 0x28DE
PIDs in prefFor(). Rich-input capture stays out of scope (no rich-input plane
yet) — standard buttons/sticks resolve to the host's Steam Deck pad.
Rust workspace clippy/fmt/test green; Decky src/ typechecks clean (only a
pre-existing @decky/api dep resolution error remains); Swift/Kotlin compile on
their CI. The full pipeline is now BUILT; what remains is validation that needs
hardware we don't have (a running Steam on the host, a live Deck client, the
Moonlight paddle regression). Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux + Windows native clients (clients/{linux,windows}/src/gamepad.rs) now
capture and send the Steam Controller / Steam Deck rich inputs, so a real Deck
(off Steam Input) or a Steam Controller on a desktop client drives the host's
virtual hid-steam pad end-to-end:
- Set SDL's HIDAPI Steam hints (SDL_JOYSTICK_HIDAPI_STEAMDECK / _STEAM) before
init so SDL opens Valve devices directly (paddles + both trackpads + gyro as
first-class SDL gamepad inputs).
- Detect the Deck/SC by VID/PID (0x28DE + 0x1205 / 0x1102 / 0x1142) ->
GamepadPref::SteamDeck (there is no SDL gamepad type for it), so the host
builds the virtual Deck with the right identity.
- Map the SDL paddle + Misc1 buttons -> BTN_PADDLE1..4 / BTN_MISC1 (a free win
for Xbox Elite paddles too).
- Route a SECOND touchpad -> RichInput::TouchpadEx (SDL touchpad 0 = left ->
surface 1, 1 = right -> surface 2, signed coords); a single touchpad keeps the
legacy Touchpad. New forward_touch() helper centralizes the choice.
- Track held touchpad contacts per (surface, finger) and lift them on pad
switch/detach so a contact held at that moment can't stick.
- Sensor (gyro/accel) capture was already generic across pad types.
Linux client builds + clippy clean; the Windows client is a near-verbatim
mirror (windows CI compiles it). On a Deck in Game Mode, Steam Input still holds
the device — the user disables Steam Input for the client (the Decky UX, next);
on a desktop client (or a Deck with Steam Input off) the hints just work.
Remaining M4: Decky Disable-Steam-Input UX, Apple/Android parity, and the C-ABI
PunktfunkRichInputEx + send_rich_input2 (Apple/embedder send path). Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire —
strictly additive + forward-compatible (unknown kinds/bits drop on old peers).
Core (punktfunk-core):
- input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace
(so the GameStream paddle path and native grips share one host injector map;
Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots).
- quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed
coords, pressure; the second trackpad the single Touchpad can't express) and
HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped.
- abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits,
RICH_TOUCHPAD_EX / HIDOUT_TRACKPAD_HAPTIC constants. from_hid packs
TrackpadHaptic into the existing which + effect[0..6] — the legacy structs do
NOT grow (guarded by new size_of==20/19 asserts); GamepadPref lockstep +
paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated.
Host (punktfunk-host):
- steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM;
apply_rich routes TouchpadEx left/right -> the matching pad.
- every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm
(surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles
everywhere and a Steam client streaming to a DS host keeps its right pad.
- the xpad BUTTON_MAP finally consumes the GameStream paddle bits
(BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently
no-op'd before (design §5.6).
- Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA).
Validated on-box: the ignored backend test now drives the full wire path —
from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the
evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping
unit-tested. Workspace clippy/fmt/test green. Not pushed.
Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the
Apple/embedder *send* path needs it; the host decodes TouchpadEx today).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the virtual hid-steam device a selectable per-session host gamepad,
end-to-end on Linux: PUNKTFUNK_GAMEPAD=steamdeck now builds a
SteamControllerManager that creates a /dev/uhid 28DE:1205 Deck, enters
gamepad_mode, and feeds the byte-exact Deck report (M1).
- inject/linux/steam_controller.rs: SteamControllerManager / SteamDeckPad,
mirroring dualsense.rs (open/create2, GET/SET_REPORT pump, heartbeat, RAII
destroy). Two Steam-specific quirks beyond the DualSense path:
* gamepad_mode entry — best-effort `lizard_mode=0` via sysfs, plus a b9.6
creation pulse (MODE_ENTER) so steam_do_deck_input_event stops
early-returning, plus an anti-toggle guard (MENU_HOLD_CAP) so a long
in-game Start-hold can't flip gamepad_mode back off.
* UHID_SET_REPORT answered err=0 (DualSense omits it; the kernel stalls
~5s/cmd otherwise); the 0xEB rumble report parsed onto the 0xCA plane.
- core config.rs: GamepadPref::SteamDeck (wire byte 6) + SteamController
(byte 5, reserved — folds to Xbox360 until its backend lands); from_u8 /
from_name / as_str. Forward-compatible (unknown byte -> Auto); the C-ABI
PUNKTFUNK_GAMEPAD_* constants stay M3, so no generated-header drift.
- punktfunk1.rs: PadBackend::SteamDeck variant + select / handle / apply_rich
/ pump / heartbeat arms; pick_gamepad Linux arm.
On-box: an #[ignore]d backend test (backend_binds_and_input_flows) drives the
real SteamDeckPad — it binds hid-steam (gamepad + IMU evdevs), enters gamepad
mode, BTN_A reaches the evdev, and the device tears down on drop. Workspace
clippy/fmt/test green. Not pushed. Next: M3 (protocol/ABI wire) + M4 (client
capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flesh out inject/proto/steam_proto.rs into the full Steam Deck HID contract,
transcribed verbatim from the kernel steam_do_deck_input_event /
steam_do_deck_sensors_event and validated field-for-field against kernel 7.0:
- SteamState: the u64 button map (bytes 8..16), sticks/triggers/trackpads/IMU
stored as raw little-endian report values; serialize_deck_state is a pure,
byte-exact memcpy into the 64-byte unnumbered frame.
- from_gamepad (XInput frame -> Deck buttons/sticks/triggers) + apply_rich
(RichInput touchpad -> right pad, motion -> IMU).
- parse_steam_output: the 0xEB ID_TRIGGER_RUMBLE_CMD feedback -> (low, high)
for the universal rumble plane.
- serial_reply fixed: prepend the report-id-0 byte the kernel strips
(steam_recv_report does memcpy(data, buf+1, ...)); M0's reply lacked it, so
the kernel fell back to the "XXXXXXXXXX" serial.
- SteamModel (Deck now; classic Controller later), command/feature IDs.
The spike is repurposed as the M1 validator: it pulses the b9.6 mode-switch to
enter gamepad_mode (steam_do_deck_input_event early-returns under the default
lizard_mode otherwise), then holds a known test pattern. Reading both evdevs via
EVIOCGABS/EVIOCGKEY, every field matched: ABS_X/Y/RX/RY (incl. the kernel
Y-negation), both triggers, the touched right-pad HAT1X/Y, the IMU accel/gyro
(with ABS_Z/RZ negations), and the 6 expected buttons incl. the L4/R5 grips.
5 unit tests + workspace clippy/fmt/test green. Next: M2 (SteamControllerManager
UHID backend + PadBackend wiring). Not pushed — pipeline not yet shippable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greenfield virtual Steam Deck controller, the Steam analogue of the shipped
virtual DualSense. Proves the kernel hid-steam driver binds a /dev/uhid
28DE:1205 device, registers it as a real Steam Deck, and parses our input
reports — the go/no-go gate for the full Steam Controller/Deck pipeline.
- inject/proto/steam_proto.rs: keeper module — the vendor HID descriptor (one
feature report, the sole thing steam_is_valve_interface() checks), the
command/feature IDs, serialize_deck_state, and the serial GET_REPORT reply.
Unit-tested.
- src/bin/steam_uhid_spike.rs: throwaway M0 spike (Linux-only) — opens
/dev/uhid, creates the device, services the handshake including
UHID_SET_REPORT (which the DualSense backend omits and which hid-steam
stalls ~5s/cmd without), and heartbeats a neutral report.
- design/steam-controller-deck-support.md: full design + M0–M7 plan; the two
walls (Steam Input capture ownership; virtual-Steam recognition) and the
fidelity ceiling. Status: M0 GREEN.
On-box (headless Ubuntu 26.04, kernel 7.0, no Steam): journalctl -k shows
hid-steam binding the device (rebind off hid-generic), "Steam Controller
connected", and the kernel creating BOTH a "Steam Deck" gamepad evdev and a
"Steam Deck Motion Sensors" IMU evdev (INPUT_PROP_ACCELEROMETER). A
layout-agnostic mash-probe drove 23 distinct BTN_* codes through
hid-steam -> evdev, proving the input-report parse path. M1 line-checks the
exact per-bit report layout against the lab kernel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17/18 now fixed. A red-team of the three accepted findings showed #9 and #13
rested on a circular premise (each was the other's "safe fallback") and S7's
written rationale was wrong (signing exercises the same modexp Marvin targets).
#9/#13 closed; S7 accept retained for the corrected reasons + amplifier hardened.
See f0574a5, f6c9576.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Red-team found the .cargo/audit.toml justification for RUSTSEC-2023-0071 was
materially wrong: it claimed "Marvin targets decryption, so the vulnerable path
isn't exercised" — but the advisory is a variable-time modexp of the secret
exponent, which RSA *signing* (signing_key.sign) also runs. The accept is still
correct, for the RIGHT reasons (no decryption/padding oracle; the signed
serversecret is host-random not attacker-chosen; signing is operator-PIN-gated;
GameStream is off by default and the native QUIC plane uses rustls, not rsa;
Moonlight mandates RSA-2048 so the GameStream key can't move off it). Rewrite
the rationale accordingly.
Also shut the timing-sample amplifier the review surfaced: the pairing session
was never marked after phase 3, so a peer past phase 1 could loop phase2/phase3
to harvest many RSA signing-time samples. Sign exactly once per ceremony
(reject a repeated serverchallengeresp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The accepts for #9 (PIN-window burn) and #13 (knock-queue flood) rested on a
circular premise — each cited the other as the safe fallback — and a re-review
showed one LAN attacker could defeat BOTH, denying all onboarding. Close them:
- #13 per-source-IP cap on the pending-knock queue (MAX_PENDING_PER_IP) so one
host can't fill/evict the 32-slot queue (QUIC validates the source address);
and eviction now NEVER drops a live *parked* knock (a held-open connection
awaiting operator approval), so a cert-rotating flood can't evict the genuine
device being onboarded. This makes the delegated-approval path genuinely
flood-resistant — restoring the validity of #9's "use delegated approval on
hostile LANs" fallback.
- #9 fingerprint-bindable PIN window: `NativePairing::arm_for(ttl, Some(fp))`
binds the window to one operator-selected device; `pin_for_attempt` returns
`BoundToOther` for any other fingerprint, which the QUIC pair path rejects
WITHOUT consuming the window — so an unpaired peer can neither pair nor BURN a
window armed for a specific device (it can't forge the bound fingerprint). The
mgmt `POST /native/pair/arm` gains an optional `fingerprint` (from a pending
knock); unbound arming keeps the legacy any-device behavior (trusted-LAN).
(Web-console "pair this pending device with a PIN" UX is a follow-up; the
flood-resistant knock path above is the immediate hostile-LAN onboarding path.)
+ regression tests (armed_pin_is_fingerprint_bindable,
pending_per_ip_cap_and_parked_protection); api/openapi.json regenerated.
110 host tests + clippy + fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15/18 now fixed; no finding remains open and actionable. SDDL scoped to
SYSTEM+LocalService, validated live (6943-frame DualSense+IDD session works;
non-SYSTEM OpenFileMapping now ACCESS_DENIED). See e59fa60.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gamepad host<->UMDF-driver shared sections (Global\pfds-shm-*, pfxusb-shm-*)
and the IDD-push frame ring/event (Global\pfvd-*) were created with
`D:(A;;GA;;;WD)` — GENERIC_ALL to **Everyone** — on the assumption the driver's
WUDFHost ran under a restricted token needing broad access. So any local
unprivileged user could OpenFileMapping the section to inject controller input,
tamper the trusted HID channel, or read captured screen frames
(security-review 2026-06-28 #5).
On-box validation (RTX box, 2026-06-29) disproved the restricted-token premise:
the WUDFHost token is NT AUTHORITY\LocalService (S-1-5-19), SYSTEM integrity,
with ZERO restricted SIDs. So the section only needs SYSTEM (the host creates +
writes it) and LocalService (the driver opens it). Scope both SDDL sites to
`D:(A;;GA;;;SY)(A;;GA;;;LS)`; rename the now-misnamed `permissive_sa` ->
`shared_object_sa`; correct the stale "restricted-token / Everyone" docs.
Validated live: a full DualSense + 1280x720x60 session — 6943 frames received,
HID output round-tripped, device status OK (pf_dualsense + pf_vdisplay WUDFHosts
both LocalService open the scoped sections fine), while OpenFileMapping from a
non-SYSTEM admin session now returns ACCESS_DENIED (was a granted handle under
WD). Host-only change (the SDDL is set when the host CREATES the section);
drivers unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CFBundleDisplayName was "Punktfunkempfänger" across all targets/configs; the
in-app title is already "Punktfunk", so make the home-screen name match. Built
iOS app resolves CFBundleDisplayName = "Punktfunk".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stream reliability
- Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects
and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard
on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation).
Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback.
- Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 =
identity) and let the layer's contentsGravity scale via the system compositor — the
same path stage-1's videoGravity used — instead of scaling in-shader.
- Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR
actually lands, so a keyframe request swallowed by the throttle can't strand a frozen
frame; 100 ms keyframe throttle to match the Android path.
- Fix "Publishing changes from within view updates": defer the HostStore writes out of
the .onChange(of: model.phase) callback.
- Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial
queue) to stop the UI-stall warning.
Controllers
- Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811)
so a transient server interruption self-heals instead of cascading; playsHapticsOnly so
a controller engine doesn't join the always-active streaming audio session.
- Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic.
UI / design
- Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the
license surfaced in Acknowledgements.
- Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel
with custom-resolution entry; 10-bit HDR toggle in Display.
- Industrial host-card redesign (left-aligned, bold, brand monogram tiles).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
force_extend_topology() was added before the resolve loop to de-clone a fresh IDD on
integrated-screen boxes (laptops), but its bare SDC_TOPOLOGY_EXTEND preset is
ACCESS_DENIED from the Session-0 service context on a HEADLESS box and broke the IDD
auto-activation there: resolve_gdi_name stayed None -> "not an active display path" ->
black screen. That regressed the headless/primary platform (live RTX box).
Revert to the proven e2c9bfd flow: resolve FIRST (Windows auto-activates the IDD as its
own extended path), and force-EXTEND only as the FALLBACK when resolve returns None (the
integrated-screen clone case, observed live to leave resolve None). The success path is
byte-identical to e2c9bfd (resolve -> set_active_mode -> isolate_displays_ccd).
Validated live: the headless RTX box streams again (probe: frames flow, driver attaches
to the ring, host/driver render LUIDs match).
Reviewed multi-agent + adversarial: no regression on the validated headless path or the
observed Optimus-laptop clone path (a cloned IddCx target resolves to None there, so the
is_none() fallback fires + de-clones). Known theoretical caveat, documented inline and
unobserved for IddCx but untested across GPU/driver/OS: a CCD clone that manifests as a
shared-source ACTIVE path would resolve to Some and bypass the is_none() gate. Follow-up:
widen the gate (a target_is_cloned helper) once an integrated-screen box is available to
validate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THIRD-PARTY-NOTICES.txt is ~885 KB / 16k lines; rendering it in a single
SwiftUI Text overshot the text-rendering height limit — it laid out for ages
and drew blank below the cutoff (only the small punktfunk licenses above it
showed). Split the notices into ~80 line-chunks (<=200 lines / <=18 KB each,
computed once as Licenses.thirdPartyNoticesChunks) and render them in a
top-level LazyVStack so only on-screen chunks lay out and no chunk is tall
enough to clip. Chunking is lossless — rejoining the chunks reproduces the
original byte-for-byte, so no notice text is dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The add-host content is a SwiftUI Form (backed by a scrollable list), so it
bounced/scrolled inside the fixed .height(320) detent even though the three
rows + action button fit exactly. Lock it with .scrollDisabled(true) on iOS
(covers iPadOS); macOS (fixed-size panel) and tvOS (custom rows, no Form) are
untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-app OSS license screens (7591425) added a `resources:` array to the
PunktfunkKit SwiftPM target, which makes SwiftPM emit a resource-bundle target
(PunktfunkKit_PunktfunkKit). A resource bundle is a product type that cannot
carry a provisioning profile, so the explicit PROVISIONING_PROFILE_SPECIFIER
each release.yml archive step set — global on macOS, sdk-scoped on iOS/tvOS —
now lands on it and fails the archive ("does not support provisioning profiles")
on all three platforms. (Before that commit there was no resource bundle, so the
profile was harmless.)
Switch all three archive steps to CODE_SIGN_STYLE=Automatic (development):
Automatic signing assigns a profile only to the app target and leaves the
resource bundle (and the macOS-host SwiftPM macro plugins) alone, and bakes the
sandbox entitlements in. No -allowProvisioningUpdates, so it stays offline and
never cloud-signs (the App-Manager ASC key can't). DISTRIBUTION signing is
unchanged — still manual, in the -exportArchive step (which maps the profile to
io.unom.punktfunk only). Drops the now-unneeded manual signing xcconfigs.
Requires the runner to have a development provisioning profile for
io.unom.punktfunk on each platform (now installed for macOS/iOS/tvOS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OpenAPI 'info.version' tracks CARGO_PKG_VERSION; the 0.3.0 bump made the
checked-in spec stale (the openapi_document_is_complete_and_checked_in test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface THIRD-PARTY-NOTICES.txt in every GUI client (the desktop packages already
ship it as a file; this adds the on-glass screen):
- Linux: Preferences -> About -> Third-party licenses (adw::AboutDialog with the app
license + Legal sections; include_str! the root notices).
- Apple: macOS About tab / iOS+tvOS Acknowledgements link; notices bundled as
PunktfunkKit SPM resources, read via Bundle.module (the Xcode app links the SPM
product, so they ride along - no .pbxproj edit).
- Android: Settings -> About -> Open-source licenses (reads the bundled asset).
- (Windows landed earlier in d1d2ca2: Settings -> About -> Third-party licenses.)
gen-third-party-notices.sh now copies the generated file into the Apple Resources/
and Android assets/ trees so the in-tree copies never drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web-console "Approve" (delegated pairing, roadmap §8b-1) was unreachable: every
client routed a fresh pair=required host straight to the SPAKE2 PIN ceremony, so
no "knock" was ever recorded; and an unpaired connect was rejected+closed with no
way to resume after approval. The backend + console were complete but had no
client-side trigger and no post-approval admit path.
Host (native_pairing.rs, punktfunk1.rs): an unpaired identified knock is now
PARKED instead of rejected — it releases its NVENC session permit, awaits an
operator decision (NativePairing::wait_for_decision, woken by a Notify on
approve/deny), and on approval re-acquires a slot and admits the SAME connection
with no reconnect. QUIC keep-alive (4s/8s) holds the parked connection warm. The
pairing gate moves out of the HANDSHAKE_TIMEOUT-bounded handshake future;
approve_pending is reordered read-then-add and wait_for_decision double-checks
is_paired to close a "neither pending nor paired" race. New PENDING_APPROVAL_WAIT
(180s). Tests: delegated_approval_admits_after_knock now approves mid-park (no
reconnect) + new wait_for_decision_approve_deny_timeout unit test (108 host tests
green).
Clients (Linux/Apple/Windows/Android): a fresh pair=required host now offers
"Request access" alongside the PIN ceremony — a plain identified connect with a
~185s handshake budget and a cancelable "waiting for approval" UI; on success the
host is saved as paired, and cancel returns the UI immediately while a late-
resolving connect is torn down silently via a per-attempt flag. Apple reuses the
existing C-ABI timeout_ms (no ABI change); Windows adds SessionParams.connect_timeout
+ a RequestAccess screen; Android adds a timeoutMs arg to the nativeConnect JNI
seam (both sides + both callers). Linux built + clippy + fmt clean; Apple/Windows/
Android pending their CI/on-device compiles.
SPAKE2 ceremony reviewed end-to-end against the spake2 0.4 contract — correct, no
changes needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
axum-server was used only for the plain-HTTP nvhttp listener, but we enabled
its tls-rustls feature (HTTPS is hand-rolled over tokio-rustls) — and that
feature was what pulled the unmaintained rustls-pemfile (RUSTSEC-2025-0134).
Drop the feature, bump axum-server to 0.8 (0.8 also no longer pulls it), and
move our own PEM parsing in gamestream/tls.rs to rustls-pki-types' PemObject
(the same path punktfunk-core/quic.rs already uses), removing our direct
rustls-pemfile dep too.
Net: rustls-pemfile fully gone; dependency graph trimmed 547 -> 529 crates
(the tls-rustls feature also dragged in prettyplease + a wasm-tooling chain).
cargo audit now reports only audiopus_sys + paste (transitive, latest, no
successor). 108 host tests + clippy + fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
memmap2 0.9.10 has an unchecked-pointer-offset unsoundness; 0.9.11 is the
patched release (pulled transitively via xkbcommon in the host). cargo audit
now reports only the 3 deliberately-visible `unmaintained` warnings
(audiopus_sys / paste / rustls-pemfile — all latest, transitive, warn-only,
do not fail CI per .cargo/audit.toml).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MIT OR Apache-2.0 SOURCE license is clean (audit found no copied copyleft); the
gaps were all binary-distribution (Layer-2). This makes the shipped artifacts honest:
- Windows host + client: bundled FFmpeg BtbN gpl-shared -> lgpl-shared (AMF/QSV/decode
unaffected; the GPL-only x264/x265 were never used), and ship the FFmpeg LGPL notice
+ license text in the installer + MSIX (licenses/).
- THIRD-PARTY-NOTICES.txt generated + bundled into installer/MSIX/deb/rpm. Offline
generator (scripts/gen-third-party-notices.{py,sh}) + cargo-about config (about.toml/
.hbs) with a permissive-only accepted-license allow-list as a copyleft regression gate.
- Reword the win32u GPU-preference hook comments to reflect independent reimplementation
(no Apollo/Sunshine GPL-3.0 source copied).
- README dual-license + inbound=outbound contributor clause + non-affiliation trademark
disclaimer; new CONTRIBUTING.md.
- LICENSE files into the standalone driver + vk-layer workspaces; deb copyright holder
aligned to "unom and the punktfunk contributors".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0.11.15 bump for S1 (pre-auth out-of-order STREAM reassembly memory
exhaustion on the default QUIC listener) was reverted before the original
fix commit, so Cargo.lock on main still pinned the vulnerable 0.11.14 and
the new cargo-audit CI gate failed. Re-apply and lock it in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows local-privilege findings from design/security-review-2026-06-28.md.
These are #[cfg(windows)] paths (verify in CI / on the box; this Linux dev
VM can't compile MSVC). They follow the existing write_secret_file/icacls
patterns; the cross-platform parts are cargo check/clippy/test green.
- #2 [HIGH]: route the mgmt bearer token write through the shared
write_secret_file so it gets the SAME Windows DACL (SYSTEM/Administrators)
as the host key — it was cfg(unix)-only and left Users-readable, leaking
full mgmt admin authority to any local user.
- #3 [HIGH]: create_private_dir now applies a restrictive DACL to the
%ProgramData%\punktfunk config directory (re-owns to Administrators to
defeat a pre-creation, strips inheritance, SYSTEM/Admins/OWNER full +
Users read-only) so a local user can't plant host.env/apps.json that the
SYSTEM service trusts (env/arg-injection LPE). host.env is now written
DACL-locked via write_secret_file; the config + logs dirs go through
create_private_dir.
- #8 [LOW]: write the web-console password file empty, icacls-lock it, THEN
write the secret — closes the brief write-then-icacls TOCTOU window.
- #11 [LOW]: the SYSTEM logs dir is DACL-locked (Users read-only, no
create), so a local user can't pre-plant host.log as a reparse/hardlink to
redirect SYSTEM's writes (subsumed by the #3 dir lockdown).
Deferred: #5 (host<->UMDF gamepad/IDD shared-section Everyone:GENERIC_ALL).
The section SDDL is intentionally permissive because the UMDF driver opens
it under a restricted token of unknown SID/integrity; scoping it blind would
likely break the live-validated gamepad/IDD pipeline, so it needs on-box
validation first. Tracked in the report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):
- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
pre-auth out-of-order STREAM reassembly memory exhaustion on the
always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
`/launch` and bind it to the launching client's source IP (threaded
through the HTTPS handler), so an unpaired peer can neither start capture
on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
(exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
apply_input_env / the launch-cmd set_var / the gamescope env read), so
concurrent native sessions can't race set_var/getenv (data-race UB ->
host-wide DoS). Full per-session SessionContext threading remains a
follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
$XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
can't OOM the host during library enumeration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-agent follow-up audit of the privileged streaming host: 18 attack
surfaces, every finding adversarially double-verified, plus a coverage
critic. Records 15 confirmed + 9 partial findings and a prior-fix
re-verification of the 2026-06-21 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With cmake now found, Homebrew's CMake 4 refuses the vendored libopus's
`cmake_minimum_required(VERSION <3.5)` ("Compatibility with CMake < 3.5 has been
removed"). Export CMAKE_POLICY_VERSION_MINIMUM=3.5 (the same knob the Windows
build uses) so the cmake crate's child cmake configures the audiopus_sys libopus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The self-hosted macOS runner runs steps with `bash --noprofile --norc`, so
Homebrew's bin dir is not on PATH — the previous `brew install cmake` died with
`brew: command not found` (exit 127). Find brew at its known prefix, install cmake
only if missing, and export the brew bin dir to $GITHUB_PATH so the subsequent
xcframework build (audiopus_sys → vendored libopus) actually finds `cmake`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 5.1/7.1 surround commit (75627c8) added in-core Opus, which broke two CI jobs
that the merge didn't touch:
* Windows MSIX client: clients/windows/src/main.rs's headless `SessionParams`
initializer was missing the new `audio_channels` field (the GUI path sets it
from settings). Default the CLI/test path to stereo (2), matching trust.rs.
* Apple xcframework (apple.yml + release.yml): in-core Opus decode pulls
`audiopus_sys`, which builds a vendored *static* libopus via CMake when
pkg-config finds no system Opus — keeping the xcframework self-contained (no
runtime libopus.dylib on end-user Macs/devices). The self-hosted macOS runner
lacked `cmake`; install it self-healing before every xcframework build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A line-wrap put `+`/`*`-style markers at the start of two doc lines, which
clippy (Windows host job, rust 1.96) reads as markdown list items whose
unindented follow-on lines trip `doc_lazy_continuation` under `-D warnings`:
- encode/windows/nvenc.rs `chroma_444` field doc (the failing Windows-host
clippy job): "+ chromaFormatIDC = 3" → "and chromaFormatIDC = 3".
- encode/linux/vaapi.rs `probe_can_encode_444` doc: "+ validate" → "and
validate" (last line, didn't fire yet, but fragile — fixed pre-emptively).
Pure doc rewording, no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds negotiated 5.1/7.1 surround to the punktfunk/1 protocol and every client
(previously stereo-only):
- core: new shared `audio` layout table (LAYOUT_51/71 + identity multistream
mapping, canonical wire order FL FR FC LFE RL RR SL SR); Hello/Welcome
`audio_channels` negotiation via the trailing-byte back-compat pattern (old
peers fall back to stereo); C-ABI `punktfunk_connect_ex6`,
`punktfunk_connection_audio_channels`, and in-core multistream decode
`punktfunk_connection_next_audio_pcm` for embedders without a multistream
Opus decoder. Real-libopus channel-identity round-trip test.
- host: native audio thread captures + Opus-(multi)stream-encodes at the
negotiated count (with a cross-session cached-capturer channel-mismatch fix);
GameStream surround unified onto the safe `opus::MSEncoder`, dropping
`audiopus_sys` (~4 unsafe blocks) and un-gating Windows GameStream surround;
WASAPI loopback capture relaxed to 2/6/8 with the correct dwChannelMask.
- clients: Linux (PipeWire), Windows (WASAPI), Android (AAudio) decode via
`opus::MSDecoder` + render multichannel; Apple decodes in-core to PCM →
AVAudioEngine with an explicit wire-order channel layout; each gains a
Stereo/5.1/7.1 setting. `punktfunk-probe --audio-channels N` is the headless
validator.
Verified on Linux: core/host/linux/probe test suites + the Android Rust
(cargo-ndk) build, clippy -D warnings, and rustfmt all green. Windows/Apple
builds, all on-glass checks, and the live native loopback are pending (CI / a
free box).
Also lands the concurrent in-tree HEVC 4:4:4 host work (PUNKTFUNK_444): it
shares the same touched files (quic.rs, punktfunk1.rs, encode/*, ...) and so
cannot be committed separately from the surround changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Play-listing/marketing screenshots of the Compose client rendered on the host JVM
by Roborazzi (Robolectric Native Graphics) — no emulator, GPU, KVM, host, or JNI
core. Five scenes render the REAL composables with embedded mock state under a
forced brand palette (Material You has no wallpaper to seed from on the JVM):
hosts grid, settings, TOFU + PIN dialogs, and the live stats HUD. Validated 5/5
locally.
- New JVM unit-test source set (app/src/test) + Roborazzi/Robolectric test deps;
@Config(sdk=36) is mandatory (no android-all jar for compileSdk 37) and the
animation clock is paused so a text-bearing scene reaches idle.
- kit: `-PskipRustBuild` skips the cargo-ndk native build so the JVM-only test job
needs no Rust/NDK; normal APK/AAR builds are unchanged.
- Widen BrandDark / StatsOverlay to internal so the tests can use them.
- Standalone best-effort tag-gated workflow; PNGs upload as a 30-day artifact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host-free UI screenshots of the GTK4/libadwaita client under a virtual X display
(clients/linux/tools/screenshots.sh) — Xvfb + software GL (llvmpipe) + a root-window
grab, one app launch per scene. PUNKTFUNK_SHOT_SCENE routes build_ui to render one
mock-populated REAL view (hosts grid / settings dialog / TOFU + PIN dialogs) and
print PF_SHOT_READY once it has settled; the saved-hosts grid is driven by a seeded
client-known-hosts.json. NON_UNIQUE in shot mode so back-to-back launches don't
collide. The stream scene is deferred — its page needs a live NativeClient.
Gated to stable release tags in a standalone best-effort workflow that builds the
client in the rust-ci image and captures under Xvfb; PNGs upload as a 30-day
artifact, not committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marketing/store screenshots of the console, captured from the built Storybook
with headless Chromium (web/tools/screenshots.mjs) — every Pages/* + Shell/*
story rendered at 1440x900@2x. The page stories render from fixtures, so no live
mgmt API, login, or GPU is needed (the web analogue of apple.yml's screenshots
job). Gated to stable release tags in a standalone best-effort workflow; PNGs
upload as a 30-day artifact, not committed.
- Add Stats + Pairing stories (the two pages that lacked them) with stats/pairing
fixtures typed against the generated models.
- Extract a pure PairingView (index.tsx -> view.tsx), matching the
Dashboard/Clients/Stats split, so the page renders host-free from mock state
instead of racing its polling queries. Container wiring is behaviour-identical.
- Playwright driver + a chromium-capable tag-gated job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The docs footer was a hand-maintained mirror of the marketing site's. Both now
render the same @unom/app-ui/footer component, so they stay in sync. The shared
view themes itself through @unom/style tokens (which the docs already map onto
their Fumadocs surfaces), and a resolveHref hook rebases root-relative links
onto the marketing-site origin. Footer types now come from the library too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plugin self-update (no Decky store): CI publishes a per-channel manifest.json
({version, immutable per-version artifact, sha256}) beside the zip and bakes
update.json {channel, manifest} into the plugin. main.py `check_update` reads the
installed version from package.json (the value Decky reports — not plugin.json),
fetches the channel manifest, and the frontend shows an "Update to vX" button that
drives Decky Loader's own install RPC (root downloads + SHA-256-verifies + hot-reloads).
CI now stamps a plain-numeric semver (0.3.<run> canary / X.Y.Z stable) into
package.json — a -ciN suffix would mis-order under compare-versions.
Linux client: `--fullscreen` (plus SteamDeck/gamescope env fallback) enters GTK
fullscreen on stream start so Gaming-Mode chrome is hidden; native-mode resolution
falls back to the display's first monitor when the window isn't mapped yet (was
dropping to the 1080p floor — wrong on the Deck's 1280×800); add a confirmed
"Remove saved host" action (KnownHosts::remove_by_fp).
Docs: new docs/steam-deck.md (Decky install/pair/stream/self-update/troubleshooting),
wired into meta.json nav, and cross-linked from clients/install-client/channels. This
is the page docs.punktfunk.unom.io/docs/steam-deck — the website's download link
pointed at it before it existed; committing it makes that link resolve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The punktfunk-mic PipeWire source connected without RT_PROCESS, so it ran as an
async/main-loop node. In the host's busy multi-stream graph (desktop audio + video
capture + the session) it never acquired a driver, stayed suspended, and its
process() callback never fired — every recorder reading the remote mic heard pure
silence (the long-standing "Linux host mic broken"). Connect the mic stream with
RT_PROCESS so it is a synchronous node that joins its consumer's driver group and
is actually driven.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GameStream/Moonlight video plane is a separate encode loop that lacked the
session-following the native punktfunk/1 plane has, so a mid-stream Desktop<->Game
switch killed the stream ("video stream failed") instead of following it.
* Normalize the session env like the native plane: extract open_gs_virtual_source,
which detects the LIVE compositor + apply_session_env/apply_input_env (gamescope
ATTACH default -> resize-on-attach to the box's own game-mode session at the
client mode; KWin/Mutter retargeting). GameStream previously ran a bare detect()
against raw process env, so in game mode it bare-spawned a COMPETING gamescope
instead of attaching to the box's session.
* In-place capture-loss rebuild: replace the `?` that ended the stream with a
bounded rebuild (re-detect the live compositor via the same factory, build the
new source BEFORE dropping the old, reopen the encoder, force an IDR) — keeping
the send thread + packetizer + socket + RTP clock. A same-resolution
Desktop<->Game toggle is now FOLLOWED with no Moonlight reconnect.
Protocol limit (unchanged): a mid-stream RESOLUTION change is impossible on
GameStream (WxH locked at ANNOUNCE; no Reconfigure) — a session toggle keeps the
negotiated mode, so this isn't hit. The portal/synthetic source passes no rebuild
closure (propagates as before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the Windows host docs page for first-time setup, on par with the
other host guides: remove the standout "Status:" banner, restructure into
Requirements / Install (web console + pairing + configure) / How it works /
Notes & limits.
Bring the content up to date with the shipping host:
- encode is all-vendor (NVENC/AMF/QSV + software fallback), not NVIDIA-only
- virtual display is punktfunk's own pf-vdisplay IDD (SudoVDA removed)
- gamepads need no prerequisite — UMDF drivers bundled; ViGEmBus is gone
- add HDR10 + Vulkan-game HDR layer coverage
Fix the same stale claims where other pages cross-reference the Windows host
(requirements, running-as-a-service, install, roadmap, status).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Steam game mode work on a display-less streaming host and stream it at the
client's resolution:
* Ship /etc/gamescope-session-plus/sessions.d/steam (packaging/bazzite/
gamescope-headless-session, installed by the RPM + Arch PKGBUILD): fall back to
gamescope's headless backend when no display is connected, so "Switch to Game
Mode" boots offscreen instead of crashing on the missing panel (and 5-striking
back to desktop). No-op on display-attached boxes; only sets unset values so
the host's per-client mode still wins.
* Default Bazzite/SteamOS to ATTACH (PUNKTFUNK_GAMESCOPE_ATTACH=1 in host.env):
the box owns its session (Desktop<->Game, persistent), the host follows +
captures it and never tears it down — so switching is rock-solid and a
disconnect leaves the box in its mode (reconnect returns there).
* Resize-on-attach (gamescope.rs): on connect, ensure the box's own game-mode
session runs at the CLIENT's resolution — reuse it when already matching (fast
path, no restart), else reconfigure + restart the box's own autologin
gamescope-session-plus@<client> at the client mode (cooperative: no competing
unit, so no autologin-respawn fight). Detect the live gamescope's -W/-H via
argv[0] in /proc (its /proc/<pid>/exe is unreadable for that process).
Validated live on a headless bazzite-deck-nvidia box: game mode boots headless +
stable (0 strikes); the host attaches + streams video/audio/EIS input; a
5120x1440 client reuses the matching session and streams at 5120x1440.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Bazzite/SteamOS Gaming↔Desktop switch tears the old compositor down and can
take 15s+ to bring the new one up — longer than the capture-loss rebuild's
~10s window, so the session failed mid-switch ("disconnect — session failed")
and forced the client to cold-reconnect. Retry the rebuild within a 40s budget
instead of giving up after one round, and re-detect the live compositor on
each attempt so the stream follows the box to whatever session comes up (a new
instance of the same compositor, or a different one — the kind-change case).
The QUIC keepalive runs on its own thread, so the client stays connected
(frozen on the last frame) and the stream resumes when the new output appears,
with no reconnect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the compositor is torn down mid-stream (a Gaming↔Desktop switch removes
the virtual output), its PipeWire stream leaves Streaming for Paused rather
than disconnecting. try_latest treated that as Ok(None) ("static desktop —
repeat the last frame"), so the stream froze on the last frame forever and
neither recovery path fired: the capture-loss rebuild keys on Err, and the
session watcher keys on a session-KIND change (a desktop→desktop new KWin
instance is the same kind).
Track the PipeWire stream state via state_changed (a `streaming` flag) and,
in try_latest, surface a sustained non-Streaming state (1.5s grace for a
transient renegotiation blip) as a capture-loss Err — which the encode loop
already handles by rebuilding the pipeline in place. A static desktop stays
Streaming, so no false trigger. Complements the now-default session watcher.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mid-stream session watcher (rebuild the backend in place when the box
flips Gaming↔Desktop) was opt-in via PUNKTFUNK_SESSION_WATCH, so it never
ran on a stock Bazzite/SteamOS box — switching modes froze the stream on the
now-dead compositor. Default it ON when os-release ID/ID_LIKE is
bazzite/steamos (the platforms that flip sessions); still off on plain
desktops. Also parse the env properly so PUNKTFUNK_SESSION_WATCH=0 actually
disables it (was: any value, including "0", enabled it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apply_input_env() hard-pinned PUNKTFUNK_INPUT_BACKEND=libei for KWin, and
default_backend() reads that env first — so the auto-detecting host (the
normal `serve` service) ignored the new KwinFakeInput backend and fell back
to the RemoteDesktop portal path that needs a user to approve. Route KWin to
"kwin" (org_kde_kwin_fake_input); GNOME/Mutter stay on libei (no fake_input
there).
Validated live on a Bazzite KDE box via the auto-detect path:
backend=KwinFakeInput, "KWin fake_input ready (no portal)", input events
forwarded with no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-finger touch was absolute "direct pointing" — the host cursor jumped to the
finger and was recomputed from each touch-start, so you couldn't precisely reach a
target. Now a relative trackpad: the cursor stays put on touch-down and moves by the
finger delta (host MouseMove via nativeSendPointerMove, already supported — no
protocol change), with mild pointer acceleration and sub-pixel remainder
accumulation so slow precise moves aren't lost to Int truncation. Swipe, lift, and
re-swipe to walk it across; tap = left-click at the cursor's current position.
Two-finger scroll / right-click, three-finger HUD toggle, and tap-then-hold-drag are
preserved unchanged; finger-id re-anchoring keeps multi-touch transitions jump-free.
Added Settings → Pointer → "Trackpad mode" (default on); turning it off restores the
old direct-pointing path verbatim.
:app:compileDebugKotlin green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The jitter ring was a port of the Linux client's, but Linux runs on PipeWire
(adaptive resampling masks host↔DAC drift + a shallow buffer); AAudio hands us a
raw realtime callback and we own the buffer, so the same code crackled only on
Android. Three converging causes, all fixed:
- Heap free on the realtime audio thread every quantum (Android's Scudo free() has
unbounded tail latency → XRun → click). Decoded buffers are now recycled back to
the producer via a free-list instead of freed on the audio thread; the ring is
pre-reserved so extend() never reallocates there.
- The ring collapsed to ~15 ms on the tiny LowLatency burst and re-primed (a fresh
silence) on every single empty callback. Now ~40 ms prime / ~150 ms hard cap,
decoupled from the burst size, with de-prime hysteresis (re-prime only after a
sustained drain).
- AAudio's anti-glitch knobs were unused: prime the HW buffer above its 2-burst
default and grow it on getXRunCount(). The post-open log now reports
perf/sharing/buffer so a fall to a resampled legacy path is visible.
Steady-state audio latency ~15 → ~40 ms (within lip-sync tolerance; matches the
Moonlight/Sunshine operating point). cargo-ndk build both ABIs + fmt + clippy green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desktop-mode (KWin) streaming had no input: the path was libei via the
RemoteDesktop portal, which (a) isn't reachable from the host service env
and (b) requires a human to approve "Allow remote control?" — a
non-starter on a headless box. KWin's own headless RDP server (krdpserver)
solves this with org_kde_kwin_fake_input, authorized by the exact same
.desktop X-KDE-Wayland-Interfaces grant we already ship
(org_kde_kwin_fake_input is listed alongside zkde_screencast_unstable_v1).
Add a fake_input injector: vendor the protocol XML, bind the global as an
ordinary Wayland client, authenticate (auto-accepted for an
interface-authorized client — no dialog), and translate pointer (rel/abs),
button, scroll, keyboard (raw evdev keycodes resolved by KWin's own keymap)
and touch. Select it for KWin (compositor=="kwin" or XDG_CURRENT_DESKTOP
KDE); GNOME stays on libei (it has neither fake_input nor the wlr
protocols). PUNKTFUNK_INPUT_BACKEND=kwin forces it.
cargo check + clippy + fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streaming the KDE *Desktop* (KWin) session failed on a real interactive
Plasma session with "KWin does not expose zkde_screencast_unstable_v1":
KWin treats the screencast/virtual-output and fake_input globals as
restricted and advertises them only to a client whose installed .desktop
lists them under X-KDE-Wayland-Interfaces (matched by /proc/<pid>/exe ->
Exec, and cached per-executable on first connect). The host shipped no
.desktop, so it was permanently denied; it only ever worked on the
headless dev box via KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.
Ship packaging/linux/io.unom.Punktfunk.Host.desktop (least-privilege:
only the host, only zkde_screencast_unstable_v1 + org_kde_kwin_fake_input)
and install it from the RPM/.deb/Arch host packaging so it is present
before the host first connects. Drop the blunt session-wide
NO_PERMISSION_CHECKS hack from kde-desktop-setup.sh (it now only seeds the
RemoteDesktop input grant) and fix the now-misleading kwin.rs docs/errors.
Validated live on a Bazzite Kinoite box (KWin 6.6.4): probe-compositor +
spike --source kwin-virtual succeed against a KWin running WITHOUT the
permission bypass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows virtual mic fakes a capture endpoint by writing the client's
uplinked PCM into a virtual device's *render* endpoint, while the
desktop-audio plane loopback-captures the *default render* endpoint — with
no mutual exclusion between the two. WASAPI loopback captures the mixed
output of an endpoint (everything any app renders to it, including our mic
writes), so when both resolve to the same device — VB-CABLE used for both,
or the auto-installed Steam Streaming Microphone being the default render on
a headless box — the injected mic is captured straight back into the
host->client audio stream: an infinite echo.
find_device() now resolves the loopback's endpoint id (default render) and
skips any candidate matching it, scanning on to the next non-loopback match,
so the mic can never land on the device the loopback reads. The auto-install
path now provisions the full Steam pair (Streaming Microphone + Streaming
Speakers) so a bare host gets two distinct devices instead of one shared
one. Errors distinguish "no device" from "only candidate is the loopback
device". Linux was already immune (its mic is a dedicated Audio/Source node,
structurally separate from the monitored sink).
Windows-only (#[cfg(windows)]); rustfmt-clean, compile-checked in
windows-host CI, needs on-glass validation on the RTX box. Does not force
the system default playback onto Steam Streaming Speakers (IPolicyConfig) —
not required to break the echo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pnputil /add-driver call in windows/install.rs was committed unwrapped;
`cargo fmt --all --check` (which checks cfg(windows) files too) flagged it and
failed the `rust` CI job at the Format step, skipping clippy/build/test. Apply
rustfmt — no behavior change. Clears the way to cut the v0.2.0 release from
green main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wordmark was light violet only — low-contrast on a light README
background. Swap to a single theme-adaptive SVG: an internal
`prefers-color-scheme` media query paints it deep violet (the brand-mark
palette) on light backgrounds and the original light violet on dark, so it
reads on both GitHub/Gitea themes with no markup change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Option A removed install-pf-vdisplay.ps1 / install-gamepad-drivers.ps1 / web-setup.ps1;
the installer now calls the exe subcommands. Drop the stale table rows + reword the
install-flow + 'thin installer' notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add the centered punktfunk wordmark banner at the top (assets/punktfunk-logo.svg,
the same logo + layout the marketing site's README uses).
- Refresh the now-stale Windows-host facts: all-vendor (NVENC + AMF/QSV), its own
all-Rust pf-vdisplay IddCx virtual display (was SudoVDA), bundled UMDF virtual-gamepad
drivers (ViGEmBus gone), HDR incl. Vulkan-game HDR; x64-only, no longer NVIDIA-only.
- Note punktfunk-host covers Linux + Windows; point design/ at its new README index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the three install-time PowerShell *files* (install-pf-vdisplay.ps1,
install-gamepad-drivers.ps1, web-setup.ps1) into punktfunk-host.exe subcommands:
`driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
[--password-file <f>]` (windows/install.rs).
Why: PowerShell 5.1 reads a BOM-less .ps1 FILE in the machine ANSI codepage, so a
stray non-ASCII byte mis-decodes and aborts on a non-English box - exactly how the
pf-vdisplay driver install silently failed. A compiled subcommand drives the same
external tools (certutil/pnputil/nefconc/schtasks/netsh/icacls) as fixed string
literals, with no file-codepage surface. (The .iss's INLINE -Command PowerShell is a
command-line string, not a file read, so it's unaffected and stays.)
- windows/install.rs: faithful port - cert trust, gated nefconc node create + pnputil
for pf-vdisplay; pnputil per-inf for gamepads; web-password ACL, the PunktfunkWeb task
(generated UTF-16 XML), firewall rule, start. Best-effort (a hiccup warns, never aborts).
- punktfunk-host.iss [Run]: call the exe instead of `powershell -File`; drop the
web-setup.ps1 staging + WebSetup define; WebSetupParams emits --app-dir/--password-file.
- pack-host-installer.ps1: stop copying the three install scripts into the stages.
- delete the three .ps1 files.
The `mod install;` + dispatch arms in main.rs landed in the preceding docs commit
(swept up by a concurrent commit); this commit adds the module + installer wiring.
CI-compile-validated via windows-host; the install path is on-glass-validated on the
next canary install (the test box is offline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Much of design/ described work that has since shipped. Trim each doc to
its durable rationale + still-open items (the code is the source of truth
for shipped detail; git history holds the full originals).
- Shipped plans -> status stubs: stats-capture, gamestream-host-plan,
apple-stage2-presenter, windows-service.
- Trimmed completed-out / open-kept: implementation-plan, hdr-pipeline,
host-latency, gpu-contention (fixed stale status table), game-library,
linux-setup (fixed m0->spike + stale zero-copy claim),
session-aware-host-followups, windows-client-bootstrap,
windows-dualsense-{scoping,game-detection}, windows-virtual-display,
security-review (per-finding status table; #12 still open),
apollo-comparison (shipped backlog collapsed to one-liners).
- Windows-host cluster consolidated: windows-host.md -> redirect into
windows-host-rewrite.md (whose stale scorecard is corrected -- goal1 is
merged, M4 done); windows-secure-desktop.md archived (now a fallback
behind IDD-push primary).
- Kept evergreen: ci.md, gamescope-multiuser.md, windows-build-and-packaging.md.
- New design/README.md: per-doc status table + consolidated open-items
roll-up so nothing is tracked in only one buried doc.
- Repoint 5 code comments to the archived secure-desktop doc path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single repo-internal source of truth for the Windows build/packaging: what ships, the
all-Rust driver workspace built FROM SOURCE in CI (+ the anti-stale rationale), the
toolchain (clang 22 + bindgen 0.72, no LLVM pin), the Inno installer, the web console
bundle, the CI workflows, signing, and the dev loop. (design/, not the docs-site.)
packaging/windows/README.md: drop the deleted vendored-driver dir + its "Vendored driver"
callout, add the build-* / install-gamepad / clear-force-integrity rows, point at the new
design doc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that the drivers build from source in CI, remove the dead checked-in binaries and
the toolchain cruft they left behind:
- Delete packaging/windows/{pf-vdisplay,gamepad-drivers}/ (the prebuilt .dll/.inf/.cat/.cer).
pack-host-installer.ps1 builds + signs all three drivers from the drivers/ workspace and
nothing reads the vendored dirs anymore; stage-pf-vdisplay.ps1's -VendorDir is now a
mandatory build-output path, not a vendored default.
- Drop the LLVM-21 pin. The vendored bindgen 0.71->0.72 bump (the shipping pack already
builds green on the runner-default clang 22) retired the bindgen-0.71 layout-test overflow
that needed LLVM 21.1.2, so windows-drivers.yml + provision-windows-wdk.ps1 no longer
install/point at C:\llvm-21 (~898 MB off a fresh provision) - both driver builds now use one
toolchain (clang 22 + bindgen 0.72).
- pack -SkipBuild on the gamepad build (build-pf-vdisplay.ps1 already builds the whole
workspace), build-web.ps1 reaps a stale node too, deploy-dev.ps1 nefconc path + comments.
- Reword the vendored-driver references (build scripts, .iss, READMEs, the vite web-bundle
comment) to the build-from-source reality.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Bug C [UninstallRun] one-liner had `ForEach-Object { Stop-Process ... }`; Inno
Setup parses `{...}` as a constant in [Run]/[UninstallRun] sections, so ISCC aborted
with "Unknown constant" and the windows-host pack failed at the ISCC step (the host
build, clippy, driver build + web smoke-boot all passed). Escape `{` as `{{`. The
same one-liner in the [Code] StopWebConsole proc is inside a Pascal string literal,
so its brace is literal and must NOT be escaped. Validated: ISCC now parses past
[UninstallRun] + [Code] (fails only later on the absent dummy payload).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold the pf-dualsense (DualSense / DualShock 4) and pf-xusb (Xbox 360 / XInput)
UMDF drivers into the in-tree drivers workspace (their source had stale
../../crates/wdk-* path-deps from before the wdk vendoring reorg and could no
longer build at all) and build them from source per release, exactly like
pf-vdisplay - same anti-stale reasoning. One `cargo build --release` now builds
all three drivers against the vendored wdk-sys (incl. the bindgen 0.72 pin), and
build-gamepad-drivers.ps1 signs pf_dualsense + pf_xusb (clear FORCE_INTEGRITY ->
sign dll -> stampinf -> Inf2Cat -> sign cat) with one shared cert + .cer,
matching the layout install-gamepad-drivers.ps1 expects. pack-host-installer.ps1
builds + stages them instead of the retired checked-in binaries.
Validated on the runner: the whole workspace (pf-vdisplay + pf-dualsense +
pf-xusb) builds with CARGO_TARGET_DIR=C:\t set, and build-gamepad-drivers.ps1
produces signed pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + the .cer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI driver build panicked in wdk-sys's build script - "a Cargo.lock file should
exist in the same directory as the top-level Cargo.toml". wdk-build's
find_top_level_cargo_manifest() walks UP from OUT_DIR for the first ancestor holding a
Cargo.lock and explicitly does NOT support non-default target dirs - but
build-pf-vdisplay.ps1 pointed CARGO_TARGET_DIR at an out-of-tree dir (to isolate from
CI's shared C:\t), so no ancestor of OUT_DIR had a Cargo.lock. Build into the driver
workspace's DEFAULT target dir instead (its ancestors include the driver Cargo.lock);
the driver's own [workspace] already isolates it and it has no CMake deps needing C:\t.
Also make the Test-FileCatalog coverage guard non-fatal (it can't open a catalog
signed by a not-yet-trusted cert). Validated on the runner with CARGO_TARGET_DIR=C:\t.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pf-vdisplay virtual-display driver shipped as a checked-in PREBUILT binary
that went stale - two field failures on a fresh install (live-repro'd on a
German-locale Dell laptop):
* Bug A (every box): a repo-wide rename edited the vendored pf_vdisplay.inf
but never re-signed pf_vdisplay.cat, so the catalog stopped covering the INF
-> `pnputil /add-driver` fails SPAPI_E_FILE_HASH_NOT_IN_CATALOG -> driver
never installs -> every session dies "pf-vdisplay driver interface not
found".
* the prebuilt binary also predated IOCTL_SET_RENDER_ADAPTER (added to the
driver source after the vendor freeze) that the host needs to pin the IDD
render GPU on hybrid/Optimus boxes.
Fix: build the driver FROM SOURCE every release (build-pf-vdisplay.ps1, wired
into pack-host-installer.ps1) so .dll/.inf/.cat are always in lockstep and
current driver features ship. The runner's clang 22 made the driver's pinned
bindgen 0.71 emit opaque structs (157 layout-assert errors), so bump the
vendored wdk-sys/wdk-build bindgen 0.71 -> 0.72 (+ lock). The build self-signs
the driver per build (installer trusts the bundled .cer); a stable
DRIVER_CERT_PFX_B64 secret can override.
* Bug B (non-English boxes): the installer runs install-pf-vdisplay.ps1 etc.
via powershell.exe (5.1), which reads a BOM-less script in the ANSI codepage
- an em-dash's trailing 0x94 byte becomes a curly quote on German
Windows-1252 and the script aborts "unterminated string", so the driver
never installed (the gamepad script survived only because it was already
ASCII). Scrub every installer-run .ps1/.cmd to ASCII + add a CI gate that
fails on any non-ASCII so it can't regress.
* Bug C (upgrades): nothing stopped the OLD web console before re-registering
its task, so a stale server kept :3000 (the new one restart-looped on
EADDRINUSE) and served a broken old bundle (500 on /login). Stop + reap it
(runtime-agnostic, by the :3000 listener owner) in web-setup.ps1 and in the
.iss before the file copy + on uninstall.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A freshly-added IddCx virtual display lands in CLONE/duplicate mode when a
physical display is already active (a laptop panel, an attached monitor): the
cloned output shares that display's source, so the OS never commits a distinct
path for it, never calls ASSIGN_SWAPCHAIN, and capture sees no frames - the
session fails "not an active display path / needs a WDDM GPU to activate" and
tears down with 0 frames (seen live on an Intel-iGPU + NVIDIA-Optimus laptop).
force_extend_topology() applies the EXTEND preset (the programmatic Win+P
"Extend") right after ADD so the IDD comes up as its own active path; the
existing resolve_gdi_name -> set_active_mode -> isolate_displays_ccd bring-up
then proceeds. Idempotent / no-op on a sole-display (headless single-GPU) box,
so it's safe on the path that already worked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.
- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
shared with the mgmt API + both streaming loops, mirroring NativePairing). The
hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
graphs while armed, recordings table (view / download-JSON / delete), and a detail
view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
+ health. Charts adapt to either path's stage set.
Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android app is in Google Play Internal Testing, so the public Play Store URL
doesn't resolve for non-testers. Lead the Android install instructions with a
"request a tester invite on Discord" CTA (the Play listing unlocks once a Google
account is added to the test track), and surface the Discord + r/Punktfunk
community links in the README, the docs intro, and the docs-site nav.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the "Linux-first" framing across the README and docs site in favor of
first-class Linux AND Windows hosts, and surface the Windows IDD-push
virtual-display path as a distinct differentiator (punktfunk's own indirect
display driver the host pushes frames into — a real virtual display, no physical
monitor or dummy plug, even on the secure desktop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The file moves (docs/ → design/, docs/api/openapi.json → api/openapi.json) landed
in d01a8fd, but the matching reference updates did not — so mgmt.rs's drift-test
`include_str!("../../../docs/api/openapi.json")` pointed at a path that no longer
exists and the host failed to build. This restores it and updates every reference:
- mgmt.rs include_str! → ../../../api/openapi.json (fixes the build)
- web/orval.config.ts codegen target, web/Dockerfile, .dockerignore
- deb/rpm/Arch packaging install paths
- CLAUDE.md, the .gitea CI workflows, code doc-comments, design-doc cross-links
docs-site route URLs (/docs/...) untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.
Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.
The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).
Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The one genuine soundness defect the unsafe-proof program surfaced (flagged
SUSPECT in program 3/N). `HelperRelay` holds an `rx: Receiver<RelayAu>`, which is
`!Sync` (std mpsc is single-consumer), so asserting `Sync` claimed more than the
fields support — an `Arc<HelperRelay>` recv'd from two threads would compile and
be UB.
It was never live-exploited, and it turns out `Sync` is also unnecessary: the
relay is a single-owner `mut relay` local in the punktfunk1 two-process mux loop
(recv_timeout/try_recv/request_keyframe all called on the owning thread; no `Arc`,
no `thread::spawn` capturing it). So the fix is simply to delete the impl — the
struct keeps its sound `unsafe impl Send` (needed for the raw `HANDLE` fields),
which is all the code uses.
Box-verified: cargo clippy -p punktfunk-host --features nvenc --target
x86_64-pc-windows-msvc -- -D warnings stays green without the Sync impl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the unsafe-proof program now that the parallel WIP has landed:
- idd_push.rs (25 sites), nvenc.rs (7), punktfunk1.rs (21): a SAFETY proof on
every unsafe block — D3D11/DXGI COM (same-device textures, immediate-context
single-thread, keyed-mutex-held convert), the NVENC SDK table (versioned POD,
register/map/lock-bitstream pairing), cross-process shm reads (atomic
magic/generation handshake), and the C-ABI harness (each call cross-checked
against its abi.rs `# Safety` doc). No SUSPECT (UB) blocks.
- capture.rs / encode.rs: the parent-module deny is restored (their WIP children
are now proven), and main.rs gains a crate-root
#![deny(clippy::undocumented_unsafe_blocks)] — the permanent catch-all gate so
no future unsafe block anywhere in the crate can land without a proof.
- Fixed 4 blocks the agents missed: unsafe blocks nested inside `assert_eq!(...)`
macro args (the comment-above-statement didn't associate) — hoisted to a `let`.
- rustfmt-canonicalized the Windows files (the agents' SAFETY comments + some
pre-existing 1.9.0 drift) so `cargo fmt --all --check` is clean.
Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings AND
cargo fmt -p punktfunk-host --check both green with the crate-root deny active.
Windows cfg(windows) re-verified on the box next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GPU-contention work (host-latency plan §5.A): the IDD-push output ring now hands
NVENC native YUV instead of RGB, so NVENC skips its internal RGB→YUV colour
conversion on the SM/3D engine the running game saturates.
- idd_push.rs: out_ring is now NV12 (SDR, BT.709 limited) via a D3D11 VIDEO-engine
BGRA→NV12 VideoConverter (keeps the CSC off the contended 3D/compute engine), or
P010 (HDR, BT.2020 PQ limited) via the FP16→P010 shader (NVIDIA's VideoProcessor
can't do RGB→P010). The ring drops its per-slot RTV (textures only), matching the
WGC YUV ring; converters rebuild on a size/HDR flip.
- nvenc.rs: NV12 input forces bit_depth=8 so an HDR→SDR toggle (or a 10-bit-
negotiated client on an SDR display) re-inits the session at the matching depth —
NV12 can't feed a 10-bit session (register_resource rejects it).
- punktfunk1.rs: per-stage latency instrumentation under PUNKTFUNK_PERF
(cap=try_latest, submit=encode_picture, wait=lock_bitstream µs p50/p99/max) to
pinpoint where capture→encoded latency goes under GPU saturation.
Continues the unsafe-proof program across the Windows/cross-platform host files
(~75 blocks, 21 files), each with a SAFETY proof of the real invariant and a
per-file #![deny(clippy::undocumented_unsafe_blocks)] gate:
capture/windows: dxgi.rs, wgc_relay.rs, wgc.rs, desktop_watch.rs, composed_flip.rs
(windows-rs COM: interface validity, same-D3D11-device textures,
immediate-context single-thread, borrowed args outlive the call)
windows: service.rs (SCM/token/CreateProcessAsUserW/event handles — OwnedHandle
liveness, no double-close/signal race), win_display, wgc_helper, interactive
vdisplay/windows: manager.rs, pf_vdisplay.rs (SwDeviceCreate/IddCx/ioctl handle
liveness via the OnceLock VDM singleton + OwnedHandle)
encode/windows: ffmpeg_win.rs (full AVBufferRef refcount audit — balanced, NO leaks,
unlike the vaapi sibling), sw.rs
cross-platform: gamestream/audio.rs (libopus), gamestream/stream.rs (sendmmsg),
inject/windows/sendinput.rs, audio/windows/wasapi_mic.rs,
session_tuning.rs, vdisplay.rs
Two findings (handled separately):
- wgc_relay.rs `unsafe impl Sync for HelperRelay` is UNSOUND (its mpsc Receiver is
!Sync) though not live-exploited — marked SUSPECT inline; fix pending box check
(it touches the in-flight punktfunk1.rs).
- capture.rs / encode.rs (PARENT modules of the WIP idd_push.rs / nvenc.rs) do NOT
get the file deny yet — it would propagate the lint into the undocumented WIP
children. The deny lands there once those are documented (after the WIP commits).
Linux-visible parts verified green (cargo clippy -p punktfunk-host --all-targets
-- -D warnings). The cfg(windows) deny gates are box-verified next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaced while writing the unsafe-soundness proofs (2/N): both are refcount
leaks (sound — never dangling/double-free — so the SAFETY proofs held, but real
bugs on the persistent punktfunk1-host listener that opens a fresh encoder per
session).
1. Per-session leak: `par->hw_frames_ctx = av_buffer_ref(drm_frames)` created a
second owned ref. `av_buffersrc_parameters_set` takes its OWN ref of
`par->hw_frames_ctx`, and `av_free(par)` frees only the struct, not the ref —
so the extra ref leaked every session, pinning the DRM frames ctx + device.
Fix: assign `drm_frames` borrowed (the standard ffmpeg pattern); our single
owned ref lives in DmabufInner and is unref'd in Drop.
2. Error-path leak: the final `open_vaapi_encoder(...)?` returned without the
unref ladder every other error path runs, leaking graph/drm_frames/
vaapi_device/drm_device on encoder-open failure. Fix: match + clean up before
returning (nv12_ctx is borrowed from the sink → freed by graph teardown).
cargo clippy -p punktfunk-host --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:
zerocopy/cuda.rs (26) leaked process-lifetime libcuda fn-ptr table; opaque
CUcontext never dereferenced; free-exactly-once via the
Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
zerocopy/egl.rs (18) eglGetProcAddress'd procs with the GL context current;
EGLImage liveness; the two-call modifier-query bounds
zerocopy/vulkan.rs (4) copy-bounds arithmetic (src_size>=span); Send = thread
confinement to the punktfunk-pipewire thread
dmabuf_fence.rs (4) poll/ioctl/close fd liveness + ownership
capture/linux/mod.rs (16) spa_data repr(transparent) cast; null-checked spa
derefs; single-loop-thread buffer ownership until requeue
inject/linux/gamepad.rs (10) uinput ioctl request-number ↔ struct-size match
(static-asserted); InputEventRaw no-padding for the byte cast
encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9) ffmpeg object ownership/
free ladders; VAAPI/DRM graph; Send = single-thread transfer
inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)
No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.
Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Start of the structural unsafe-proof program (per the "every unsafe needs a
documented proof of soundness" goal): each `unsafe` block gets an accurate
`// SAFETY:` proof of WHY it is sound, and the file gains
`#![deny(clippy::undocumented_unsafe_blocks)]` so the proof requirement is
permanently enforced (a future undocumented unsafe in this file fails CI).
drm_sync.rs (10 blocks: libc open/ioctl/clock_gettime/close + 3 in tests): each
proof states the real invariant — fd liveness/ownership, the ioctl request number
encoding the matching struct size, the `&mut req` being a live correctly-sized
`#[repr(C)]` struct, and (for the timeline ioctls) the `handles`/`points` arrays
outliving the synchronous call with `count_handles` matching their length.
The gate grows file-by-file (CI stays green; undone files don't carry the lint
yet); it promotes to a crate-root deny once every file is done. ~122 Linux blocks
+ the Windows files remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GameStream's apps.json `cmd` is delivered via set_launch_command, which ONLY the Linux
gamescope backend nests. On Windows (no gamescope) and Linux kwin/mutter/wlroots (which
stream the existing desktop) the command was silently dropped. Now, after capture is live,
stream.rs spawns it via library::launch_gamestream_command for those backends — Windows:
into the interactive USER session (spawn_in_active_session, since the host is SYSTEM);
Linux: a plain `sh -c` spawn into the host's own graphical session so the app lands on the
streamed (primary) output. Linux gamescope keeps nesting via set_launch_command and is
skipped here to avoid a double launch. The command is operator-typed apps.json (trusted),
never client-set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A disk-backed art cache (library-art-cache.json in the canonical host config dir) is the
source of truth read by all_games(), so the library list + launch-resolve never block on
the network. A host-lifetime background warmer (start_art_warmer, started in serve())
fetches uncached art OFF the hot path: GOG via the public no-auth api.gog.com product API,
Xbox via the unofficial no-auth displaycatalog (keyed by StoreId). Both best-effort
(protocol-relative URLs normalized to https; results cached even when empty so they aren't
re-fetched). The GOG + Xbox providers now read cached_art() (title-only until warmed).
Cross-platform (ureq blocking HTTP — no tokio on this path) so the fetch/parse code is
compiled + checked everywhere; a host whose stores all self-provide art (Steam CDN /
Heroic CDN / Lutris data: URLs) does no fetching. Dep: ureq (webpki roots, no system certs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
XboxProvider scans each fixed drive's <drive>:\XboxGames for GDK games (presence of
Content\MicrosoftGame.config marks a game vs. an ordinary UWP app), parsing title /
Identity name / Executable Id / StoreId via roxmltree. The PackageFamilyName is READ
from the AppRepository\Packages\<PackageFullName> dir name (reduced to Name_Hash) —
never computed from the publisher. Launch via the AUMID (shell:AppsFolder\<PFN>!<AppId>)
through explorer in the interactive user session (UWP activation needs the user token,
which spawn_in_active_session already provides). Cover art (displaycatalog) is deferred
→ title-only. Known v1 gaps: custom .GamingRoot install folders + non-GDK pure-UWP Store
games (under the ACL-locked WindowsApps) aren't enumerated.
New windows_launch_for `aumid` arm; XboxProvider wired into all_games() under cfg(windows).
Dep: roxmltree (Windows). Windows unit tests cover MicrosoftGame.config parsing (incl. the
ms-resource title fallback), the PackageFullName→PFN reduction, and the aumid launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EpicProvider reads the launcher's local .item manifests under %ProgramData% (no auth,
launcher need not run) with Playnite's exclusion filter (skip UE_* components +
non-launchable addons + dead install dirs); cover art from the base64 catcache.bin
(public Epic CDN, best-effort). Launch via the com.epicgames.launcher:// URI opened
through explorer.exe — the namespace:catalogItemId:appName triple, with a bare-appName
fallback so a launch is never dropped.
GogProvider enumerates HKLM\SOFTWARE\WOW6432Node\GOG.com\Games (winreg) + each
goggame-<id>.info primary FileTask into a direct-exe spawn (no Galaxy, dodges its
cold-start/anti-cheat). GOG cover art (public api.gog.com) is deferred — it needs an
HTTP fetch + cache off the hot all_games() path — so GOG is title-only for now.
windows_launch_for gains epic/gog arms; both providers wired into all_games() under
cfg(windows). Deps: base64 moved to the cross-platform table (Epic catcache decode +
Lutris art encode both need it); winreg added on the Windows target. Windows unit tests
cover the Epic exclusion filter + URI builder and the GOG spawn + play-task parsing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The service.rs STOP/SESSION events are now OnceLock<OwnedHandle> (61c02e6) — the
last host-side raw-handle smuggle retired. Runtime-validated on the RTX box: swap
in, sc start -> RUNNING, sc stop -> clean STOPPED in ~1s, original restored. D2
(OwnedHandle/RAII rollout) is complete; only the deferred host P0 lints remain in
Goal 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The service's STOP/SESSION manual-reset events were smuggled across the C SCM
control-handler boundary as raw `isize` in `AtomicIsize` statics (the handler is a
capture-free `'static` closure, so it can't hold a non-`Send` `HANDLE` — it has to
reach the events through statics), reconstructed via `load_event`, and explicitly
`CloseHandle`d at `run_service` end.
Replace the raw-`isize` statics with `OnceLock<OwnedHandle>`:
- `run_service` creates each event, wraps it in an `OwnedHandle`, derives a borrowed
`HANDLE` for `supervise` (unchanged signature), and `set`s the OnceLock (once per
process) — all BEFORE the handler is registered, so the handler always sees `Some`.
- The handler reads `event_handle(&STOP_EVENT)` (a borrow) and `SetEvent`s it, with a
defensive `None` guard (matches the old `SetEvent(HANDLE(0))` no-op if it ever fired
pre-init).
- The events are owned by the OnceLocks for the process lifetime (the service process
exits right after `run_service` returns, so the OS reaps them at exit). Dropping the
explicit `CloseHandle` also removes the latent close-then-signal window the old
statics had (the raw isize lingered after the close).
Deletes the `AtomicIsize`/`Ordering` import + `load_event` + the raw-isize smuggle —
the last host-side raw-handle reduction. Behaviour-preserving (same events, same
signal/wait/reset, same once-per-process init order). Linux check + fmt clean; the
file is #[cfg(windows)] → to be box-validated (compile + a service stop/restart).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GameCard badge hard-coded steam-vs-custom, so any non-Steam non-custom store
rendered with the "Steam" label. Add storeLabel(store): steam/custom keep their
localized strings, every other store is shown as a capitalized proper noun — so the
new Lutris/Heroic providers (and future ones) surface correctly with no per-store
translation. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LutrisProvider reads the local pga.db (rusqlite, read-only/immutable so a running
Lutris can't block us) → installed games, launch via `lutris lutris:rungameid/<id>`,
cover art from Lutris's on-disk cache inlined as data: URLs (no public CDN keyed by a
stable id, unlike Steam/Heroic). HeroicProvider parses Heroic's store_cache JSON —
legendary/gog/nile = Epic+GOG+Amazon in one provider — installed-only with an
install-dir existence cross-check (works around Heroic's gog is_installed bug #2691),
free public CDN cover art, launch via `heroic --no-gui heroic://launch?...` (the
single-instance-Electron gamescope-escape caveat is documented; needs live confirm).
New command_for arms (lutris_id digits-guard, heroic runner+appName-guard) + both
providers wired into all_games(); everything Linux-gated (the launchers are
Linux-only), so the Windows/macOS host build is unaffected. Deps rusqlite (bundled
SQLite, no system dep) + base64 added to the Linux target only. Unit tests with
sqlite/json fixtures (installed-only filtering, CDN-art mapping, launch guards); live
`library` enumeration returns [] gracefully on a box without the launchers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goal 3: the IDD-push hot-loop KeyedMutexGuard (6585643) landed, and the whole
session's Windows + driver work is now ON-GLASS BUILD-VALIDATED on the RTX box —
host clippy -D warnings clean + driver build clean (the gate that surfaced + got
11 lints fixed in bd05bc8). Only the deferred host P0 lints + the deliberately-
left service.rs SCM-handler event smuggling remain, plus an optional latency A/B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Built the host crate (`cargo clippy --features nvenc -D warnings`) and the driver
workspace (`cargo build`) on the RTX box — the project's intended Windows gate,
which `cargo check` (what the goal1/§2.5 work used) never runs. It surfaced lint
issues accumulated across the goal1 / §2.5 / this-session Windows work:
- 9× redundant `as *mut c_void` after `.as_raw_handle()` (already `*mut c_void`):
idd_push.rs (3, this session), service.rs (3, this session), manager.rs (3,
pre-existing §2.5 — my OwnedHandle work copied the idiom). Removed the casts +
the now-unused `use std::ffi::c_void` in idd_push.rs / manager.rs (service still
uses it).
- `if_same_then_else` in session_plan.rs::resolve_topology (pre-existing goal1
stage 3): collapsed the two `false` arms into one condition (behavior identical).
- `unused_unsafe` in the driver `pod_init!` macro: it expands at call sites already
inside an `unsafe` block, where its own `unsafe` is redundant — `#[allow(
unused_unsafe)]` (needed at the non-unsafe sites, redundant at the nested ones).
After these, BOTH builds are clean on the box — validating the whole session's
blind Windows + driver work compiles + passes clippy on real hardware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IDD-push consume loop acquired the slot's keyed mutex by hand
(`AcquireSync(0,8)` … work … `ReleaseSync(0)`), with a comment warning that a
`?`-return between acquire and release would leak the lock and stall the driver
on that slot — the reason the HDR converter is built *before* the acquire.
Replace with a `KeyedMutexGuard` RAII (acquire → `ReleaseSync` on drop), scoped
to JUST the convert/copy block so the lock releases at the EXACT same point as
before (the driver gets the slot back immediately; not held across the rest of
`try_consume`). Now the release can't be skipped on any early return/panic — the
leak footgun is gone by construction, and the hot loop has no raw `ReleaseSync`.
Behavior/latency-equivalent (same acquire params, same release point). Windows-
only (CI + on-glass gated); to be validated on the RTX box (host clippy build +
a PERF=1 latency A/B vs the shipping binary — the change should show no delta).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-existing working-tree changes committed to the branch on request: the
gpu-contention investigation doc, host-latency-plan additions, and small
pack-host-installer / stage-pf-vdisplay packaging-script edits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web-researched + adversarially-verified design for extending library.rs with more
store providers: the LibraryProvider extension point, the two cross-cutting pieces
(Windows interactive-session launch wiring + a layered artwork strategy), new
LaunchSpec kinds, per-store enumeration/launch/art recipes with priority/effort/
confidence, a phased plan, and the verification corrections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the no-op Windows `set_launch_command` real. New `windows/interactive.rs`
`spawn_in_active_session` (WTSGetActiveConsoleSessionId → WTSQueryUserToken →
CreateProcessAsUserW(winsta0\default) under the LOGGED-IN USER token, factored from
the wgc_relay primitive) + `library::launch_title` resolving a store-qualified id to
a concrete process via `windows_launch_for` (steam_appid → Steam.exe/explorer.exe
steam:// URI; command → cmd.exe /c). Threaded as `SessionContext.launch` into both
native data-plane paths (`virtual_stream`, `virtual_stream_relay`) and fired after
capture is live so the title renders onto the captured desktop and grabs foreground.
Security invariant intact: the client sends only the store-qualified id; the host
resolves the recipe from its own library and the URI/flags are handed to a concrete
EXE as plain args (never cmd /c of a client string). Linux unchanged (gamescope
nesting via the handshake PUNKTFUNK_GAMESCOPE_APP path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goal 3 scorecard + §4 P2: the OwnedHandle/RAII rollout now covers the three
gamepad backends via the shared inject/windows/gamepad_raii.rs (Shm + SwDevice).
Scratched the IOCTL-dispatcher item (control.rs's read_input/write_output_complete
are already generic — would be churn, not reduction). The only remaining unsafe
reductions are the deliberately-left service.rs SCM-handler event smuggling and
the on-glass-gated KeyedMutexGuard hot-loop RAII.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DualSense, DualShock 4, and XUSB Windows pad backends each hand-rolled the
SAME per-pad resource handling: a `CreateFileMappingW` + `MapViewOfFile` shared
section (with the permissive D:(A;;GA;;;WD) SDDL the restricted-token driver
needs) and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` +
`CloseHandle` — three copies, each a chance to drift or leak on an error path.
New `inject/windows/gamepad_raii.rs` owns both resources with RAII:
- `Shm` — the section handle (`OwnedHandle`) + its view; `Shm::create(name, size)`
does the SDDL + map + zero-fill leak-safely, `base()` gives the mapped pointer,
`Drop` unmaps then closes (in that order).
- `SwDevice` — the `SwDeviceCreate`'d devnode; `Drop` calls `SwDeviceClose`.
All three backends now hold `_sw: Option<SwDevice>` + `shm: Shm` instead of raw
`hsw`/`map`/`view`, access the section via `self.shm.base()`, and have NO manual
`Drop`. Deletes the duplicated `create_shm_section` (DualSense/DS4 now use
`Shm::create`) and the three hand-written Drops; the DS4 device-type byte is still
written before the magic, the SwDeviceCreate `None` fallback still works, and the
field drop order (devnode removed, then section unmapped+closed) matches the old
manual order.
Net: 3 manual `Drop`s + a duplicated section-creation path → one shared RAII
module; fewer unsafe ops, leak-on-error fixed by construction. Linux `cargo check`
clean (the inject mod wiring); the backends are #[cfg(windows)] → CI-gated.
Drafted + adversarially verified (no double-free, imports correct under
-D warnings, behavior preserved); my own spot-checks confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scorecard Goal 3 + §4 P2: the OwnedHandle RAII rollout (idd_push 011607e — also a
view-leak fix; service child/job 4c95ba7) and the driver pod_init! macro (bf57704,
27→1) landed. Recorded the remaining items (service SCM-handler event smuggling,
driver IOCTL-dispatch / KeyedMutexGuard levers, the deferred D1-host lint sweep)
and that ThreadBound was skipped as not-a-clean-win.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The driver zero-initialised C POD structs (IddCx/WDF descriptors) with 27
scattered `let mut x: T = unsafe { core::mem::zeroed() };`, each carrying its own
`// SAFETY` about the all-zero bit pattern being valid + the caller setting `.Size`
etc. right after.
Replace with one `pod_init!(T)` macro (in log.rs, reachable everywhere via the
existing `#[macro_use] mod log;` — same mechanism as `dbglog!`) that owns the
single `unsafe { zeroed::<T>() }` + the SAFETY rationale. All 27 sites
(adapter 6, callbacks 3, entry 4, monitor 10, swap_chain_processor 4) now read
`let mut x = pod_init!(T)`. Zero behavior change (mem::zeroed semantics identical);
the type is passed explicitly so no inference depends on the removed annotation.
27 `unsafe` blocks → 1. Driver still `deny(unsafe_op_in_unsafe_fn)`-clean (the
macro expands to an explicit `unsafe {}`; the one nested-in-user-unsafe site is
fine — no `unused_unsafe` for macro-generated blocks). Driver-only (CI-gated);
adversarially reviewed (macro scoping, all sites, no leftover raw zeroed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SCM supervisor scattered manual `CloseHandle(pi.hProcess)`/`(pi.hThread)`
across ~5 supervise-loop match arms and hand-closed the job object — easy to miss
an arm (leak) or double-close.
- `spawn_host` returns an owned `Child { process: OwnedHandle, _thread: OwnedHandle,
pid }` instead of raw `PROCESS_INFORMATION`; the supervise loop borrows
`child.process` (`HANDLE(as_raw_handle() as *mut c_void)`) for wait/Terminate and
the `Child` auto-closes both handles when it drops / is replaced each iteration.
- The job object → `OwnedHandle` (borrowed for AssignProcessToJobObject), auto-closed.
- Deletes ~9 manual `CloseHandle` calls. The `_thread` handle is RAII-only (`_`-prefixed
so `dead_code`/`-D warnings` doesn't flag it).
Deliberately LEFT the `STOP_EVENT`/`SESSION_EVENT` `AtomicIsize` statics as-is — they
are smuggled into the C SCM control handler, so `OwnedHandle`-ifying them is a separate,
riskier supervisor redesign out of scope here (noted in a comment).
Behavior preserved (the supervise state machine / wait semantics / restart-on-
session-change / kill-on-close are unchanged). Windows-only (CI-gated); adversarially
reviewed (no double-close, handles outlive their borrows, idiom matches manager.rs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IDD-push capturer held raw `HANDLE`s for the shared header mapping, the
frame-ready event, the debug section, and each ring slot's shared texture, with
manual `CloseHandle` scattered across two `Drop` impls — and the MapViewOfFile
VIEWS (header/dbg_block) were never UnmapViewOfFile'd (a real view leak).
- New `MappedSection { handle: OwnedHandle, view }` RAII: `Drop` UnmapViewOfFile's
the view THEN the `OwnedHandle` closes the mapping (unmap-before-close).
- `map`+`header` → `section: MappedSection` (+ a cached `header` ptr borrowing into
it, declared after `section` for drop order); same for `dbg_map`+`dbg_block`.
- `event: HANDLE` → `OwnedHandle` (borrowed as `HANDLE(as_raw_handle() as *mut
c_void)` for WaitForSingleObject); `HostSlot.shared` → `OwnedHandle` (its manual
`Drop` deleted). Removed the manual `CloseHandle`s + the `CloseHandle` import.
Net: deletes two `Drop` impls' worth of manual handle/view teardown and fixes the
view leak — fewer unsafe ops, RAII-correct. Behavior preserved (recreate_ring
writes the header in place; the keepalive still drops last so REMOVE is last).
Windows-only (CI-gated); adversarially reviewed (no double-free / UAF / dangling
header; handle interop matches manager.rs). Linux check unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared host<->driver ABI crate already contains more than the virtual
display: the IDD-push frame ring + control plane AND the gamepad shared-memory
layouts (XusbShm / PadShm). "pf-vdisplay-proto" was a misnomer — the name now
represents all the drivers it serves.
Mechanical rename, no behavior change:
- git mv crates/pf-vdisplay-proto -> crates/pf-driver-proto (package name +
path-deps in the host crate and the driver workspace).
- pf_vdisplay_proto -> pf_driver_proto across host + driver Rust, both Cargo.lock
files, the workspace members, the CI path triggers (windows-drivers.yml), and
the docs/INF comments. The runtime Global\pfvd-* shared-object names are a
SEPARATE contract and are deliberately untouched (host<->driver name matching).
- The pf-vdisplay DRIVER crate + its INF service name (Root\pf_vdisplay,
UmdfService=pf_vdisplay, pf_vdisplay.dll) are unchanged — only the full
`pf_vdisplay_proto` token was replaced, never the `pf_vdisplay` driver name.
Linux-verified: cargo test -p pf-driver-proto (const size-asserts compile) +
cargo clippy -p punktfunk-host -D warnings clean; Cargo.lock regenerated. The
driver-workspace side (path-dep + imports + its Cargo.lock) is Windows-CI-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goal 2 ("drop every trace of SudoVDA") is done. The SudoVDA driver is no longer
shipped (only pf-vdisplay; the old vdisplay-driver tree was deleted in a2bd0cd),
and F1 (d638a93/e60cda3) already moved the display-utility helpers out of the
backend into neutral modules (win_adapter/win_display), breaking the reach-in.
So the backend is now cleanly removable:
- Deleted crates/punktfunk-host/src/vdisplay/windows/sudovda.rs (350 lines: the
SudoVdaDisplay VirtualDisplay impl + its VdisplayDriver/probe).
- vdisplay::open()/probe() are now unconditional pf-vdisplay; deleted the
windows_use_pf_vdisplay() backend selector. open() now ensure!s
pf_vdisplay::is_available() with a clear "driver not installed" error instead
of the old silent SudoVDA fallback (no fallback driver exists anymore).
- Scrubbed the dangling references to the deleted symbols (manager/sendinput/dxgi
comments, the config + host.env PUNKTFUNK_VDISPLAY docs); the var stays as an
informational forward-seam. Updated the F1 module docs (Goal 2 now done).
All changes are #[cfg(windows)] except the config doc; Linux clippy
-p punktfunk-host -D warnings clean; zero `sudovda::`/`SudoVdaDisplay` code refs
remain (comments only). Windows build is CI-gated.
Scorecard Goal 2 -> DONE; recorded the E1 "do NOT do it" stability decision in
windows-host-rewrite.md §4 (the process-global driver design is sound given
ProcessSharingDisabled; a device-owned variant adds a use-after-free window for
no gain).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The driver assigned each virtual monitor a monotonically-increasing NEXT_ID used
as the EDID serial / IddCx ConnectorIndex / container GUID, and never reclaimed
it on REMOVE. Under sustained ADD/REMOVE churn the connector index kept climbing,
so IddCx/PnP allocated a NEW OS target slot every cycle and orphaned the old one
(ghost "Generic Monitor (punktfunk)" nodes) until the adapter's target capacity
was exhausted and ADD failed 0x80070490 ERROR_NOT_FOUND.
Fix: `create_monitor` now allocates the LOWEST free id (`alloc_monitor_id`,
computed under the MONITOR_MODES lock with the push) instead of a counter, so a
departed monitor's id is reclaimed and a fresh ADD reuses its target slot rather
than orphaning it. With <= N live monitors the id stays bounded to 1..=N+1.
Deleted the now-unused NEXT_ID + AtomicU32/Ordering import.
CI-compile-gated only — the wedge reproduces solely under sustained churn on the
RTX box, so this needs an on-glass reconnect-storm A/B to confirm (box is
ephemeral/down). Marked on-glass-pending in windows-host-rewrite.md §4; keep
reset-pf-vdisplay.ps1 as the recovery until validated. NOT to be relied on (or
merged to main) until that A/B passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the validated IDD-push zero-copy path the default for a fresh install,
without penalising dev / non-pf-driver runs:
- The shipped default config now enables it. Both seed sites set
`PUNKTFUNK_VDISPLAY=pf` + `PUNKTFUNK_IDD_PUSH=1`: the hardcoded default the
service writes on `service install` (`ensure_default_host_env`) AND the
`host.env.example` template the installer bundles. A fresh install therefore
runs the validated path (the installer also bundles the pf-vdisplay driver);
it falls back to DDA if the driver can't attach.
- `idd_push` is now **value-aware** instead of a bare presence flag, so an
operator can turn it OFF with `PUNKTFUNK_IDD_PUSH=0` in host.env — a `var_os`
presence check read `=0` as "on". Unset still ⇒ off (the code default is
unchanged, so existing host.env files and dev/CI runs are unaffected; only the
shipped default config opts in).
Also scrubbed the stale "SudoVDA" wording in host.env.example. Linux cargo
clippy -p punktfunk-host -D warnings clean; the service.rs default string is
Windows-only (CI-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stand up Storybook so the management console can be designed without a running
host, plus the design-system work that surfaced along the way.
Storybook (@storybook/react-vite):
- Slim Start/Nitro-free vite config; the preview imports the app's real
src/styles.css directly so the design tokens stay single-sourced (no mirror).
- Stories for the @unom/ui primitives (Button/Card/Inputs/Badge), brand marks,
the AppShell (throwaway in-memory TanStack router), and every data-driven page
(Dashboard/Host/Clients/Library/Settings) rendered offline via a window.fetch
stub + typed fixtures. The route page components are exported so stories can
render them.
Light theme:
- styles.css now carries a light :root (lavender, from the docs palette) with the
existing violet chrome moved to .dark; the live console still pins html.dark by
default, so this only adds the option (Storybook's toolbar toggles it).
- Fixes a stray `*/` inside a comment that prematurely closed it and silently
broke Tailwind's @theme processing.
Spinner:
- The punktfunk lens recreated with motion/react: two circles surge through one
another in depth (JS perspective scale + z-index — robust where mix-blend-mode
flattens CSS preserve-3d) with a screen-blend lens highlight. Replaces the
skeleton loading state in QueryState; removes ui/skeleton.tsx.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows-host docs were scattered across a design plan, a staged-refactor
plan, an audit, an audit-remediation tracker, and a game-capture-bug analysis —
several badly stale (the audit/remediation predate the Goal-1 branch landing and
call DONE items "not started"). Verified the true state of every audit finding /
goal / milestone against current code+git (4-agent workflow), then rewrote
windows-host-rewrite.md as ONE consolidated, accurate doc:
- §1 Status scorecard (Goals 1-3, M0-M6, GB1, audit P0/P1/P2) with DONE/PARTIAL/
OPEN + commit evidence.
- §2 Architecture as-built (layering, HostConfig→SessionPlan→SessionContext, the
VirtualDisplayManager ownership model, IDD-push-primary capture incl. secure
desktop + GB1 recovery, encode/EncoderCaps, pf-vdisplay-proto, the driver,
service/packaging).
- §3 Validated invariants (the jewels).
- §4 Prioritized open tasks (the genuine remaining work).
- §5 Operations (RTX-box recipe, CI, env, build).
- §6 Deep reference (/INTEGRITYCHECK answer, the 6 iddcx bindgen knobs, the driver
port checklist, resolved decisions).
Deleted the four now-redundant docs (content folded in; history in git):
windows-host-goal1-plan.md, windows-host-rewrite-audit.md,
windows-host-rewrite-remediation.md, windows-host-rewrite-game-capture-bug.md.
Repointed the 6 code/proto/driver doc-comment refs that targeted them at the
consolidated windows-host-rewrite.md sections. Linux cargo check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner-confirmed on glass (2026-06-25, "works great"): the IDD-push primary path
captures the lock/UAC secure desktop AND input reaches the streamed console
session. This was the single biggest open risk — the whole capture strategy
(Decision B: IDD-push primary for everything incl. secure desktop, WGC/DDA
demoted) rested on it. Now proven, not asserted.
- §15: M3 row → DONE (secure desktop); removed the secure-desktop gate from
"What genuinely remains" (renumbered); added it to "Resolved since §11".
- §11 "IDD-push input + secure desktop" open item → RESOLVED.
- §14 critique "SINGLE BIGGEST RISK: the secure-desktop claim" → RESOLVED.
The WGC-relay / secure-DDA path is no longer load-bearing — kept only as a
non-IddCx-hardware fallback. Remaining rewrite work is migration/cleanup (M4
gamepad drivers, M5/M6, slot-reclaim), none blocking the validated path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fullscreen-game-breaks-IDD-push bug is FIXED by the resolution-listening
recovery (c87bfe0: the 250ms poll now follows the display's actual resolution
and recreates the ring on any descriptor change, recover-or-drop), backed by
open-time first-frame DDA failover (f98ab07) and the driver publish() width/
height guard + flushed logging (789ad49). No protocol bump was needed — the host
reads the real resolution straight from Windows (CCD/GDI), so the bug doc's
Stage-1 composing capturer + Stage-2 protocol bump were unnecessary. Bug doc
marked FIXED with a Resolution section; the staged plan kept as superseded record.
windows-host-rewrite.md: the progress log was stale (ended at "M1 cont."). Added
§15 Current status — the driver STEP 0-8 port landed on main on-glass HDR-
validated; the host was refactored *in place* via windows-host-goal1 (not the §10
greenfield rebuild); §2.5 ownership model resolved the swap-chain-reuse / monitor-
leak open item; iddcx + /INTEGRITYCHECK CI-green. Remaining: the secure-desktop
on-glass gate (the single biggest unproven claim), M4 gamepad-driver migration,
M5/M6 cleanup, and the pf-vdisplay slot-reclaim driver fix. Top Status flipped
proposed → largely implemented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Goal-1 host refactor is now functionally complete — all 6 stages, §2.5, and
all three Stage-5 seam-trait tightenings have landed (EncoderCaps = 0ccd0fe).
Remaining is non-blocking: the optional namespace collapse (decision: skip —
pure churn), the merge to main (confirm with the user — outward-facing), and the
pf-vdisplay slot-reclaim driver fix (reassigned to windows-host-rewrite.md, the
greenfield driver rewrite, alongside the fullscreen-game capture bug).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last §2.3 seam-trait tightening: give `Encoder` a `caps() -> EncoderCaps`
so the session glue routes by *query* instead of relying on the no-op/`false`
defaults of `invalidate_ref_frames`/`set_hdr_meta`.
`EncoderCaps { supports_rfi, supports_hdr_metadata }` is a cheap `Copy` struct.
The trait gains a default `caps()` returning `EncoderCaps::default()` (all
false) — correct for every SDR/libavcodec backend (Linux NVENC, VAAPI, AMF/QSV,
software openh264), so they need no change. Only the Windows direct-NVENC path
(`NvencD3d11Encoder`) overrides it, reporting the real `rfi_supported` (probed
once at open via `nvEncGetEncodeCaps`) and `hdr` (HDR-SEI on keyframes).
Consumer: the GameStream encode loop (`gamestream/stream.rs`) hoists
`supports_rfi` once before the loop and gates the loss-recovery path on it —
`!(supports_rfi && enc.invalidate_ref_frames(..))` forces a keyframe directly
on non-RFI encoders instead of making an always-`false` call every loss event.
Behaviour-preserving (same keyframe/RFI outcome), one fewer no-op call, intent
explicit. The native host (punktfunk1) uses FEC+keyframes, no RFI consumer.
Linux `cargo clippy -p punktfunk-host --all-targets -D warnings` clean; the
three edited files are rustfmt-clean. The NVENC override is Windows-only
(1:1 with the existing impl style) → CI/on-glass gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:20 +00:00
756 changed files with 130296 additions and 26826 deletions
VERSION="$V" node -e 'const fs=require("fs");for(const f of ["clients/decky/package.json","clients/decky/plugin.json"]){const j=JSON.parse(fs.readFileSync(f,"utf8"));j.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n");}'
- name:Assemble store-layout zip
working-directory:${{ gitea.workspace }}
@@ -89,9 +105,20 @@ jobs:
chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
cp LICENSE-MIT "$DEST/LICENSE"
# Self-update channel pointer the backend reads (main.py check_update). It points at
# THIS channel's manifest.json (published below); that manifest in turn points at the
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -502,11 +504,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
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.