8 Commits

Author SHA1 Message Date
enricobuehler 3947d5b07a fix(host/audio): drive the Linux virtual mic with RT_PROCESS (was silent)
apple / swift (push) Successful in 1m1s
ci / rust (push) Successful in 4m36s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 55s
apple / screenshots (push) Successful in 5m9s
ci / bench (push) Successful in 4m36s
windows-host / package (push) Successful in 7m8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m19s
release / apple (push) Successful in 9m52s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
android / android (push) Successful in 3m21s
decky / build-publish (push) Successful in 11s
deb / build-publish (push) Successful in 2m45s
flatpak / build-publish (push) Successful in 4m11s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m48s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
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>
2026-06-28 12:46:06 +00:00
enricobuehler 238501597e feat(host/gamestream): follow Desktop<->Game session switches
android / android (push) Successful in 4m49s
ci / web (push) Successful in 55s
apple / swift (push) Successful in 59s
ci / rust (push) Successful in 4m52s
ci / docs-site (push) Successful in 56s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 7m1s
deb / build-publish (push) Successful in 2m30s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m7s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m59s
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>
2026-06-28 12:22:12 +00:00
enricobuehler 04dd3e3a19 docs: refresh Windows host page for new users; drop stale Status/NVIDIA-only/SudoVDA
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>
2026-06-28 11:22:50 +00:00
enricobuehler 61aa1053e7 feat(host/gamescope): headless game mode that follows the box + matches the client
apple / swift (push) Successful in 1m2s
android / android (push) Successful in 4m43s
ci / rust (push) Successful in 4m53s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 57s
apple / screenshots (push) Successful in 5m6s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-host / package (push) Successful in 9m2s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m6s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m43s
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>
2026-06-28 11:09:45 +00:00
enricobuehler 50e17b3508 fix(host/capture): hold the session through a slow compositor switch
apple / swift (push) Successful in 1m1s
ci / docs-site (push) Successful in 54s
apple / screenshots (push) Successful in 5m14s
deb / build-publish (push) Successful in 2m30s
decky / build-publish (push) Successful in 11s
android / android (push) Successful in 4m41s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 49s
windows-host / package (push) Successful in 7m54s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m34s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m10s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m7s
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>
2026-06-28 09:31:47 +00:00
enricobuehler 94c556f0e3 fix(host/capture): recover from compositor loss instead of freezing
apple / screenshots (push) Successful in 5m7s
apple / swift (push) Successful in 1m1s
windows-host / package (push) Successful in 7m26s
android / android (push) Successful in 4m50s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 11s
ci / rust (push) Successful in 4m51s
deb / build-publish (push) Successful in 2m29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m1s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
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>
2026-06-28 09:00:35 +00:00
enricobuehler 32c1929948 feat(host/session-watch): default Gaming↔Desktop follow on for Bazzite/SteamOS
apple / swift (push) Successful in 1m2s
android / android (push) Successful in 4m52s
ci / rust (push) Successful in 5m3s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 22s
windows-host / package (push) Successful in 9m7s
ci / bench (push) Successful in 4m40s
apple / screenshots (push) Successful in 5m20s
deb / build-publish (push) Successful in 2m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m29s
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>
2026-06-28 08:43:27 +00:00
enricobuehler 3915a82780 fix(host/input): route KWin auto-detect to the fake_input backend
apple / swift (push) Successful in 1m1s
apple / screenshots (push) Successful in 5m2s
windows-host / package (push) Successful in 6m56s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m52s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 56s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m4s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m0s
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>
2026-06-27 11:52:02 +00:00
16 changed files with 618 additions and 115 deletions
+8 -1
View File
@@ -320,11 +320,18 @@ fn mic_pw_thread(
.into_inner();
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
// RT_PROCESS: run the producer callback on PipeWire's realtime data loop, so the source is a
// *synchronous* graph node that joins its consumer's driver group and is actually driven. Without
// it the node is async/main-loop and, in the host's busy multi-stream graph (desktop-audio +
// video capture + the session), never acquires a driver — it stays suspended and its process()
// never fires, so every recorder hears pure silence (the long-standing "Linux host mic broken").
stream
.connect(
spa::utils::Direction::Output, // we PRODUCE samples (a source)
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.context("pw mic stream connect")?;
+52 -1
View File
@@ -40,6 +40,13 @@ pub struct PortalCapturer {
/// branch to tell "format never negotiated" (modifier/format mismatch) apart from "negotiated
/// but no buffers arrived" (compositor idle/unmapped) — the two black-screen root causes.
negotiated: Arc<AtomicBool>,
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
streaming: Arc<AtomicBool>,
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
/// is `Streaming`.
stall_since: Option<std::time::Instant>,
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
node_id: u32,
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
@@ -109,6 +116,7 @@ struct PwHandles {
frames: Receiver<CapturedFrame>,
active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
quit: ::pipewire::channel::Sender<()>,
join: thread::JoinHandle<()>,
}
@@ -121,6 +129,8 @@ impl PwHandles {
frames: self.frames,
active: self.active,
negotiated: self.negotiated,
streaming: self.streaming,
stall_since: None,
node_id,
quit: Some(self.quit),
join: Some(self.join),
@@ -143,6 +153,8 @@ fn spawn_pipewire(
let active_cb = active.clone();
let negotiated = Arc::new(AtomicBool::new(false));
let negotiated_cb = negotiated.clone();
let streaming = Arc::new(AtomicBool::new(false));
let streaming_cb = streaming.clone();
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
// inner `mod pipewire` shadows the crate name at this scope.
@@ -157,6 +169,7 @@ fn spawn_pipewire(
frame_tx,
active_cb,
negotiated_cb,
streaming_cb,
zerocopy,
preferred,
quit_rx,
@@ -169,6 +182,7 @@ fn spawn_pipewire(
frames: frame_rx,
active,
negotiated,
streaming,
quit: quit_tx,
join,
})
@@ -219,6 +233,28 @@ impl Capturer for PortalCapturer {
}
}
}
if latest.is_some() || self.streaming.load(Ordering::Relaxed) {
// A frame arrived, or the source is alive but idle (static desktop) — normal. Clear any
// stall and repeat the last frame on `None`, exactly as before.
self.stall_since = None;
return Ok(latest);
}
// No new frame AND the stream has left `Streaming` (Paused/Unconnected/Error). The source
// went away — a compositor torn down on a Gaming↔Desktop switch, a removed virtual output.
// Grace a brief window (a transient mid-stream renegotiation can blip out of Streaming and
// back) before declaring it lost so the encode loop rebuilds in place rather than freezing
// on the last frame forever.
const STALL_GRACE: Duration = Duration::from_millis(1500);
let since = *self.stall_since.get_or_insert_with(std::time::Instant::now);
if since.elapsed() >= STALL_GRACE {
self.stall_since = None;
return Err(anyhow!(
"PipeWire source stalled (node {}): stream left Streaming for >{}ms with no frames \
— the compositor/virtual output went away (session switch?)",
self.node_id,
STALL_GRACE.as_millis()
));
}
Ok(latest)
}
@@ -467,6 +503,10 @@ mod pipewire {
/// Set once a video format is agreed (`param_changed`), so a first-frame timeout can tell
/// "format never negotiated" apart from "negotiated but no buffers arrived".
negotiated: Arc<AtomicBool>,
/// True only while the PipeWire stream is in `Streaming` (the source is alive). Goes false on
/// `Paused`/`Unconnected`/`Error` — the source vanished (compositor torn down on a session
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
streaming: Arc<AtomicBool>,
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer.
importer: Option<crate::zerocopy::EglImporter>,
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
@@ -1056,6 +1096,7 @@ mod pipewire {
tx: SyncSender<CapturedFrame>,
active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
zerocopy: bool,
preferred: Option<(u32, u32, u32)>,
quit_rx: pw::channel::Receiver<()>,
@@ -1150,6 +1191,7 @@ mod pipewire {
tx,
active,
negotiated,
streaming,
importer,
vaapi_passthrough,
nv12: crate::zerocopy::nv12_enabled(),
@@ -1174,8 +1216,17 @@ mod pipewire {
let _listener = stream
.add_local_listener_with_user_data(data)
.state_changed(|_stream, _ud, old, new| {
.state_changed(|_stream, ud, old, new| {
tracing::info!(?old, ?new, "pipewire stream state");
// Track whether the node is actively producing. A live source sits in `Streaming`
// (a static desktop just sends no buffers); anything else — `Paused`/`Unconnected`/
// `Error` — means the source went away (compositor died, virtual output removed on a
// Gaming↔Desktop switch). `try_latest` turns a sustained non-Streaming state into a
// capture-loss so the encode loop rebuilds instead of freezing on the last frame.
ud.streaming.store(
matches!(new, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
})
.param_changed(|_stream, ud, id, param| {
let Some(param) = param else { return };
+136 -38
View File
@@ -114,12 +114,12 @@ fn run(
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
// output is released when this capturer drops at stream end (RAII via its keepalive).
if crate::config::config().video_source.as_deref() == Some("virtual") {
// The launched app picks the compositor (e.g. gamescope for game entries) and the
// nested command.
let compositor = app
.and_then(|a| a.compositor)
.map(Ok)
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?;
// Open the virtual-display source: pick the live compositor, normalize the session env
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
// exactly like the native plane), create a virtual output at the client mode, and capture it.
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
// Desktop<->Game switch.
let (mut capturer, compositor) = open_gs_virtual_source(cfg, app)?;
tracing::info!(
?compositor,
app = ?app.map(|a| &a.title),
@@ -127,31 +127,6 @@ fn run(
h = cfg.height,
"video source: virtual display (native client resolution)"
);
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target.
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("create virtual output at client resolution")?;
// `want_hdr=false`: the IDD-push backend (opt-in PUNKTFUNK_IDD_PUSH) has no monitor-HDR
// auto-detection — it converts its always-FP16 ring per this flag — and GameStream HDR is not
// negotiated into StreamConfig here, so an IDD-push GameStream session streams SDR even on an
// HDR desktop. (The default WGC backend DOES auto-detect HDR from the output colorspace, but
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push
// is wired only for punktfunk/1 (want_hdr = negotiated bit_depth >= 10). TODO: derive want_hdr
// from a GameStream HDR flag once StreamConfig carries one.
let mut capturer = capture::capture_virtual_output(
vout,
capture::OutputFormat::resolve(false),
crate::session_plan::CaptureBackend::resolve(),
)
.context("capture virtual output")?;
capturer.set_active(true);
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
// the existing desktop, so the app must be spawned into the session to land on the streamed
@@ -171,8 +146,14 @@ fn run(
}
}
}
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
// GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.)
let rebuild = || open_gs_virtual_source(cfg, app).map(|(c, _)| c);
return stream_body(
&mut *capturer,
&mut capturer,
Some(&rebuild),
&sock,
cfg,
running,
@@ -200,8 +181,10 @@ fn run(
}
};
capturer.set_active(true);
// Portal/synthetic source: no compositor virtual output to re-detect, so no rebuild closure.
let result = stream_body(
&mut *capturer,
&mut capturer,
None,
&sock,
cfg,
running,
@@ -215,6 +198,53 @@ fn run(
result
}
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
/// retargeting) exactly like the native plane (punktfunk1.rs resolve_compositor), create a virtual
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
fn open_gs_virtual_source(
cfg: StreamConfig,
app: Option<&super::apps::AppEntry>,
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
c
} else {
let active = crate::vdisplay::detect_active_session();
crate::vdisplay::apply_session_env(&active);
let c = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
crate::vdisplay::apply_input_env(c);
c
};
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target.
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("create virtual output at client resolution")?;
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
let capturer = capture::capture_virtual_output(
vout,
capture::OutputFormat::resolve(false),
crate::session_plan::CaptureBackend::resolve(),
)
.context("capture virtual output")?;
capturer.set_active(true);
Ok((capturer, compositor))
}
/// One frame's packets, handed from the encode thread to the send thread.
type PacketBatch = Vec<Vec<u8>>;
@@ -367,7 +397,11 @@ fn percentile(v: &mut [u32], q: f64) -> u32 {
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
#[allow(clippy::too_many_arguments)]
fn stream_body(
capturer: &mut dyn Capturer,
// `&mut Box` (not `&mut dyn`) so a mid-stream capture-loss rebuild can SWAP the capturer in place.
capturer: &mut Box<dyn Capturer>,
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
sock: &UdpSocket,
cfg: StreamConfig,
running: &Arc<AtomicBool>,
@@ -459,7 +493,12 @@ fn stream_body(
// RFI capability is fixed for the session (probed at encoder open). Query it once so the
// recovery path skips the always-`false` invalidate call on encoders without NVENC RFI and
// forces a keyframe directly instead.
let supports_rfi = enc.caps().supports_rfi;
let mut supports_rfi = enc.caps().supports_rfi;
// Bound consecutive capture-loss rebuilds (a delivered frame clears the counter) so a permanently
// dead source can't loop forever — it ends the stream after the cap, falling back to a reconnect.
const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0;
while running.load(Ordering::SeqCst) {
let tick = Instant::now();
@@ -467,9 +506,68 @@ fn stream_body(
// armed (cheap Relaxed atomic, re-read each frame).
let measure = perf || stats.is_armed();
// Advance to the freshest captured frame if one arrived; otherwise reuse the last.
if let Some(f) = capturer.try_latest().context("capture frame")? {
frame = f;
uniq += 1;
match capturer.try_latest() {
Ok(Some(f)) => {
frame = f;
uniq += 1;
rebuilds = 0; // a delivered frame clears the consecutive-loss counter
}
Ok(None) => {} // no new frame — reuse the last (static/idle desktop)
Err(e) => {
// The capture source went away — the compositor was torn down on a Desktop<->Game
// switch, or the virtual output was removed. On the virtual-display path, re-detect the
// now-live compositor and re-attach IN PLACE (the send thread + packetizer + socket +
// RTP clock all survive), then force an IDR so Moonlight resyncs — so the stream FOLLOWS
// the switch with no client reconnect. Build the new source BEFORE dropping the old.
// Bounded by a counter + a ~40s budget; on exhaustion, end the stream (Moonlight
// reconnect). The portal/synthetic path has no rebuild closure → propagate as before.
let Some(rebuild) = rebuild else {
return Err(e).context("capture frame");
};
rebuilds += 1;
if rebuilds > MAX_REBUILDS {
return Err(e).context("capture lost — rebuild attempts exhausted");
}
tracing::warn!(error = %format!("{e:#}"), rebuild = rebuilds,
"gamestream: capture lost — rebuilding source in place (following a session switch)");
let rebuild_deadline = Instant::now() + Duration::from_secs(40);
let new_cap = loop {
match rebuild() {
Ok(c) => break c,
Err(e2) => {
if !running.load(Ordering::SeqCst) || Instant::now() >= rebuild_deadline
{
return Err(e2)
.context("capture lost — no source within the rebuild budget");
}
tracing::warn!(error = %format!("{e2:#}"),
"gamestream: source not up yet — retrying");
std::thread::sleep(Duration::from_millis(500));
}
}
};
*capturer = new_cap;
capturer.set_active(true);
frame = capturer.next_frame().context("first frame after rebuild")?;
// Re-open the encoder for the new source (same negotiated WxH → same SPS profile) and
// force an IDR so Moonlight resyncs on the first emitted AU.
enc = encode::open_video(
cfg.codec,
frame.format,
frame.width,
frame.height,
cfg.fps,
cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(),
8,
)
.context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues");
continue;
}
}
let t_cap = tick.elapsed();
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
+105 -5
View File
@@ -2256,6 +2256,45 @@ struct SessionSwitch {
/// read (so no handshake plumbing). Opt-in via `PUNKTFUNK_SESSION_WATCH`; readiness of the new
/// backend is left to the encode thread's `build_pipeline_with_retry` (the watcher never writes
/// env). Exits when `stop` is set or the channel closes.
/// Whether to run the mid-stream session-switch watcher. An explicit `PUNKTFUNK_SESSION_WATCH` wins
/// (truthy → on; `0`/`false`/`no`/`off`/empty → off). When unset it defaults **on** for Steam HTPC
/// platforms (Bazzite / SteamOS) — which flip Gaming↔Desktop and need the host to follow the switch
/// mid-stream — and **off** elsewhere, preserving the opt-in default for plain desktop hosts.
fn session_watch_enabled() -> bool {
match std::env::var("PUNKTFUNK_SESSION_WATCH") {
Ok(v) => {
let v = v.trim();
!(v.is_empty()
|| v == "0"
|| v.eq_ignore_ascii_case("false")
|| v.eq_ignore_ascii_case("no")
|| v.eq_ignore_ascii_case("off"))
}
Err(_) => is_steam_htpc_platform(),
}
}
/// True on Bazzite or SteamOS (matched against os-release `ID`/`ID_LIKE`) — the platforms that flip
/// between Steam Gaming Mode and a Desktop session, where following a mid-stream switch is the
/// sensible default. Anything else (incl. non-Linux, where the file is absent) → false.
fn is_steam_htpc_platform() -> bool {
let Ok(os) = std::fs::read_to_string("/etc/os-release") else {
return false;
};
os.lines().any(|line| {
let line = line.trim();
let Some(val) = line
.strip_prefix("ID=")
.or_else(|| line.strip_prefix("ID_LIKE="))
else {
return false;
};
val.trim_matches('"')
.split_whitespace()
.any(|tok| tok.eq_ignore_ascii_case("bazzite") || tok.eq_ignore_ascii_case("steamos"))
})
}
fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<AtomicBool>) {
use crate::vdisplay;
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(3);
@@ -2491,9 +2530,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// place when the box flips Gaming↔Desktop. When not spawned, session_rx just stays empty.
let mut compositor = compositor;
let (session_tx, session_rx) = std::sync::mpsc::channel::<SessionSwitch>();
let watch = std::env::var_os("PUNKTFUNK_SESSION_WATCH").is_some()
&& crate::config::config().compositor.is_none();
let watch = session_watch_enabled() && crate::config::config().compositor.is_none();
let _watcher = if watch {
tracing::info!("session watcher on — following a mid-stream Gaming↔Desktop switch");
let stop = stop.clone();
std::thread::Builder::new()
.name("punktfunk1-watcher".into())
@@ -2675,15 +2714,76 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
}
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
"capture lost — rebuilding pipeline in place");
let (new_cap, new_enc, new_frame, new_interval) =
build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth, plan)
.context("rebuild after capture loss")?;
// A Bazzite/SteamOS Gaming↔Desktop switch tears the old compositor down and can take
// 15s+ to bring the new one up. Don't fail the session over that (the client would
// have to cold-reconnect, surfacing a "session failed") — keep retrying within a
// generous budget while the QUIC keepalive (its own thread) holds the connection,
// RE-DETECTING the live compositor each attempt so we follow the box to whatever
// session comes up: a fresh instance of the same compositor, OR a different one
// (the kind-change case the session watcher also handles). The client stays
// connected, frozen on the last frame, and the stream resumes when the new output
// appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
let (new_cap, new_enc, new_frame, new_interval) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it).
if crate::config::config().compositor.is_none() {
let active = crate::vdisplay::detect_active_session();
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
crate::vdisplay::apply_session_env(&active);
crate::vdisplay::apply_input_env(c);
if c != compositor {
if matches!(
c,
crate::vdisplay::Compositor::Kwin
| crate::vdisplay::Compositor::Mutter
) {
crate::vdisplay::settle_desktop_portal(c);
}
match crate::vdisplay::open(c) {
Ok(v) => {
tracing::info!(from = compositor.id(), to = c.id(),
"capture loss: active session switched compositor — retargeting");
vd = v;
compositor = c;
}
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
"capture loss: opening the newly-detected compositor failed — retrying"),
}
}
}
}
match build_pipeline_with_retry(
&mut vd,
cur_mode,
bitrate_kbps,
bit_depth,
plan,
) {
Ok(p) => break p,
Err(e2) => {
if stop.load(Ordering::SeqCst)
|| std::time::Instant::now() >= rebuild_deadline
{
return Err(e2)
.context("capture lost — no compositor came up within the rebuild budget");
}
tracing::warn!(error = %format!("{e2:#}"),
"capture lost — new session not up yet, retrying");
}
}
};
capturer = new_cap;
enc = new_enc;
frame = new_frame;
interval = new_interval;
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
next = std::time::Instant::now();
tracing::info!(
compositor = compositor.id(),
"capture loss: pipeline rebuilt — stream resumes"
);
}
}
if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) {
+5 -1
View File
@@ -457,7 +457,11 @@ pub fn settle_desktop_portal(_chosen: Compositor) {}
pub fn apply_input_env(chosen: Compositor) {
let backend = match chosen {
Compositor::Gamescope => "gamescope",
Compositor::Kwin | Compositor::Mutter => "libei",
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
Compositor::Kwin => "kwin",
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
Compositor::Mutter => "libei",
Compositor::Wlroots => "wlr",
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
@@ -15,7 +15,7 @@
//! `inject/libei.rs`) — wired and live-validated.
use super::{Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, Context, Result};
use anyhow::{anyhow, bail, Context, Result};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
@@ -110,12 +110,11 @@ impl VirtualDisplay for GamescopeDisplay {
// PUNKTFUNK_GAMESCOPE_NODE=<id|auto>; "auto" discovers the gamescope `Video/Source` node.
if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") {
let node_id: u32 = if id.trim().eq_ignore_ascii_case("auto") {
find_gamescope_node().ok_or_else(|| {
anyhow!(
"PUNKTFUNK_GAMESCOPE_NODE=auto but no running gamescope Video/Source node \
was found — is the headless gamescope/Steam session up?"
)
})?
// Attach to the box-owned game-mode session, but FIRST make it run at the connecting
// client's resolution (the box is headless, so its game-mode mode is ours to set).
// Reuse if it already matches (fast, no restart); otherwise relaunch the box's own
// session at the client mode. Without this the client gets the box's default mode.
ensure_box_gamescope_mode(mode)?
} else {
id.parse()
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
@@ -368,6 +367,150 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
})
}
/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s
/// output size, then return its capture node. Reuses the running session if it already matches (no
/// restart — the rock-solid fast path a stable client always hits); otherwise reconfigures + restarts
/// the box's OWN autologin `gamescope-session-plus@<client>` unit at the client mode. Restarting the
/// box's own unit (rather than spawning a competing one) avoids the autologin-respawn fight the old
/// MANAGED path hit. A headless box has no physical panel, so its game-mode resolution is ours to set;
/// Steam restarts only on an actual resolution CHANGE.
fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
let target = (mode.width, mode.height);
// Fast path: already at the client's resolution — just attach to the live node.
if current_gamescope_output_size() == Some(target) {
if let Some(node) = find_gamescope_node() {
tracing::info!(
w = mode.width,
h = mode.height,
node,
"gamescope: box game-mode session already at the client's resolution — reusing"
);
return Ok(node);
}
}
let Some(unit) = running_autologin_gamescope_unit() else {
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
// whatever node exists, accepting its resolution.
return find_gamescope_node().ok_or_else(|| {
anyhow!(
"no running gamescope Video/Source node — is the headless game mode up? \
(put the box into Steam Game Mode)"
)
});
};
tracing::info!(
from = ?current_gamescope_output_size(),
to_w = mode.width,
to_h = mode.height,
hz = mode.refresh_hz,
%unit,
"gamescope: relaunching the box game-mode session at the client's resolution"
);
// The session reads SCREEN_WIDTH/HEIGHT (+ CUSTOM_REFRESH_RATES) from the user-manager
// environment; set them and restart the box's own unit.
systemctl_user(&[
"set-environment",
&format!("SCREEN_WIDTH={}", mode.width),
&format!("SCREEN_HEIGHT={}", mode.height),
&format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)),
]);
systemctl_user(&["restart", &unit]);
// Wait for the relaunched session to come up at the new size and publish its capture node. The
// node appears when gamescope is up (well before Steam finishes booting); the caller's
// first-frame retry absorbs Steam's cold start.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if current_gamescope_output_size() == Some(target) {
if let Some(node) = find_gamescope_node() {
tracing::info!(
node,
w = mode.width,
h = mode.height,
"gamescope: box game-mode session relaunched at the client's resolution"
);
return Ok(node);
}
}
if Instant::now() >= deadline {
bail!(
"box game-mode session did not come up at {}x{} within 45s after relaunch \
(Steam may still be booting)",
mode.width,
mode.height
);
}
std::thread::sleep(Duration::from_millis(500));
}
}
/// Output (capture) resolution `-W <w> -H <h>` of the running `gamescope` binary, parsed from its
/// `/proc/<pid>/cmdline`. `None` if no gamescope is running or the flags aren't present.
fn current_gamescope_output_size() -> Option<(u32, u32)> {
for entry in std::fs::read_dir("/proc").ok()?.flatten() {
let name = entry.file_name();
let Some(pid) = name.to_str() else { continue };
if !pid.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
continue;
};
let args: Vec<String> = raw
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect();
// Match the gamescope BINARY by argv[0]'s basename — NOT /proc/<pid>/exe, which is commonly
// unreadable for the gamescope process (returns empty). The session wrapper scripts run as
// bash/sh (argv[0] != gamescope), so they're excluded; the -W/-H presence check below is the
// final filter.
let is_gamescope = args
.first()
.map(|a0| a0.rsplit('/').next().unwrap_or(a0) == "gamescope")
.unwrap_or(false);
if !is_gamescope {
continue;
}
let flag = |names: &[&str]| -> Option<u32> {
args.iter().enumerate().find_map(|(i, a)| {
names
.contains(&a.as_str())
.then(|| args.get(i + 1).and_then(|v| v.parse().ok()))
.flatten()
})
};
if let (Some(w), Some(h)) = (
flag(&["-W", "--output-width"]),
flag(&["-H", "--output-height"]),
) {
return Some((w, h));
}
}
None
}
/// The running autologin gaming-mode unit (`gamescope-session-plus@<client>.service`), if any — the
/// box's own game-mode session, which [`ensure_box_gamescope_mode`] reconfigures + restarts.
fn running_autologin_gamescope_unit() -> Option<String> {
let out = Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--state=running",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_whitespace().next())
.find(|u| u.starts_with("gamescope-session-plus@") && u.ends_with(".service"))
.map(|u| u.to_string())
}
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
+3 -3
View File
@@ -43,12 +43,12 @@ signed installer — see [Windows Host](/docs/windows-host) for what it includes
```
3. Run `punktfunk-host-setup-<ver>.exe` (elevated). It installs to `C:\Program Files\punktfunk`,
optionally installs the bundled **SudoVDA** virtual-display driver, and registers + starts the
installs the bundled **pf-vdisplay** virtual-display driver, and registers + starts the
`LocalSystem` service (`/VERYSILENT` for an unattended install). Upgrades and uninstall go through
Add/Remove Programs.
You need an NVIDIA GPU + driver (the host is NVENC-only on Windows). More detail — including the CLI
`punktfunk-host service install` path — is in
For hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or Intel (QSV); there's a software
fallback without one. More detail — including the CLI `punktfunk-host service install` path — is in
[Running as a Service → Windows](/docs/running-as-a-service#windows).
## What the packages are
+4 -3
View File
@@ -19,9 +19,10 @@ environments it supports today, each with its own guide:
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't
listed, the host still needs one of these compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**
> — a signed installer that registers a service and bundles a virtual-display driver. It's NVIDIA-only
> and newer than the Linux host; see [Windows Host](/docs/windows-host).
> **Windows host:** punktfunk also runs as a native host on **Windows 10/11 (x64)** — a signed
> installer that registers a service and bundles a virtual-display driver. It encodes on NVIDIA
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
> [Windows Host](/docs/windows-host).
## GPU and driver
+3 -3
View File
@@ -35,7 +35,7 @@ see [Status & Progress](/docs/status).
from one process.
- **Native-resolution virtual displays** on Linux across KWin, GNOME/Mutter, gamescope, and
Sway/wlroots, with a fully zero-copy GPU path to NVENC (stable 240 fps at 5120×1440).
- **A native Windows host** (NVIDIA, x64) — a signed installer with secure-desktop capture and a
- **A native Windows host** (x64; NVIDIA/AMD/Intel encode) — a signed installer with secure-desktop capture and a
bundled virtual-display driver, and the only host that can stream **HDR** (10-bit BT.2020 PQ,
captured from an HDR Windows desktop and encoded as HEVC Main10). See
[Windows Host](/docs/windows-host). *(Beta — newer than the Linux host.)*
@@ -55,8 +55,8 @@ see [Status & Progress](/docs/status).
- **Apple stage-2 presenter as the default.** The lower-latency `VTDecompressionSession`
`CAMetalLayer` path is live behind an opt-in flag and graduating to the default.
- **Web console parity.** Surfacing the speed test and bitrate picker the apps already have.
- **Windows host hardening.** Broader real-world testing, AMD/Intel encode (NVIDIA-only today), and
bundling the ViGEm gamepad driver.
- **Windows host hardening.** Broader real-world testing — especially on-glass validation of the
AMD (AMF) and Intel (QSV) encode paths, which are CI-green but newer than NVENC.
## 🔭 Planned
@@ -95,13 +95,14 @@ model Sunshine/Apollo use.
The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package
registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host
into `C:\Program Files\punktfunk`, optionally installs the bundled **SudoVDA** virtual-display driver,
and registers + starts the service for you (`/VERYSILENT` for unattended). Upgrades and uninstall are
into `C:\Program Files\punktfunk`, installs the bundled **pf-vdisplay** virtual-display driver, and
registers + starts the service for you (`/VERYSILENT` for unattended). Upgrades and uninstall are
handled through Add/Remove Programs.
Prefer the CLI? Run `punktfunk-host service install` from an elevated prompt — see
[Windows service](https://git.unom.io/unom/punktfunk/src/branch/main/docs/windows-service.md). Either
way you need an NVIDIA GPU + driver (the host is NVENC-only on Windows).
[Windows service](https://git.unom.io/unom/punktfunk/src/branch/main/docs/windows-service.md). For
hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or Intel (QSV); the host falls back to
software H.264 without one.
## Verifying
+1 -1
View File
@@ -14,7 +14,7 @@ A high-level view of where punktfunk stands. The ordered plan of work is on the
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
| **GameStream host** (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
| **Native protocol**`punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live |
| **Windows host** (NVIDIA, x64) | 🟡 implemented & shipping as a signed installer; NVIDIA-only, newer than the Linux host |
| **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host |
| **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass stage-2 presenter behind an opt-in flag, becoming the default |
| **Linux client** (`punktfunk-client`, GTK4/libadwaita) | ✅ full client; VAAPI zero-copy decode + software fallback |
| **Windows client** (`punktfunk-client`, WinUI 3) | ✅ stage 1 complete; ships as signed MSIX; on-glass hardware validation pending |
+86 -40
View File
@@ -1,45 +1,78 @@
---
title: "Windows Host"
description: "Run the punktfunk streaming host on a Windows PC — a first-class, virtual-display host."
description: "Run the punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
---
Set up a punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any punktfunk or
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
client's **exact resolution and refresh** via punktfunk's own **virtual display** — including
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
on the fly, so you need **no second monitor and no dummy HDMI plug**, and capture keeps working even on
the secure desktop (UAC prompts, the lock screen).
**Status: implemented and shipping — x64-only.** Alongside the Linux host, punktfunk runs as a
first-class native **Windows host**: a signed installer registers a `LocalSystem` service that streams
your Windows desktop or games to any punktfunk or Moonlight client, at the client's exact resolution
via a **virtual display** — including **HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR
mode. punktfunk has its own **indirect display driver (IDD)** that the host pushes finished frames
straight into, so you get a real on-the-fly virtual display with no physical monitor or dummy HDMI
plug — even on the secure desktop (UAC / lock screen). The Windows host is newer and less
battle-tested than the Linux host. (The Linux host is 8-bit only — HDR there is blocked upstream.)
> New to this? Skim [Requirements](/docs/requirements) first.
> This page is about the Windows **host** (streaming *from* a Windows PC). To stream *to* a Windows
> PC, see the [Windows client](/docs/clients#windows-desktop-client).
> This page is about the Windows **host** streaming *from* a Windows PC. To stream *to* a Windows PC,
> see the [Windows client](/docs/clients#windows-desktop-client).
## Requirements
- **Windows 10/11, x64.** ARM64 is not supported — both NVENC and the virtual-display driver are
x64-only.
- **An NVIDIA GPU + driver.** The host encodes with NVENC (`nvEncodeAPI64.dll`); there is no other
encoder backend on Windows.
- **(Optional) ViGEmBus** for virtual gamepads — a manual prerequisite for now
([releases](https://github.com/nefarius/ViGEmBus/releases)).
- **Windows 10 or 11, x64.** ARM64 is not built (no ARM64 NVIDIA driver, and the virtual-display
driver is x64-only).
- **A GPU for hardware encode** — the host auto-detects the vendor:
- **NVIDIA** → NVENC
- **AMD** → AMF
- **Intel** → QSV
No discrete GPU? The host falls back to a **software H.264** encoder (higher CPU use, lower quality —
fine for light desktop use).
- **No gamepad prerequisite.** The virtual gamepad drivers are bundled in the installer — there is
nothing else to download. (Earlier builds needed ViGEmBus; it is no longer used.)
## Install
Download the signed `punktfunk-host-setup-<ver>.exe` from the package registry and run it — it
installs the host into `C:\Program Files\punktfunk`, optionally installs the bundled **SudoVDA**
virtual-display driver, and registers + starts the service. Full steps (including the silent install
and the CLI `punktfunk-host service install` path) are in
[Running as a Service → Windows](/docs/running-as-a-service#windows); packaging internals live in
Download the signed `punktfunk-host-setup-<ver>.exe` from the
[package registry](https://git.unom.io/unom/-/packages) and run it. The installer:
- drops the host into `C:\Program Files\punktfunk` and registers + starts the **`PunktfunkHost`**
service,
- installs the bundled **virtual-display driver** (`pf-vdisplay`) so the host can create per-client
displays,
- installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360),
- registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display,
- sets up the **web management console** (see below).
For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove
Programs**; your config and pairings are kept across upgrades. Prefer the CLI, or want the full
service/firewall details? See [Running as a Service → Windows](/docs/running-as-a-service#windows).
Packaging internals live in
[`packaging/windows`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/windows/README.md).
### Web console & pairing
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own bun runtime and runs it as the **`PunktfunkWeb`** service
on **`http://<this-PC>:3000`**, starting at boot. During setup you choose the console **login
password** (pre-filled with a secure random default and shown again on the final page); change it
later in `%ProgramData%\punktfunk\web-password`. Open the console from any browser on the LAN and log
in — no extra install, and the host's management API stays loopback-only behind it.
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** service on
**`http://<this-PC>:3000`**, starting at boot. During setup you choose the console **login password**
(pre-filled with a secure random default and shown again on the final page); change it later in
`%ProgramData%\punktfunk\web-password`.
The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the
console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on
your [client](/docs/clients). The host's own management API stays loopback-only behind the console.
### Configure
The service reads `%ProgramData%\punktfunk\host.env`. The defaults work out of the box; common knobs:
- `PUNKTFUNK_ENCODER=auto``auto` picks NVENC/AMF/QSV by GPU vendor. Force one with `nvenc`, `amf`,
`qsv`, or `sw` (software).
- `PUNKTFUNK_HOST_CMD` — the service runs `serve --gamestream` by default (native punktfunk/1 **plus**
the GameStream/Moonlight-compat planes). Set it to `serve` for a **secure native-only** host with no
GameStream surface (GameStream pairs over plain HTTP and uses weaker legacy encryption — trusted LAN
only).
Edit the file, then restart: `punktfunk-host service stop` / `punktfunk-host service start`. See the
[Configuration reference](/docs/configuration) for every option.
## How it works
@@ -58,23 +91,36 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
| Subsystem | Linux backend | Windows backend |
|---|---|---|
| **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **Windows.Graphics.Capture** (+ Desktop Duplication for the secure desktop) → D3D11 texture; FP16/10-bit when the desktop is HDR |
| **Virtual display** | KWin / Mutter / Sway / gamescope | **SudoVDA** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down |
| **Encode** | `ffmpeg-next` NVENC (CUDA hwframes) | **NVENC** with a D3D11 device (`--features nvenc`); HEVC Main10 / BT.2020 PQ for HDR |
| **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **Windows.Graphics.Capture** + **Desktop Duplication** (secure desktop), with a zero-copy path straight from the virtual-display driver; FP16/10-bit when the desktop is HDR |
| **Virtual display** | KWin / Mutter / Sway / gamescope | **pf-vdisplay** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down |
| **Encode** | NVENC (CUDA) / VAAPI (AMD·Intel) / software | **NVENC** (NVIDIA) · **AMF** (AMD) · **QSV** (Intel) · software H.264; HEVC Main10 / BT.2020 PQ for HDR |
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
| **Input — gamepads** | uinput Xbox 360 pad + rumble | **ViGEm** virtual pad + rumble back-channel |
| **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
The virtual display uses **[SudoVDA](https://github.com/VirtualDrivers)** (the Sunshine Virtual
Display Adapter) — a pre-built, signed Indirect Display Driver — so there is **no kernel driver to
author or WHQL-sign**. The installer bundles and stages it; if it's absent, the host falls back to
capturing an existing monitor (losing the per-client native-resolution output).
The virtual display uses **pf-vdisplay**, punktfunk's own all-Rust **Indirect Display Driver (IDD)**
the host pushes finished frames straight into it, so you get a real virtual display with no physical
monitor or dummy plug. The installer bundles and stages the (self-signed) driver; if it isn't
installed, the host falls back to capturing an existing monitor, losing the per-client native-resolution
output.
## Limitations
### HDR
- **NVIDIA-only.** NVENC is the only encoder backend — there is no AMD / Intel / software encode path
on Windows.
- **x64-only.** No ARM64 build (no ARM64 NVIDIA driver, and SudoVDA is x64-only).
When your Windows desktop is in **HDR** mode, the host captures it as 10-bit, encodes **HEVC Main10 /
BT.2020 PQ**, and the client auto-detects HDR from the stream. A small always-on **Vulkan layer**
(bundled and registered by the installer) also lets **Vulkan games** enable HDR over the virtual
display — something the NVIDIA/AMD drivers otherwise refuse on an indirect display. The layer is
self-gating: it's a no-op on SDR and on real monitors. HDR is **Windows-only** (the Linux host is
8-bit, blocked upstream).
## Notes & limits
- **AMD / Intel encode is newer.** The NVENC path is the most exercised; AMF (AMD) and QSV (Intel) are
built and tested in CI but less battle-tested on real hardware. Software H.264 is the GPU-less
fallback.
- **x64-only.** No ARM64 build — no ARM64 NVIDIA driver, and the virtual-display driver is x64-only.
- **Newer than the Linux host.** The Linux host is the most battle-tested path; the Windows host is
more recent, with the virtual-mic and gamepad backends the youngest pieces.
more recent, with the virtual-mic and AMD/Intel encode backends the youngest pieces.
Trouble? See [Troubleshooting](/docs/troubleshooting) and [Pairing](/docs/pairing).
+7
View File
@@ -72,6 +72,8 @@ package_punktfunk-host() {
'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
'punktfunk-web: browser management console (device pairing + status)')
install=punktfunk-host.install
# User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits.
backup=('etc/gamescope-session-plus/sessions.d/steam')
local R; R="$(_repo)"; local T="$srcdir/target/release"
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
@@ -100,6 +102,11 @@ package_punktfunk-host() {
install -Dm0644 "$R/scripts/host.env.example" "$pkgdir/usr/share/punktfunk/host.env.example"
install -Dm0644 "$R/packaging/bazzite/host.env" "$pkgdir/usr/share/punktfunk/host.env.bazzite"
install -Dm0644 "$R/packaging/kde/host.env" "$pkgdir/usr/share/punktfunk/host.env.kde"
# Headless GAME-mode fix: gamescope-session-plus drop-in that uses the headless backend when no
# display is connected (so SteamOS/Bazzite "Switch to Game Mode" works on a display-less streaming
# host). No-op on display-attached boxes; sourced as /etc/gamescope-session-plus/sessions.d/steam.
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-APACHE"
@@ -0,0 +1,22 @@
# punktfunk: headless game-mode fallback for gamescope-session-plus.
#
# Installed as /etc/gamescope-session-plus/sessions.d/steam. The gamescope-session-plus launcher
# SOURCES this (shell, with `set -a` so assignments auto-export) AFTER its /usr/share defaults, so it
# can override the session's gamescope flags.
#
# Why: on a box with NO connected display (a dedicated streaming host), the stock Steam game mode runs
# gamescope's DRM backend against a physical panel (`--prefer-output *,eDP-1`). With nothing to scan
# out, gamescope crashes on launch; after 5 strikes Bazzite/SteamOS force-selects the desktop session
# and "Switch to Game Mode" appears broken. Falling back to gamescope's HEADLESS backend makes game
# mode render entirely offscreen and expose a PipeWire node, which the punktfunk host captures and
# streams — full gamescope game mode (per-game res / FSR / HDR / VRR / frame-limit), no monitor needed.
#
# Safe by construction:
# * NO-OP when any display is connected -> the normal DRM game mode runs unchanged.
# * Only sets values that are still unset (`: "${VAR:=...}"`), so the punktfunk host's per-client
# mode (SCREEN_WIDTH/SCREEN_HEIGHT injected via systemd-run for a managed session) still wins.
if ! grep -qx connected /sys/class/drm/*/status 2>/dev/null; then
: "${BACKEND:=headless}"
: "${SCREEN_WIDTH:=1920}"
: "${SCREEN_HEIGHT:=1080}"
fi
+21 -8
View File
@@ -20,12 +20,25 @@ PUNKTFUNK_ZEROCOPY=1
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput
#
# In Gaming Mode the host MANAGES a gamescope-session-plus at the CLIENT's resolution by default
# (tears the TV's autologin down on connect; restores it on a debounced idle, reused on a quick
# reconnect). To instead ATTACH to the running TV session at its own mode (couch-on-TV — gaming
# stays live on the panel, no Steam restart), set:
# PUNKTFUNK_GAMESCOPE_ATTACH=1
# PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui # only for an ad-hoc bare-spawn fallback
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
# host just ATTACHES to whatever's live and captures it; it never tears the session down or relaunches
# it. So switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
# mode — reconnecting drops you right back where you were. The streamed resolution in game mode is the
# box's gamescope mode (see SCREEN_WIDTH/HEIGHT in /etc/gamescope-session-plus/sessions.d/steam).
PUNKTFUNK_GAMESCOPE_ATTACH=1
#
# Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect):
# PUNKTFUNK_SESSION_WATCH=1
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
# its OWN at the CLIENT's exact resolution; restores on a debounced idle). Client-mode-following, but
# it does not coexist with a box-owned game-mode session — pick one:
# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (and remove PUNKTFUNK_GAMESCOPE_ATTACH above)
#
# Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect). This is
# ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it:
# PUNKTFUNK_SESSION_WATCH=0
#
# HEADLESS GAME MODE: on a box with no display attached, Bazzite's "Switch to Game Mode" normally
# crashes (gamescope's DRM backend has no panel to drive). The host package ships
# /etc/gamescope-session-plus/sessions.d/steam, which auto-falls-back to gamescope's HEADLESS backend
# when no display is connected — so game mode boots offscreen and streams, with no config here. It's a
# no-op on display-attached boxes. (The host then auto-detects Gaming and streams it.)
+10
View File
@@ -233,6 +233,13 @@ install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/%
# screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above).
install -d %{buildroot}%{_datadir}/%{name}/bazzite
install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh
# Headless GAME-mode fix: a gamescope-session-plus sessions.d drop-in that falls back to gamescope's
# headless backend when no display is connected (so "Switch to Game Mode" works on a display-less
# streaming host instead of crashing + 5-striking back to desktop). No-op on display-attached boxes.
# Sourced by gamescope-session-plus as /etc/gamescope-session-plus/sessions.d/steam (after its
# /usr/share defaults). Harmless on non-gamescope systems (the file is simply never read).
install -Dm0644 packaging/bazzite/gamescope-headless-session \
%{buildroot}/etc/gamescope-session-plus/sessions.d/steam
install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name}/openapi.json
%if %{with web}
@@ -262,6 +269,9 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%{_userunitdir}/punktfunk-host.service
%{_userunitdir}/punktfunk-kde-session.service
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
%dir /etc/gamescope-session-plus
%dir /etc/gamescope-session-plus/sessions.d
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
%dir %{_datadir}/%{name}
%{_datadir}/%{name}/*