cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

1-n-sigls
goq / crates / sigil-host / src / source / mod.rs
mod.rs 41.3K
1use std::borrow::Cow;2use std::process::{ExitStatus, Stdio};3use std::sync::Arc;4use std::time::{Duration, Instant};56use anyhow::{Context, Result, bail, ensure};7use serde_json::Value;8use sigil_protocol::PointerSurfaceDimensions;9use tokio::io::{AsyncRead, AsyncReadExt};10use tokio::process::Command;11use tokio::sync::{oneshot, watch};12use tracing::warn;1314use crate::clock::SessionClock;15#[cfg(test)]16use crate::config::VaapiRateControl;17use crate::config::{GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig};1819mod annexb;20mod external;21mod gstreamer_properties;22mod in_process;23mod preflight;2425#[cfg(test)]26use external::gamescope_pipeline_args;27use external::{28 forward_annex_b_stream, spawn_gamescope_pipewire_with_target,29 spawn_gamescope_pipewire_with_target_and_shutdown,30};31pub(crate) use gstreamer_properties::probe_encoder_properties;32#[cfg(test)]33pub(crate) use in_process::EncoderControlTestHarness;34#[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]35use in_process::spawn_gamescope_pipewire_in_process_with_target;36pub use in_process::{EncoderControl, EncoderControlStatus};37use preflight::preflight_gamescope_static;38pub(crate) use preflight::{preflight_gstreamer_element, validate_executable};3940const MAX_ENCODE_BUFFER: usize = 16 * 1024 * 1024;41const MAX_CURRENT_GOP_BYTES: usize = 32 * 1024 * 1024;42const MAX_PW_DUMP_OUTPUT: usize = 8 * 1024 * 1024;43const MAX_DIAGNOSTIC_OUTPUT: usize = 512 * 1024;44const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5);4546#[derive(Clone, Debug)]47pub struct EncodedFrame {48 pub sequence: u64,49 pub width: u16,50 pub height: u16,51 /// Session-monotonic timestamp when the complete encoded access unit became52 /// observable to the daemon, using the same epoch as audio. Raw PipeWire53 /// capture PTS is not preserved by the current stdout bridge, so this must54 /// not be presented as capture age.55 pub capture_timestamp_micros: u64,56 /// Post-encode observation PTS in the shared session clock. This preserves57 /// real gaps in damage-driven video output.58 pub presentation_timestamp_micros: i64,59 pub observed_at: Instant,60 pub keyframe: bool,61 pub codec_config: bool,62 /// This configured keyframe begins a decoder generation that must not use63 /// reference state from the preceding encoded resolution.64 pub discontinuity: bool,65 pub data: Arc<[u8]>,66}6768#[derive(Clone, Debug)]69pub(crate) struct EncodedGop {70 pub frames: Vec<EncodedFrame>,71 pub(crate) payload_bytes: usize,72}7374pub struct EncodedSource {75 pub frames: watch::Receiver<Option<EncodedFrame>>,76 /// A bounded snapshot of every access unit from the latest IDR through the77 /// current frame. A new subscriber can therefore reconstruct the current78 /// static image without receiving an orphan inter-frame.79 pub(crate) current_gop: watch::Receiver<Option<EncodedGop>>,80 pub task: tokio::task::JoinHandle<Result<()>>,81 pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,82 pub encoder_control: Option<EncoderControl>,83}8485pub fn spawn_test_pattern(config: HostConfig, session_clock: SessionClock) -> EncodedSource {86 let (sender, receiver) = watch::channel(None);87 let (current_gop_sender, current_gop) = watch::channel(None);88 let task = tokio::spawn(async move {89 run_test_pattern(config, session_clock, sender, current_gop_sender).await90 });91 EncodedSource {92 frames: receiver,93 current_gop,94 task,95 pointer_surface_dimensions: None,96 encoder_control: None,97 }98}99100#[derive(Clone, Debug, PartialEq, Eq)]101pub struct GamescopePreflight {102 /// PipeWire object.serial, resolved from exact configured properties.103 pub target_object: String,104 /// Native Gamescope surface used by the relative-pointer coordinate space.105 pub pointer_surface_dimensions: PointerSurfaceDimensions,106 /// Bounded encoded mode after applying an optional explicit size override.107 pub video_mode: CaptureVideoMode,108}109110#[derive(Clone, Copy, Debug, PartialEq, Eq)]111pub struct CaptureVideoMode {112 pub width: u16,113 pub height: u16,114 pub framerate: u32,115}116117impl CaptureVideoMode {118 fn resolve(config: &HostConfig, native: PointerSurfaceDimensions) -> Result<Self> {119 let configured = config.configured_dimensions();120 let (width, height) =121 configured.unwrap_or((u32::from(native.width), u32::from(native.height)));122 ensure!(123 (64..=7_680).contains(&width) && (64..=4_320).contains(&height),124 "resolved Gamescope encoded dimensions {width}x{height} are outside H.264 bounds"125 );126 ensure!(127 width.is_multiple_of(2) && height.is_multiple_of(2),128 "resolved Gamescope encoded dimensions {width}x{height} must be even"129 );130 if configured.is_some() {131 ensure!(132 u64::from(width) * u64::from(native.height)133 == u64::from(height) * u64::from(native.width),134 "configured Gamescope encoded dimensions {width}x{height} must preserve native {}x{} aspect ratio",135 native.width,136 native.height137 );138 }139 Ok(Self {140 width: u16::try_from(width).context("resolved Gamescope width exceeds metadata")?,141 height: u16::try_from(height).context("resolved Gamescope height exceeds metadata")?,142 framerate: config.framerate,143 })144 }145}146147pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<GamescopePreflight> {148 config.validate()?;149 preflight_gamescope_static(config).await?;150 resolve_gamescope_pipewire_target(config).await151}152153async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<GamescopePreflight> {154 config.validate()?;155 let pipewire = gamescope_config(config)?;156 let mut command = Command::new(&pipewire.pw_dump_path);157 command.env("LC_ALL", "C");158 let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;159 ensure!(160 output.status.success(),161 "pw-dump failed: {}",162 diagnostic(&output.stderr)163 );164 resolve_pipewire_node(&output.stdout, config)165}166167pub struct ProbeEncodedSource {168 pub source: EncodedSource,169 pub shutdown: Option<oneshot::Sender<()>>,170}171172pub fn spawn_gamescope_pipewire_for_probe(173 config: HostConfig,174 session_clock: SessionClock,175 preflight: GamescopePreflight,176) -> Result<ProbeEncodedSource> {177 match gamescope_config(&config)?.encoder_backend {178 GamescopeEncoderBackend::ExternalGstLaunch => {179 let (shutdown, shutdown_receiver) = oneshot::channel();180 let source = spawn_gamescope_pipewire_with_target_and_shutdown(181 config,182 session_clock,183 preflight,184 Some(shutdown_receiver),185 )?;186 Ok(ProbeEncodedSource {187 source,188 shutdown: Some(shutdown),189 })190 }191 GamescopeEncoderBackend::InProcessGstreamer => {192 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]193 {194 let source = spawn_gamescope_pipewire_in_process_with_target(195 config,196 session_clock,197 preflight,198 )?;199 Ok(ProbeEncodedSource {200 source,201 shutdown: None,202 })203 }204 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]205 {206 let _ = (session_clock, preflight);207 bail!(208 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"209 )210 }211 }212 }213}214215/// Start capture after `serve` has completed the static executable/GstVA216/// preflight. The PipeWire node is still resolved immediately before each217/// session because its object serial can change when Gamescope restarts.218pub async fn spawn_gamescope_pipewire_after_static_preflight(219 config: HostConfig,220 session_clock: SessionClock,221) -> Result<EncodedSource> {222 let preflight = resolve_gamescope_pipewire_target(&config).await?;223 spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)224}225226fn spawn_gamescope_pipewire_with_preflight(227 config: HostConfig,228 session_clock: SessionClock,229 preflight: GamescopePreflight,230) -> Result<EncodedSource> {231 match gamescope_config(&config)?.encoder_backend {232 GamescopeEncoderBackend::ExternalGstLaunch => {233 spawn_gamescope_pipewire_with_target(config, session_clock, preflight)234 }235 GamescopeEncoderBackend::InProcessGstreamer => {236 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]237 {238 spawn_gamescope_pipewire_in_process_with_target(config, session_clock, preflight)239 }240 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]241 {242 let _ = (session_clock, preflight);243 bail!(244 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"245 )246 }247 }248 }249}250251async fn run_test_pattern(252 config: HostConfig,253 session_clock: SessionClock,254 sender: watch::Sender<Option<EncodedFrame>>,255 current_gop_sender: watch::Sender<Option<EncodedGop>>,256) -> Result<()> {257 let (width, height) = config.test_pattern_dimensions()?;258 let input = format!(259 "testsrc2=size={}x{}:rate={}",260 width, height, config.framerate261 );262 let gop = interactive_gop_frames(config.framerate).to_string();263264 let mut command = Command::new(&config.ffmpeg_path);265 command266 .arg("-hide_banner")267 .arg("-loglevel")268 .arg("warning")269 .arg("-re")270 .arg("-f")271 .arg("lavfi")272 .arg("-i")273 .arg(input)274 .arg("-an")275 .arg("-c:v")276 .arg("libx264")277 .arg("-preset")278 .arg("ultrafast")279 .arg("-tune")280 .arg("zerolatency")281 .arg("-g")282 .arg(&gop)283 .arg("-keyint_min")284 .arg(&gop)285 .arg("-sc_threshold")286 .arg("0")287 .arg("-bf")288 .arg("0")289 .arg("-pix_fmt")290 .arg("yuv420p")291 .arg("-bsf:v")292 .arg("h264_metadata=aud=insert")293 .arg("-f")294 .arg("h264")295 .arg("pipe:1")296 .stdin(Stdio::null())297 .stdout(Stdio::piped())298 .stderr(Stdio::piped())299 .kill_on_drop(true);300301 let mut child = command302 .spawn()303 .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;304 let mut stdout = child.stdout.take().context("ffmpeg stdout was not piped")?;305 let stderr = child.stderr.take().context("ffmpeg stderr was not piped")?;306 let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "ffmpeg"));307308 let result = forward_annex_b_stream(309 session_clock,310 &mut stdout,311 &sender,312 &current_gop_sender,313 interactive_gop_frames(config.framerate) as usize,314 u16::try_from(width).context("configured width exceeds encoder metadata")?,315 u16::try_from(height).context("configured height exceeds encoder metadata")?,316 )317 .await;318 let _ = child.kill().await;319 let status = child.wait().await.context("waiting for ffmpeg")?;320 stderr_task.abort();321 match result {322 Ok(()) => Ok(()),323 Err(error) => Err(error).with_context(|| format!("ffmpeg exited with {status}")),324 }325}326327struct AccessUnitPublisher<'a> {328 session_clock: SessionClock,329 sender: &'a watch::Sender<Option<EncodedFrame>>,330 current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,331 max_gop_frames: usize,332 width: u16,333 height: u16,334 sequence: u64,335}336337impl<'a> AccessUnitPublisher<'a> {338 fn new(339 session_clock: SessionClock,340 sender: &'a watch::Sender<Option<EncodedFrame>>,341 current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,342 max_gop_frames: usize,343 width: u16,344 height: u16,345 ) -> Self {346 Self {347 session_clock,348 sender,349 current_gop_sender,350 max_gop_frames,351 width,352 height,353 sequence: 0,354 }355 }356357 fn publish(&mut self, access_unit: Vec<u8>) -> Result<bool> {358 self.publish_with_metadata(access_unit, self.width, self.height, false)359 }360361 fn publish_with_metadata(362 &mut self,363 access_unit: Vec<u8>,364 width: u16,365 height: u16,366 discontinuity: bool,367 ) -> Result<bool> {368 ensure!(!access_unit.is_empty(), "encoded access unit is empty");369 ensure!(370 access_unit.len() <= MAX_ENCODE_BUFFER,371 "encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"372 );373 let observed_at = Instant::now();374 let capture_timestamp_micros = self.session_clock.micros_at(observed_at);375 let presentation_timestamp_micros =376 i64::try_from(capture_timestamp_micros).unwrap_or(i64::MAX);377 let keyframe = is_h264_keyframe(&access_unit);378 let codec_config = has_h264_codec_config(&access_unit);379 let frame = EncodedFrame {380 sequence: self.sequence,381 width,382 height,383 capture_timestamp_micros,384 presentation_timestamp_micros,385 observed_at,386 keyframe,387 codec_config,388 discontinuity,389 data: Arc::from(access_unit),390 };391 self.sequence = self392 .sequence393 .checked_add(1)394 .context("encoded frame sequence exhausted")?;395 publish_encoded_frame(396 self.sender,397 self.current_gop_sender,398 frame,399 self.max_gop_frames,400 MAX_CURRENT_GOP_BYTES,401 );402 self.width = width;403 self.height = height;404 Ok(keyframe && codec_config)405 }406407 fn receivers_closed(&self) -> bool {408 self.sender.is_closed() && self.current_gop_sender.is_closed()409 }410}411412fn publish_encoded_frame(413 sender: &watch::Sender<Option<EncodedFrame>>,414 current_gop_sender: &watch::Sender<Option<EncodedGop>>,415 frame: EncodedFrame,416 max_gop_frames: usize,417 max_gop_bytes: usize,418) {419 if !current_gop_sender.is_closed() {420 current_gop_sender.send_if_modified(|current_gop| {421 update_current_gop(current_gop, frame.clone(), max_gop_frames, max_gop_bytes)422 });423 }424 sender.send_replace(Some(frame.clone()));425}426427fn update_current_gop(428 current_gop: &mut Option<EncodedGop>,429 frame: EncodedFrame,430 max_gop_frames: usize,431 max_gop_bytes: usize,432) -> bool {433 let independently_decodable = frame.keyframe && frame.codec_config;434 if independently_decodable {435 let payload_bytes = frame.data.len();436 if max_gop_frames == 0 || payload_bytes > max_gop_bytes {437 return current_gop.take().is_some();438 }439 *current_gop = Some(EncodedGop {440 frames: vec![frame],441 payload_bytes,442 });443 return true;444 }445446 let Some(gop) = current_gop.as_mut() else {447 return false;448 };449 let contiguous = gop450 .frames451 .last()452 .and_then(|previous| previous.sequence.checked_add(1))453 == Some(frame.sequence);454 let payload_bytes = gop.payload_bytes.saturating_add(frame.data.len());455 if !contiguous || gop.frames.len() >= max_gop_frames || payload_bytes > max_gop_bytes {456 *current_gop = None;457 return true;458 }459460 gop.frames.push(frame);461 gop.payload_bytes = payload_bytes;462 true463}464465pub(crate) async fn log_stderr_chunks<R>(mut stderr: R, target: &'static str)466where467 R: AsyncRead + Unpin,468{469 const LOG_INTERVAL: Duration = Duration::from_secs(1);470 const MAX_LOG_BYTES_PER_INTERVAL: usize = 16 * 1024;471472 let mut chunk = [0_u8; 4096];473 let mut interval_started = tokio::time::Instant::now();474 let mut logged_bytes = 0_usize;475 let mut suppressed_bytes = 0_u64;476 loop {477 match stderr.read(&mut chunk).await {478 Ok(0) => return,479 Ok(count) => {480 if interval_started.elapsed() >= LOG_INTERVAL {481 if suppressed_bytes > 0 {482 warn!(483 target: "capture-process",484 process = target,485 suppressed_bytes,486 "capture diagnostics rate limited"487 );488 }489 interval_started = tokio::time::Instant::now();490 logged_bytes = 0;491 suppressed_bytes = 0;492 }493 if logged_bytes.saturating_add(count) <= MAX_LOG_BYTES_PER_INTERVAL {494 warn!(target: "capture-process", process = target, "{}", String::from_utf8_lossy(&chunk[..count]).trim_end());495 logged_bytes = logged_bytes.saturating_add(count);496 } else {497 suppressed_bytes = suppressed_bytes.saturating_add(count as u64);498 }499 }500 Err(error) => {501 warn!(target: "capture-process", process = target, %error, "failed reading stderr");502 return;503 }504 }505 }506}507508fn gamescope_config(config: &HostConfig) -> Result<&GamescopePipewireConfig> {509 config510 .gamescope_pipewire511 .as_ref()512 .context("gamescope_pipewire configuration is missing")513}514515fn interactive_gop_frames(framerate: u32) -> u32 {516 // A dropped encoded reference requires the client to wait for the next517 // independently decodable IDR. Bound that recovery to roughly 500 ms until518 // the in-process encoder can service explicit keyframe requests.519 framerate.div_ceil(2).max(1)520}521522pub(crate) async fn resolve_pipewire_node_by_properties(523 pw_dump_path: &std::path::Path,524 node_name: &str,525 media_class: &str,526 match_properties: &std::collections::BTreeMap<String, String>,527) -> Result<String> {528 let mut command = Command::new(pw_dump_path);529 command.env("LC_ALL", "C");530 let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;531 ensure!(532 output.status.success(),533 "pw-dump failed: {}",534 diagnostic(&output.stderr)535 );536 resolve_pipewire_node_exact(&output.stdout, node_name, media_class, match_properties)537}538539fn resolve_pipewire_node(output: &[u8], config: &HostConfig) -> Result<GamescopePreflight> {540 let pipewire = gamescope_config(config)?;541 let target_object = resolve_pipewire_node_exact(542 output,543 &pipewire.node_name,544 &pipewire.media_class,545 &pipewire.match_properties,546 )?;547 let pointer_surface_dimensions = resolve_pointer_surface_dimensions(548 output,549 &target_object,550 &pipewire.node_name,551 &pipewire.media_class,552 &pipewire.match_properties,553 )?;554 let video_mode = CaptureVideoMode::resolve(config, pointer_surface_dimensions)?;555 Ok(GamescopePreflight {556 target_object,557 pointer_surface_dimensions,558 video_mode,559 })560}561562pub(crate) fn resolve_pipewire_node_exact(563 output: &[u8],564 node_name: &str,565 media_class: &str,566 match_properties: &std::collections::BTreeMap<String, String>,567) -> Result<String> {568 let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;569 let objects = objects570 .as_array()571 .context("pw-dump output is not an array")?;572 let mut matches = Vec::new();573574 for object in objects {575 if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {576 continue;577 }578 let Some(properties) = object579 .get("info")580 .and_then(|info| info.get("props"))581 .and_then(Value::as_object)582 else {583 continue;584 };585 if !pipewire_node_matches(properties, node_name, media_class, match_properties) {586 continue;587 }588 let serial = property_string(properties.get("object.serial"))589 .context("matching PipeWire node has no object.serial")?;590 ensure!(591 !serial.is_empty() && serial.bytes().all(|byte| byte.is_ascii_digit()),592 "matching PipeWire node has invalid object.serial"593 );594 matches.push(serial.into_owned());595 }596597 ensure!(598 matches.len() == 1,599 "expected exactly one PipeWire node matching node.name={:?}, media.class={:?}, and configured properties; found {}",600 node_name,601 media_class,602 matches.len()603 );604 Ok(matches.remove(0))605}606607fn resolve_pointer_surface_dimensions(608 output: &[u8],609 target_object: &str,610 node_name: &str,611 media_class: &str,612 match_properties: &std::collections::BTreeMap<String, String>,613) -> Result<PointerSurfaceDimensions> {614 let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;615 let objects = objects616 .as_array()617 .context("pw-dump output is not an array")?;618 let mut matched_node_count = 0_usize;619 let mut native_size = None;620621 for object in objects {622 if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {623 continue;624 }625 let Some(info) = object.get("info").and_then(Value::as_object) else {626 continue;627 };628 let Some(properties) = info.get("props").and_then(Value::as_object) else {629 continue;630 };631 if !pipewire_node_matches(properties, node_name, media_class, match_properties)632 || property_string(properties.get("object.serial")).as_deref() != Some(target_object)633 {634 continue;635 }636 matched_node_count += 1;637 let formats = info638 .get("params")639 .and_then(|params| params.get("EnumFormat"))640 .and_then(Value::as_array)641 .context("matching Gamescope PipeWire node has no EnumFormat array")?;642 for format in formats {643 if format.get("mediaType").and_then(Value::as_str) != Some("video")644 || format.get("mediaSubtype").and_then(Value::as_str) != Some("raw")645 {646 continue;647 }648 let size = format649 .get("size")650 .context("Gamescope raw EnumFormat has no native size")?;651 let size = size.get("default").unwrap_or(size);652 let width = size653 .get("width")654 .and_then(Value::as_u64)655 .and_then(|value| u16::try_from(value).ok())656 .context("Gamescope raw EnumFormat native width is not a u16")?;657 let height = size658 .get("height")659 .and_then(Value::as_u64)660 .and_then(|value| u16::try_from(value).ok())661 .context("Gamescope raw EnumFormat native height is not a u16")?;662 let dimensions = PointerSurfaceDimensions::new(width, height)663 .context("Gamescope raw EnumFormat native size is outside pointer bounds")?;664 if native_size.is_some_and(|existing| existing != dimensions) {665 bail!("matching Gamescope PipeWire node advertises ambiguous native sizes");666 }667 native_size = Some(dimensions);668 }669 }670671 ensure!(672 matched_node_count == 1,673 "expected exactly one matched Gamescope PipeWire node while resolving its native size; found {matched_node_count}"674 );675 native_size.context("matching Gamescope PipeWire node has no raw native EnumFormat size")676}677678fn pipewire_node_matches(679 properties: &serde_json::Map<String, Value>,680 node_name: &str,681 media_class: &str,682 match_properties: &std::collections::BTreeMap<String, String>,683) -> bool {684 property_string(properties.get("node.name")).as_deref() == Some(node_name)685 && property_string(properties.get("media.class")).as_deref() == Some(media_class)686 && match_properties.iter().all(|(key, expected)| {687 property_string(properties.get(key)).is_some_and(|actual| actual == *expected)688 })689}690691fn property_string(value: Option<&Value>) -> Option<Cow<'_, str>> {692 match value? {693 Value::String(value) => Some(Cow::Borrowed(value)),694 Value::Number(value) => Some(Cow::Owned(value.to_string())),695 _ => None,696 }697}698699struct BoundedOutput {700 status: ExitStatus,701 stdout: Vec<u8>,702 stderr: Vec<u8>,703}704705async fn run_bounded_command(mut command: Command, stdout_limit: usize) -> Result<BoundedOutput> {706 command707 .stdin(Stdio::null())708 .stdout(Stdio::piped())709 .stderr(Stdio::piped())710 .kill_on_drop(true);711 let program = command.as_std().get_program().to_owned();712 let mut child = command713 .spawn()714 .with_context(|| format!("starting {}", program.to_string_lossy()))?;715 let stdout = child.stdout.take().context("child stdout was not piped")?;716 let stderr = child.stderr.take().context("child stderr was not piped")?;717718 let collect = async {719 let (stdout, stderr) = tokio::join!(720 read_bounded(stdout, stdout_limit),721 read_bounded(stderr, MAX_DIAGNOSTIC_OUTPUT)722 );723 let status = child724 .wait()725 .await726 .context("waiting for preflight command")?;727 Ok::<_, anyhow::Error>(BoundedOutput {728 status,729 stdout: stdout?,730 stderr: stderr?,731 })732 };733 match tokio::time::timeout(PREFLIGHT_TIMEOUT, collect).await {734 Ok(result) => result,735 Err(_) => {736 let _ = child.kill().await;737 let _ = child.wait().await;738 bail!(739 "preflight command {} exceeded {} seconds",740 program.to_string_lossy(),741 PREFLIGHT_TIMEOUT.as_secs()742 )743 }744 }745}746747async fn read_bounded<R>(reader: R, limit: usize) -> Result<Vec<u8>>748where749 R: AsyncRead + Unpin,750{751 let mut bytes = Vec::new();752 reader753 .take(limit.saturating_add(1) as u64)754 .read_to_end(&mut bytes)755 .await756 .context("reading bounded command output")?;757 ensure!(758 bytes.len() <= limit,759 "command output exceeded {limit} bytes"760 );761 Ok(bytes)762}763764fn diagnostic(bytes: &[u8]) -> String {765 String::from_utf8_lossy(bytes).trim().to_owned()766}767768fn is_h264_keyframe(data: &[u8]) -> bool {769 h264_nal_types(data).any(|nal_type| nal_type == 5)770}771772fn has_h264_codec_config(data: &[u8]) -> bool {773 let mut has_sps = false;774 let mut has_pps = false;775 for nal_type in h264_nal_types(data) {776 has_sps |= nal_type == 7;777 has_pps |= nal_type == 8;778 }779 has_sps && has_pps780}781782fn h264_nal_types(data: &[u8]) -> impl Iterator<Item = u8> + '_ {783 let mut index = 0;784 std::iter::from_fn(move || {785 while index + 4 <= data.len() {786 let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {787 Some(index + 4)788 } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {789 Some(index + 3)790 } else {791 None792 };793 index += 1;794 if let Some(nal_offset) = nal_offset795 && let Some(byte) = data.get(nal_offset)796 {797 return Some(byte & 0x1f);798 }799 }800 None801 })802}803804#[cfg(test)]805mod tests {806 use super::*;807 use crate::config::{GamescopeEncoderBackend, InputMode, VideoSource};808 use std::collections::BTreeMap;809 use std::path::PathBuf;810811 pub(super) fn gamescope_config() -> HostConfig {812 HostConfig {813 identity_path: PathBuf::from("/tmp/host.key"),814 state_path: PathBuf::from("/tmp/state"),815 max_viewers: crate::config::DEFAULT_MAX_VIEWERS,816 focus_owner: None,817 source: VideoSource::GamescopePipewire,818 width: Some(1280),819 height: Some(800),820 framerate: 60,821 codec: "h264".into(),822 input_mode: InputMode::Disabled,823 uinput: None,824 ffmpeg_path: PathBuf::from("/usr/bin/ffmpeg"),825 gamescope_pipewire: Some(GamescopePipewireConfig {826 encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,827 xwayland_display: None,828 node_name: "gamescope".into(),829 media_class: "Video/Source".into(),830 match_properties: BTreeMap::from([(831 "device.bus-path".into(),832 "pci-0000:04:00.0".into(),833 )]),834 pw_dump_path: PathBuf::from("/usr/bin/pw-dump"),835 gst_launch_path: PathBuf::from("/usr/bin/gst-launch-1.0"),836 gst_inspect_path: PathBuf::from("/usr/bin/gst-inspect-1.0"),837 vaapi_encoder: "vah264enc".into(),838 vaapi_render_node: PathBuf::from("/dev/dri/renderD128"),839 rate_control: VaapiRateControl::Cbr,840 bitrate_kbps: Some(12_000),841 quantizer: None,842 }),843 audio: None,844 }845 }846847 pub(super) fn configured_video_mode() -> CaptureVideoMode {848 CaptureVideoMode {849 width: 1_280,850 height: 800,851 framerate: 60,852 }853 }854855 fn gamescope_dump(serial: u64, width: u16, height: u16) -> Vec<u8> {856 serde_json::to_vec(&serde_json::json!([{857 "type": "PipeWire:Interface:Node",858 "info": {859 "props": {860 "node.name": "gamescope",861 "media.class": "Video/Source",862 "device.bus-path": "pci-0000:04:00.0",863 "object.serial": serial,864 },865 "params": {866 "EnumFormat": [{867 "mediaType": "video",868 "mediaSubtype": "raw",869 "format": "NV12",870 "size": { "width": width, "height": height },871 }],872 },873 },874 }]))875 .unwrap()876 }877878 #[test]879 fn detects_h264_keyframes() {880 assert!(is_h264_keyframe(&[0, 0, 0, 1, 5, 1, 2]));881 assert!(!is_h264_keyframe(&[0, 0, 1, 7, 1, 2]));882 assert!(!is_h264_keyframe(&[0, 0, 0, 1, 1, 1, 2]));883 }884885 #[test]886 fn codec_config_requires_sps_and_pps() {887 assert!(has_h264_codec_config(&[888 0, 0, 0, 1, 7, 1, 2, 0, 0, 1, 8, 3, 4889 ]));890 assert!(!has_h264_codec_config(&[0, 0, 1, 7, 1, 2]));891 assert!(!has_h264_codec_config(&[0, 0, 1, 8, 1, 2]));892 }893894 #[test]895 fn publisher_carries_exact_resolution_and_discontinuity_into_new_gop() {896 let (frame_sender, frame_receiver) = watch::channel(None);897 let (current_gop_sender, current_gop_receiver) = watch::channel(None);898 let mut publisher = AccessUnitPublisher::new(899 SessionClock::start(),900 &frame_sender,901 &current_gop_sender,902 30,903 1280,904 800,905 );906 let configured_idr = vec![0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1, 0, 0, 0, 1, 5, 1];907908 assert!(909 publisher910 .publish_with_metadata(configured_idr, 960, 600, true)911 .unwrap()912 );913 let frame = frame_receiver.borrow().as_ref().unwrap().clone();914 assert_eq!((frame.width, frame.height), (960, 600));915 assert!(frame.discontinuity);916 let current_gop = current_gop_receiver.borrow();917 let first = &current_gop.as_ref().unwrap().frames[0];918 assert_eq!((first.width, first.height), (960, 600));919 assert!(first.keyframe && first.codec_config && first.discontinuity);920 }921922 #[test]923 fn current_gop_retains_only_a_bounded_contiguous_decodable_chain() {924 let (frame_sender, frame_receiver) = watch::channel(None);925 let (current_gop_sender, current_gop_receiver) = watch::channel(None);926 let frame = |sequence, keyframe, codec_config, payload_len| EncodedFrame {927 sequence,928 width: 1280,929 height: 800,930 capture_timestamp_micros: sequence,931 presentation_timestamp_micros: sequence as i64,932 observed_at: Instant::now(),933 keyframe,934 codec_config,935 discontinuity: false,936 data: Arc::from(vec![sequence as u8; payload_len]),937 };938939 for encoded in [940 frame(10, true, true, 1),941 frame(11, false, false, 1),942 frame(12, false, false, 1),943 ] {944 publish_encoded_frame(&frame_sender, &current_gop_sender, encoded, 4, 8);945 }946947 assert_eq!(frame_receiver.borrow().as_ref().unwrap().sequence, 12);948 assert_eq!(949 current_gop_receiver950 .borrow()951 .as_ref()952 .unwrap()953 .frames954 .iter()955 .map(|frame| frame.sequence)956 .collect::<Vec<_>>(),957 vec![10, 11, 12]958 );959960 publish_encoded_frame(961 &frame_sender,962 &current_gop_sender,963 frame(40, true, true, 1),964 4,965 8,966 );967 assert_eq!(968 current_gop_receiver.borrow().as_ref().unwrap().frames[0].sequence,969 40970 );971972 publish_encoded_frame(973 &frame_sender,974 &current_gop_sender,975 frame(42, false, false, 1),976 4,977 8,978 );979 assert!(current_gop_receiver.borrow().is_none());980981 publish_encoded_frame(982 &frame_sender,983 &current_gop_sender,984 frame(50, true, true, 3),985 4,986 4,987 );988 publish_encoded_frame(989 &frame_sender,990 &current_gop_sender,991 frame(51, false, false, 2),992 4,993 4,994 );995 assert!(current_gop_receiver.borrow().is_none());996997 for encoded in [998 frame(60, true, true, 1),999 frame(61, false, false, 1),1000 frame(62, false, false, 1),1001 ] {1002 publish_encoded_frame(&frame_sender, &current_gop_sender, encoded, 2, 8);1003 }1004 assert!(current_gop_receiver.borrow().is_none());1005 }10061007 #[test]1008 fn recovery_gop_is_bounded_to_half_a_second() {1009 assert_eq!(interactive_gop_frames(60), 30);1010 assert_eq!(interactive_gop_frames(59), 30);1011 assert_eq!(interactive_gop_frames(1), 1);1012 }10131014 #[test]1015 fn resolves_exactly_one_pipewire_node_by_properties() {1016 let config = gamescope_config();1017 let dump = br#"[1018 {"type":"PipeWire:Interface:Node","info":{"props":{1019 "node.name":"gamescope","media.class":"Video/Source",1020 "device.bus-path":"pci-0000:04:00.0","object.serial":7311021 },"params":{"EnumFormat":[1022 {"mediaType":"video","mediaSubtype":"raw","format":"BGRx",1023 "size":{"width":2560,"height":1600}},1024 {"mediaType":"video","mediaSubtype":"raw","format":"NV12",1025 "size":{"width":2560,"height":1600}}1026 ]}}},1027 {"type":"PipeWire:Interface:Node","info":{"props":{1028 "node.name":"gamescope","media.class":"Audio/Source",1029 "device.bus-path":"pci-0000:04:00.0","object.serial":7321030 }}}1031 ]"#;1032 assert_eq!(1033 resolve_pipewire_node(dump, &config).unwrap(),1034 GamescopePreflight {1035 target_object: "731".into(),1036 pointer_surface_dimensions: PointerSurfaceDimensions::new(2_560, 1_600).unwrap(),1037 video_mode: configured_video_mode(),1038 }1039 );10401041 let duplicate = br#"[1042 {"type":"PipeWire:Interface:Node","info":{"props":{1043 "node.name":"gamescope","media.class":"Video/Source",1044 "device.bus-path":"pci-0000:04:00.0","object.serial":7311045 }}},1046 {"type":"PipeWire:Interface:Node","info":{"props":{1047 "node.name":"gamescope","media.class":"Video/Source",1048 "device.bus-path":"pci-0000:04:00.0","object.serial":"732"1049 }}}1050 ]"#;1051 assert!(resolve_pipewire_node(duplicate, &config).is_err());10521053 let mut native_config = config.clone();1054 native_config.width = None;1055 native_config.height = None;1056 let native = resolve_pipewire_node(dump, &native_config).unwrap();1057 assert_eq!(1058 native.video_mode,1059 CaptureVideoMode {1060 width: 2_560,1061 height: 1_600,1062 framerate: 60,1063 }1064 );1065 assert_eq!(native.pointer_surface_dimensions.width, 2_560);1066 assert_eq!(native.pointer_surface_dimensions.height, 1_600);10671068 let mut distorted = config.clone();1069 distorted.width = Some(1_920);1070 distorted.height = Some(1_080);1071 assert!(1072 resolve_pipewire_node(dump, &distorted)1073 .unwrap_err()1074 .to_string()1075 .contains("must preserve native 2560x1600 aspect ratio")1076 );10771078 let portrait_dump = std::str::from_utf8(dump)1079 .unwrap()1080 .replace("2560", "WIDTH")1081 .replace("1600", "2560")1082 .replace("WIDTH", "1600");1083 let portrait = resolve_pipewire_node(portrait_dump.as_bytes(), &native_config).unwrap();1084 assert_eq!(1085 (portrait.video_mode.width, portrait.video_mode.height),1086 (1_600, 2_560)1087 );1088 }10891090 #[test]1091 fn unconfigured_gamescope_mode_follows_each_resolved_session() {1092 let mut config = gamescope_config();1093 config.width = None;1094 config.height = None;10951096 for (serial, width, height) in [1097 (731, 2_560, 1_600),1098 (900, 1_280, 800),1099 (901, 1_920, 1_080),1100 (902, 1_366, 768),1101 ] {1102 let resolved =1103 resolve_pipewire_node(&gamescope_dump(serial, width, height), &config).unwrap();1104 assert_eq!(resolved.target_object, serial.to_string());1105 assert_eq!(1106 resolved.pointer_surface_dimensions,1107 PointerSurfaceDimensions::new(width, height).unwrap()1108 );1109 assert_eq!(1110 resolved.video_mode,1111 CaptureVideoMode {1112 width,1113 height,1114 framerate: 60,1115 }1116 );1117 assert_eq!(config.configured_dimensions(), None);1118 }1119 }11201121 #[test]1122 fn resolves_pinned_upstream_gamescope_pipewire_contract() {1123 // Sanitized from the standard interface emitted by Valve Gamescope1124 // commit 17baf4abd1ab3353fb705e4d0d023f84e870f7e8, src/pipewire.cpp1125 // blob 76b3ea8cc8ff01c6635498cbafe38130e33215f2. This fixture proves1126 // interface compatibility; it is not SteamOS hardware evidence.1127 let dump = include_bytes!("../../tests/fixtures/upstream-gamescope-pipewire-contract.json");1128 let mut config = gamescope_config();1129 config.width = None;1130 config.height = None;1131 config1132 .gamescope_pipewire1133 .as_mut()1134 .unwrap()1135 .match_properties1136 .clear();11371138 let resolved = resolve_pipewire_node(dump, &config).unwrap();1139 assert_eq!(resolved.target_object, "8842");1140 assert_eq!(1141 resolved.pointer_surface_dimensions,1142 PointerSurfaceDimensions::new(1_920, 1_080).unwrap()1143 );1144 assert_eq!(1145 resolved.video_mode,1146 CaptureVideoMode {1147 width: 1_920,1148 height: 1_080,1149 framerate: 60,1150 }1151 );11521153 let args: Vec<String> =1154 gamescope_pipeline_args(&config, &resolved.target_object, resolved.video_mode)1155 .unwrap()1156 .into_iter()1157 .map(|arg| arg.into_string().unwrap())1158 .collect();1159 assert!(args.contains(&"target-object=8842".to_owned()));1160 assert!(1161 args.contains(1162 &"video/x-raw,format=NV12,width=1920,height=1080,framerate=60/1".to_owned()1163 )1164 );1165 assert!(args.contains(&"max-rate=60".to_owned()));1166 }11671168 #[test]1169 fn rejects_ambiguous_or_unbounded_gamescope_native_size() {1170 let config = gamescope_config();1171 let dump = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1172 "node.name":"gamescope","media.class":"Video/Source",1173 "device.bus-path":"pci-0000:04:00.0","object.serial":7311174 },"params":{"EnumFormat":[1175 {"mediaType":"video","mediaSubtype":"raw","size":{"width":2560,"height":1600}},1176 {"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}1177 ]}}}]"#;1178 assert!(resolve_pipewire_node(dump, &config).is_err());11791180 let unbounded = std::str::from_utf8(dump)1181 .unwrap()1182 .replace("2560", "7681")1183 .replace(1184 r#",{"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}"#,1185 "",1186 );1187 assert!(resolve_pipewire_node(unbounded.as_bytes(), &config).is_err());11881189 let mut native_config = config.clone();1190 native_config.width = None;1191 native_config.height = None;1192 let odd = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1193 "node.name":"gamescope","media.class":"Video/Source",1194 "device.bus-path":"pci-0000:04:00.0","object.serial":7311195 },"params":{"EnumFormat":[1196 {"mediaType":"video","mediaSubtype":"raw","size":{"width":1365,"height":768}}1197 ]}}}]"#;1198 let error = resolve_pipewire_node(odd, &native_config).unwrap_err();1199 assert!(error.to_string().contains("must be even"), "{error:#}");1200 }1201}