diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index f6650d4f..fe266580 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -61,25 +61,28 @@ pub fn start( // Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux. crate::native::boost_thread_priority(true); tracing::info!(?cfg, "video stream starting"); - // Lifecycle events, plane parity with the native loop (RFC §4): the RTSP layer - // carries no client device name, so `client` is empty here — the `plane` field is - // what hooks key on. `client.connected` fires alongside `stream.started` because a - // Moonlight client has no persistent connection to anchor it to. - let event_stream = crate::events::StreamRef { - mode: crate::events::mode_str(cfg.width, cfg.height, cfg.fps), + // Lifecycle events + the script-facing marker file, plane parity with the native loop + // (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for + // the span between. It runs BEFORE `run` because `run` is what launches the app — the + // marker has to exist by the time the title's own wrapper script executes, or the + // wrapper takes its "not streaming" branch mid-stream. The RTSP layer carries no client + // device name, so `client` is empty here — the `plane` field is what hooks key on. + // `client.connected` fires alongside `stream.started` because a Moonlight client has no + // persistent connection to anchor it to. + let stream_marker = crate::stream_marker::announce(crate::stream_marker::StreamInfo { + width: cfg.width, + height: cfg.height, + refresh_hz: cfg.fps, hdr: cfg.hdr, client: String::new(), - app: app.as_ref().map(|a| a.title.clone()), + launch: app.as_ref().map(|a| a.title.clone()), plane: crate::events::Plane::Gamestream, - }; + }); let event_client = crate::events::ClientRef { name: String::new(), fingerprint: None, plane: crate::events::Plane::Gamestream, }; - crate::events::emit(crate::events::EventKind::StreamStarted { - stream: event_stream.clone(), - }); crate::events::emit(crate::events::EventKind::ClientConnected { client: event_client.clone(), }); @@ -103,9 +106,9 @@ pub fn start( tracing::error!(error = %format!("{e:#}"), "video stream failed"); } running.store(false, Ordering::SeqCst); - crate::events::emit(crate::events::EventKind::StreamStopped { - stream: event_stream, - }); + // Retract the marker and fire `stream.stopped` — explicitly here, before + // `client.disconnected`, so the compat plane keeps the native loop's event order. + drop(stream_marker); crate::events::emit(crate::events::EventKind::ClientDisconnected { client: event_client, reason, diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 74db285c..c29d5b25 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1173,6 +1173,7 @@ async fn serve_session( hdr: welcome.color.is_hdr(), client: hello.name.clone().unwrap_or_default(), launch: hello.launch.clone(), + plane: crate::events::Plane::Native, }); // The session's launch, threaded into the data plane. Windows carries the store-qualified id // (spawned into the interactive user session once capture is live); other hosts resolve the id diff --git a/crates/punktfunk-host/src/stream_marker.rs b/crates/punktfunk-host/src/stream_marker.rs index 49ef7a8e..bc95eb77 100644 --- a/crates/punktfunk-host/src/stream_marker.rs +++ b/crates/punktfunk-host/src/stream_marker.rs @@ -38,6 +38,11 @@ //! //! ## Semantics //! +//! Both serving planes announce — the native punktfunk/1 loop and the GameStream/Moonlight compat +//! plane — so a wrapper script behaves identically whichever client is connected. (Until 2026-07 +//! only the native loop did, so a Moonlight session left the marker absent and every wrapper took +//! its "not streaming" branch mid-stream.) +//! //! The marker reflects the **primary** session — the oldest still-live one — which is the single //! mode a single-user host is at. With several concurrent clients at different modes there is only //! one file, so `PF_STREAM_SESSIONS>1` is the script's cue that width/height/refresh describe just @@ -53,20 +58,25 @@ pub struct StreamInfo { pub hdr: bool, /// Client-supplied device name (may be empty); sanitized before it reaches the file. pub client: String, - /// Store-qualified launch id this session requested, if any — carried on the stream - /// lifecycle events, NOT written to the marker file (its key set is a stable contract). + /// The launch this session requested, if any (store-qualified id on the native plane, app + /// title on the compat plane) — carried on the stream lifecycle events, NOT written to the + /// marker file (its key set is a stable contract). pub launch: Option, + /// Which protocol plane serves this session. Carried on the lifecycle events — hooks filter + /// on it — and never written to the marker file: a launch wrapper branches on "is anything + /// streaming", not on how the pixels leave the box. + pub plane: crate::events::Plane, } /// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites -/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane. +/// (RFC §4), for every plane that announces. fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef { crate::events::StreamRef { mode: crate::events::mode_str(info.width, info.height, info.refresh_hz), hdr: info.hdr, client: info.client.clone(), app: info.launch.clone(), - plane: crate::events::Plane::Native, + plane: info.plane, } } @@ -271,6 +281,7 @@ mod imp { hdr: true, client: "Couch'TV".to_string(), launch: None, + plane: crate::events::Plane::Native, }); rewrite_to(&path, ®); let text = std::fs::read_to_string(&path).expect("marker exists while streaming"); @@ -290,6 +301,7 @@ mod imp { hdr: false, client: "Phone".to_string(), launch: None, + plane: crate::events::Plane::Gamestream, }); rewrite_to(&path, ®); let text = std::fs::read_to_string(&path).unwrap(); @@ -311,3 +323,35 @@ mod imp { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::Plane; + + /// The announcing plane must reach the lifecycle events. It was hardcoded to `Native` while the + /// native loop was the only announcer, so every compat-plane session would report as native to + /// a hook filtering on it. + #[test] + fn stream_ref_carries_the_announcing_plane() { + let info = StreamInfo { + width: 1920, + height: 1080, + refresh_hz: 60, + hdr: false, + client: String::new(), + launch: Some("Hades".to_string()), + plane: Plane::Gamestream, + }; + let r = stream_ref(&info); + assert_eq!(r.plane, Plane::Gamestream); + assert_eq!(r.mode, "1920x1080@60"); + assert_eq!(r.app.as_deref(), Some("Hades")); + + let native = stream_ref(&StreamInfo { + plane: Plane::Native, + ..info + }); + assert_eq!(native.plane, Plane::Native); + } +}