Compare commits
8 Commits
a4833e4780
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3947d5b07a | |||
| 238501597e | |||
| 04dd3e3a19 | |||
| 61aa1053e7 | |||
| 50e17b3508 | |||
| 94c556f0e3 | |||
| 32c1929948 | |||
| 3915a82780 |
@@ -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")?;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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.)
|
||||
|
||||
@@ -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}/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user