cat / goq
clone

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

feat: resolve native Gamescope dimensions (#20)

FelineStateMachine committed 1c3e7a9
Make production capture native-by-default with bounded, same-aspect overrides. Keep test-pattern and strict benchmark size assertions explicit while allowing arbitrary observed host modes.
commit
1c3e7a958228c1346d904fce41707f636bb561ef
parent
22d703184b16c459b4bb4f860f1d84362de9cd77
author
FelineStateMachine <dami.etoile@gmail.com>
8 files changed, +386 -120
MREADME.md19++-
Mcrates/sigil-host/src/audio.rs4+-
Mcrates/sigil-host/src/bin/sigil-probe.rs28+++-
Mcrates/sigil-host/src/config.rs122+++++++++++++----
Mcrates/sigil-host/src/main.rs47+++++--
Mcrates/sigil-host/src/server.rs31+++-
Mcrates/sigil-host/src/source.rs217++++++++++++++++++++++++------
Mdocs/fresh-bazzite-host.md38+++--
M README.md +12 -7
@@ -37,8 +37,10 @@ Steam and games.
3737 input messages, limits, and ALPNs.3838 - The Linux host is the pure Rust `sigil` binary with no Tauri or webview3939 dependency.40-- The Bazzite path captures the exact Gamescope PipeWire node and uses AMD41- GstVA H.264 at the fixed 1280×800/60 first target.40+- The Bazzite path captures the exact Gamescope PipeWire node, derives the41+ encoded size from its bounded native caps by default, and uses AMD GstVA42+ H.264. An explicit width/height pair remains available for downscale proofs;43+ 1280×800/60 is the first measured target, not a product constraint.4244 - Gamescope video keeps the proven external `gst-launch` pipeline as its4345 configuration default. Linux builds made explicitly with4446 `in-process-gstreamer` can opt into an in-process pipeline for bounded
@@ -50,10 +52,12 @@ Steam and games.
5052 in-process CBR bitrate decision only after exact encoder readback. External5153 encoder sessions remain explicitly advisory-only.5254 - On the opt-in in-process backend, fresh damage-driven frame progress and53- authenticated receiver pressure select either native 1280x800 or the exact54- same-aspect 960x600 motion tier. A switch commits only when GStreamer emits a55- target-size IDR with SPS/PPS; Portal then starts a new decoder epoch while56- pointer mapping remains tied to the native Gamescope surface.55+ authenticated receiver pressure select either the resolved native mode or56+ an exact same-aspect three-quarter motion tier when one exists. A switch57+ commits only when GStreamer emits a target-size IDR with SPS/PPS; Portal then58+ starts a new decoder epoch while pointer mapping remains tied independently59+ to the native Gamescope surface. Modes without an exact even reduced tier60+ continue at native resolution instead of rejecting the stream.5761 - Portal delivers encoded frames to WebCodecs through a raw Tauri binary5862 channel. The handoff is capped at four frames, the decode queue and5963 presentation queue at two, and a bounded watchdog recovers a suspended
@@ -297,7 +301,8 @@ The promoted streaming-hardening roadmap is tracked as bounded
297301 [adaptive bitrate](https://github.com/FelineStateMachine/goq/issues/8),298302 [motion-sensitive resolution](https://github.com/FelineStateMachine/goq/issues/9),299303 and [Auto Codec](https://github.com/FelineStateMachine/goq/issues/10). H.264 at300-1280×800/60 remains the known-safe first target while that work lands.304+1280×800/60 remains a known-safe benchmark while production capture accepts305+any even native mode within the protocol's bounded pixel limits.301306 302307 ## License303308
M crates/sigil-host/src/audio.rs +2 -2
M crates/sigil-host/src/bin/sigil-probe.rs +20 -8
@@ -33,8 +33,10 @@ struct Args {
3333 /// cadence. Intended for live appliance performance gates.3434 #[arg(long, value_parser = parse_minimum_fps)]3535 minimum_fps: Option<f64>,36- #[arg(long, default_value = "1280x800", value_parser = parse_size)]37- expect_size: (u16, u16),36+ /// Optional strict encoded-size assertion. When omitted, accept and report37+ /// the host's observed bounded dimensions.38+ #[arg(long, value_parser = parse_size)]39+ expect_size: Option<(u16, u16)>,3840 /// Exercise custom grouped v3 media instead of upstream MoQ. Intended only3941 /// for compatibility validation.4042 #[arg(long, conflicts_with_all = ["media_v2", "media_v1"])]
@@ -1413,12 +1415,12 @@ async fn main() -> Result<()> {
14131415 let Some((width, height)) = dimensions else {14141416 bail!("probe received no frames");14151417 };1416- ensure!(1417- (width, height) == args.expect_size,1418- "expected {}x{} but received {width}x{height}",1419- args.expect_size.0,1420- args.expect_size.11421- );1418+ if let Some((expected_width, expected_height)) = args.expect_size {1419+ ensure!(1420+ (width, height) == (expected_width, expected_height),1421+ "expected {expected_width}x{expected_height} but received {width}x{height}"1422+ );1423+ }14221424 ensure!(keyframes > 0, "probe received no H.264 keyframe");14231425 ensure!(gaps == 0, "probe observed {gaps} media sequence gaps");14241426 ensure!(
@@ -2148,6 +2150,16 @@ mod tests {
21482150 assert!(!default.media_v1);21492151 assert!(!default.media_v2);21502152 assert!(!default.media_v3);2153+ assert_eq!(default.expect_size, None);2154+ let strict = Args::try_parse_from([2155+ "sigil-probe",2156+ "--node-id",2157+ &node_id,2158+ "--expect-size",2159+ "2560x1600",2160+ ])2161+ .unwrap();2162+ assert_eq!(strict.expect_size, Some((2_560, 1_600)));21512163 for flags in [21522164 ["--media-v1", "--media-v2"],21532165 ["--media-v1", "--media-v3"],
M crates/sigil-host/src/config.rs +94 -28
@@ -43,14 +43,6 @@ pub enum GamescopeEncoderBackend {
4343 InProcessGstreamer,4444 }4545 46-fn default_width() -> u32 {47- 128048-}49-50-fn default_height() -> u32 {51- 80052-}53-5446 fn default_framerate() -> u32 {5547 605648 }
@@ -294,10 +286,13 @@ pub struct HostConfig {
294286 pub state_path: PathBuf,295287 #[serde(default = "default_source")]296288 pub source: VideoSource,297- #[serde(default = "default_width")]298- pub width: u32,299- #[serde(default = "default_height")]300- pub height: u32,289+ /// Optional encoded-size override. Gamescope uses its advertised native290+ /// size when both fields are absent; the test-pattern proof retains its291+ /// 1280x800 fixture default.292+ #[serde(default, skip_serializing_if = "Option::is_none")]293+ pub width: Option<u32>,294+ #[serde(default, skip_serializing_if = "Option::is_none")]295+ pub height: Option<u32>,301296 #[serde(default = "default_framerate")]302297 pub framerate: u32,303298 #[serde(default = "default_codec")]
@@ -318,6 +313,9 @@ pub struct HostConfig {
318313 }319314 320315 impl HostConfig {316+ pub const TEST_PATTERN_WIDTH: u32 = 1_280;317+ pub const TEST_PATTERN_HEIGHT: u32 = 800;318+321319 pub fn load(path: &Path) -> Result<Self> {322320 let mut options = OpenOptions::new();323321 options.read(true);
@@ -351,18 +349,11 @@ impl HostConfig {
351349 !self.state_path.as_os_str().is_empty(),352350 "state_path is required"353351 );354- ensure!(355- (64..=7680).contains(&self.width),356- "width must be between 64 and 7680"357- );358- ensure!(359- (64..=4320).contains(&self.height),360- "height must be between 64 and 4320"361- );362- ensure!(363- self.width.is_multiple_of(2) && self.height.is_multiple_of(2),364- "H.264 dimensions must be even"365- );352+ match (self.width, self.height) {353+ (Some(width), Some(height)) => validate_video_dimensions(width, height)?,354+ (None, None) => {}355+ _ => anyhow::bail!("width and height must either both be set or both be absent"),356+ }366357 ensure!(367358 (1..=240).contains(&self.framerate),368359 "framerate must be between 1 and 240"
@@ -407,6 +398,32 @@ impl HostConfig {
407398 Ok(())408399 }409400 401+ pub fn configured_dimensions(&self) -> Option<(u32, u32)> {402+ self.width.zip(self.height)403+ }404+405+ pub fn resolved_dimensions(&self) -> Result<(u32, u32)> {406+ self.configured_dimensions()407+ .context("video dimensions have not been resolved")408+ }409+410+ pub fn test_pattern_dimensions(&self) -> Result<(u32, u32)> {411+ ensure!(412+ self.source == VideoSource::TestPattern,413+ "test-pattern dimensions requested for a non-test source"414+ );415+ Ok(self416+ .configured_dimensions()417+ .unwrap_or((Self::TEST_PATTERN_WIDTH, Self::TEST_PATTERN_HEIGHT)))418+ }419+420+ pub fn apply_resolved_dimensions(&mut self, width: u32, height: u32) -> Result<()> {421+ validate_video_dimensions(width, height)?;422+ self.width = Some(width);423+ self.height = Some(height);424+ Ok(())425+ }426+410427 pub fn ensure_runtime_directory(&self) -> Result<()> {411428 if self.state_path.exists() {412429 let metadata = fs::symlink_metadata(&self.state_path)
@@ -446,6 +463,22 @@ impl HostConfig {
446463 }447464 }448465 466+fn validate_video_dimensions(width: u32, height: u32) -> Result<()> {467+ ensure!(468+ (64..=7680).contains(&width),469+ "width must be between 64 and 7680"470+ );471+ ensure!(472+ (64..=4320).contains(&height),473+ "height must be between 64 and 4320"474+ );475+ ensure!(476+ width.is_multiple_of(2) && height.is_multiple_of(2),477+ "H.264 dimensions must be even"478+ );479+ Ok(())480+}481+449482 fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {450483 ensure!(metadata.is_file(), "config must be a regular file");451484 #[cfg(unix)]
@@ -491,6 +524,21 @@ codec = "h264"
491524 "#;492525 let config: HostConfig = toml::from_str(input).unwrap();493526 config.validate().unwrap();527+ assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));528+ }529+530+ #[test]531+ fn test_pattern_omission_retains_the_proof_fixture() {532+ let config: HostConfig = toml::from_str(533+ r#"534+identity_path = "/tmp/host.key"535+state_path = "/tmp/state"536+"#,537+ )538+ .unwrap();539+ config.validate().unwrap();540+ assert_eq!(config.configured_dimensions(), None);541+ assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));494542 }495543 496544 #[test]
@@ -499,8 +547,6 @@ codec = "h264"
499547 identity_path = "/tmp/host.key"500548 state_path = "/tmp/state"501549 source = "gamescope-pipewire"502-width = 1280503-height = 800504550 framerate = 60505551 codec = "h264"506552
@@ -530,6 +576,7 @@ bitrate_bps = 96000
530576 "#;531577 let config: HostConfig = toml::from_str(input).unwrap();532578 config.validate().unwrap();579+ assert_eq!(config.configured_dimensions(), None);533580 assert_eq!(534581 config535582 .gamescope_pipewire
@@ -751,13 +798,32 @@ state_path = "/tmp/state"
751798 "#,752799 )753800 .unwrap();754- config.width = 1279;801+ config.width = Some(1279);755802 assert!(config.validate().is_err());756- config.width = 1280;803+ config.width = Some(1280);757804 config.codec = "av1".into();758805 assert!(config.validate().is_err());759806 }760807 808+ #[test]809+ fn rejects_partial_dimension_override() {810+ let config: HostConfig = toml::from_str(811+ r#"812+identity_path = "/tmp/host.key"813+state_path = "/tmp/state"814+width = 1280815+"#,816+ )817+ .unwrap();818+ assert!(819+ config820+ .validate()821+ .unwrap_err()822+ .to_string()823+ .contains("width and height must either both be set or both be absent")824+ );825+ }826+761827 #[test]762828 fn validates_strict_uinput_configuration() {763829 let config: HostConfig = toml::from_str(
M crates/sigil-host/src/main.rs +35 -12
@@ -356,6 +356,12 @@ async fn config_command(command: ConfigCommand) -> Result<()> {
356356 preflight.pointer_surface_dimensions.width,357357 preflight.pointer_surface_dimensions.height358358 );359+ println!(360+ "encoded_mode={}x{}@{}",361+ preflight.video_mode.width,362+ preflight.video_mode.height,363+ preflight.video_mode.framerate364+ );359365 println!("capture_preflight=ok");360366 if config.audio.is_some() {361367 let target = audio::preflight_audio(&config).await?;
@@ -396,8 +402,8 @@ async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {
396402 identity_path: PathBuf::from("unused-by-capture-probe"),397403 state_path: PathBuf::from("."),398404 source: VideoSource::TestPattern,399- width,400- height,405+ width: Some(width),406+ height: Some(height),401407 framerate: 60,402408 codec: "h264".into(),403409 input_mode: InputMode::Disabled,
@@ -430,17 +436,26 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
430436 config.source == VideoSource::GamescopePipewire,431437 "capture config source must be gamescope-pipewire"432438 );439+ let preflight = source::preflight_gamescope_pipewire(&config).await?;440+ let observed = (441+ u32::from(preflight.video_mode.width),442+ u32::from(preflight.video_mode.height),443+ );433444 if let Some(expected) = args.expect_size {434445 ensure!(435- expected == (config.width, config.height),436- "expected size {}x{} does not match configured size {}x{}",446+ expected == observed,447+ "expected size {}x{} does not match resolved encoded size {}x{}",437448 expected.0,438449 expected.1,439- config.width,440- config.height450+ observed.0,451+ observed.1441452 );442453 }443- let configured_framerate = config.framerate;454+ println!(455+ "resolved_encoded_mode={}x{}@{}",456+ observed.0, observed.1, preflight.video_mode.framerate457+ );458+ let configured_framerate = preflight.video_mode.framerate;444459 let source = source::spawn_gamescope_pipewire(config, clock::SessionClock::start()).await?;445460 consume_capture_probe(446461 args,
@@ -551,7 +566,7 @@ impl Drop for CaptureTaskGuard {
551566 }552567 553568 async fn serve_command(args: ServeArgs) -> Result<()> {554- let config = load_serve_config(&args)?;569+ let mut config = load_serve_config(&args)?;555570 config.validate()?;556571 config.ensure_runtime_directory()?;557572 let secret = identity::load(&config.identity_path)?;
@@ -563,9 +578,9 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
563578 } else {564579 AuthorizationPolicy::TestPatternProof565580 };566- let input_backend = InputBackend::initialize(&config)?;567-568581 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {582+ let (width, height) = config.test_pattern_dimensions()?;583+ config.apply_resolved_dimensions(width, height)?;569584 let status = tokio::process::Command::new(&config.ffmpeg_path)570585 .arg("-version")571586 .stdin(std::process::Stdio::null())
@@ -585,14 +600,22 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
585600 target_object = %preflight.target_object,586601 pointer_surface_width = preflight.pointer_surface_dimensions.width,587602 pointer_surface_height = preflight.pointer_surface_dimensions.height,603+ encoded_width = preflight.video_mode.width,604+ encoded_height = preflight.video_mode.height,605+ encoded_framerate = preflight.video_mode.framerate,588606 "Gamescope PipeWire capture preflight passed"589607 );608+ config.apply_resolved_dimensions(609+ u32::from(preflight.video_mode.width),610+ u32::from(preflight.video_mode.height),611+ )?;590612 if config.audio.is_some() {591613 audio::preflight_audio_static(&config).await?;592614 info!("PipeWire Opus audio static preflight passed");593615 }594616 Some(preflight.pointer_surface_dimensions)595617 };618+ let input_backend = InputBackend::initialize(&config)?;596619 597620 let pointer_positions = if config.source == VideoSource::GamescopePipewire598621 && config.input_mode == InputMode::Uinput
@@ -773,8 +796,8 @@ fn load_serve_config(args: &ServeArgs) -> Result<HostConfig> {
773796 identity_path,774797 state_path,775798 source: source.into(),776- width: args.width,777- height: args.height,799+ width: Some(args.width),800+ height: Some(args.height),778801 framerate: args.framerate,779802 codec: "h264".into(),780803 input_mode: InputMode::Log,
M crates/sigil-host/src/server.rs +25 -6
@@ -387,6 +387,25 @@ impl MotionResolutionPolicy {
387387 }388388 }389389 390+fn motion_resolution_policy(width: u32, height: u32) -> Result<Option<MotionResolutionPolicy>> {391+ let native = VideoDimensions {392+ width: u16::try_from(width).context("native width exceeds protocol")?,393+ height: u16::try_from(height).context("native height exceeds protocol")?,394+ };395+ match MotionResolutionPolicy::new(native) {396+ Ok(policy) => Ok(Some(policy)),397+ Err(error) => {398+ warn!(399+ %error,400+ width,401+ height,402+ "motion resolution disabled because this native mode has no exact reduced tier"403+ );404+ Ok(None)405+ }406+ }407+}408+390409 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]391410 struct FeedbackIngressCursor {392411 last_report_id: Option<u64>,
@@ -2979,10 +2998,8 @@ async fn serve_media_feedback(
29792998 let mut receiver_open = true;29802999 let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());29813000 let mut resolution_controller = if encoder_actuation_available {2982- Some(MotionResolutionPolicy::new(VideoDimensions {2983- width: u16::try_from(config.width).context("native width exceeds protocol")?,2984- height: u16::try_from(config.height).context("native height exceeds protocol")?,2985- })?)3001+ let (width, height) = config.resolved_dimensions()?;3002+ motion_resolution_policy(width, height)?29863003 } else {29873004 None29883005 };
@@ -4913,6 +4930,8 @@ mod tests {
49134930 })49144931 .is_err()49154932 );4933+ assert!(motion_resolution_policy(1_920, 1_080).unwrap().is_some());4934+ assert!(motion_resolution_policy(1_366, 768).unwrap().is_none());49164935 }49174936 49184937 #[test]
@@ -5572,8 +5591,8 @@ mod tests {
55725591 identity_path: "identity".into(),55735592 state_path: "state".into(),55745593 source: VideoSource::TestPattern,5575- width: 1280,5576- height: 800,5594+ width: Some(1280),5595+ height: Some(800),55775596 framerate: 60,55785597 codec: "h264".to_owned(),55795598 input_mode: crate::config::InputMode::Disabled,
M crates/sigil-host/src/source.rs +176 -41
@@ -354,6 +354,45 @@ pub struct GamescopePreflight {
354354 pub target_object: String,355355 /// Native Gamescope surface used by the relative-pointer coordinate space.356356 pub pointer_surface_dimensions: PointerSurfaceDimensions,357+ /// Bounded encoded mode after applying an optional explicit size override.358+ pub video_mode: CaptureVideoMode,359+}360+361+#[derive(Clone, Copy, Debug, PartialEq, Eq)]362+pub struct CaptureVideoMode {363+ pub width: u16,364+ pub height: u16,365+ pub framerate: u32,366+}367+368+impl CaptureVideoMode {369+ fn resolve(config: &HostConfig, native: PointerSurfaceDimensions) -> Result<Self> {370+ let configured = config.configured_dimensions();371+ let (width, height) =372+ configured.unwrap_or((u32::from(native.width), u32::from(native.height)));373+ ensure!(374+ (64..=7_680).contains(&width) && (64..=4_320).contains(&height),375+ "resolved Gamescope encoded dimensions {width}x{height} are outside H.264 bounds"376+ );377+ ensure!(378+ width.is_multiple_of(2) && height.is_multiple_of(2),379+ "resolved Gamescope encoded dimensions {width}x{height} must be even"380+ );381+ if configured.is_some() {382+ ensure!(383+ u64::from(width) * u64::from(native.height)384+ == u64::from(height) * u64::from(native.width),385+ "configured Gamescope encoded dimensions {width}x{height} must preserve native {}x{} aspect ratio",386+ native.width,387+ native.height388+ );389+ }390+ Ok(Self {391+ width: u16::try_from(width).context("resolved Gamescope width exceeds metadata")?,392+ height: u16::try_from(height).context("resolved Gamescope height exceeds metadata")?,393+ framerate: config.framerate,394+ })395+ }357396 }358397 359398 pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<GamescopePreflight> {
@@ -418,7 +457,7 @@ async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<Gamesc
418457 "pw-dump failed: {}",419458 diagnostic(&output.stderr)420459 );421- resolve_pipewire_node(&output.stdout, pipewire)460+ resolve_pipewire_node(&output.stdout, config)422461 }423462 424463 pub async fn spawn_gamescope_pipewire(
@@ -473,11 +512,13 @@ fn spawn_gamescope_pipewire_with_target(
473512 let (sender, receiver) = watch::channel(None);474513 let (current_gop_sender, current_gop) = watch::channel(None);475514 let target_object = preflight.target_object;515+ let video_mode = preflight.video_mode;476516 let task = tokio::spawn(async move {477517 run_gamescope_pipewire(478518 config,479519 session_clock,480520 target_object,521+ video_mode,481522 sender,482523 current_gop_sender,483524 )
@@ -498,8 +539,12 @@ fn spawn_gamescope_pipewire_in_process_with_target(
498539 session_clock: SessionClock,499540 preflight: GamescopePreflight,500541 ) -> Result<EncodedSource> {501- let description = gamescope_in_process_pipeline_description(&config, &preflight.target_object)?;502- let max_gop_frames = interactive_gop_frames(config.framerate) as usize;542+ let description = gamescope_in_process_pipeline_description(543+ &config,544+ &preflight.target_object,545+ preflight.video_mode,546+ )?;547+ let max_gop_frames = interactive_gop_frames(preflight.video_mode.framerate) as usize;503548 let expected_device_path = Some(504549 gamescope_config(&config)?505550 .vaapi_render_node
@@ -512,8 +557,8 @@ fn spawn_gamescope_pipewire_in_process_with_target(
512557 max_gop_frames,513558 Some(preflight.pointer_surface_dimensions),514559 expected_device_path,515- u16::try_from(config.width).context("configured width exceeds encoder metadata")?,516- u16::try_from(config.height).context("configured height exceeds encoder metadata")?,560+ preflight.video_mode.width,561+ preflight.video_mode.height,517562 )518563 }519564
@@ -561,6 +606,7 @@ fn spawn_in_process_pipeline(
561606 fn gamescope_in_process_pipeline_description(562607 config: &HostConfig,563608 target_object: &str,609+ video_mode: CaptureVideoMode,564610 ) -> Result<String> {565611 let pipewire = gamescope_config(config)?;566612 validate_pipewire_target_object(target_object)?;
@@ -573,7 +619,7 @@ fn gamescope_in_process_pipeline_description(
573619 .context("CBR bitrate is missing after validation")?;574620 let raw_caps = format!(575621 "video/x-raw,format=NV12,width={},height={},framerate={}/1",576- config.width, config.height, config.framerate622+ video_mode.width, video_mode.height, video_mode.framerate577623 );578624 Ok(format!(579625 "pipewiresrc do-timestamp=true min-buffers=1 max-buffers=4 use-bufferpool=false target-object={target_object} \
@@ -585,9 +631,9 @@ fn gamescope_in_process_pipeline_description(
585631 ! h264parse config-interval=-1 \586632 ! video/x-h264,stream-format=byte-stream,alignment=au \587633 ! appsink name=sigil_sink max-buffers=1 drop=false sync=false",588- config.framerate,634+ video_mode.framerate,589635 pipewire.vaapi_encoder,590- interactive_gop_frames(config.framerate),636+ interactive_gop_frames(video_mode.framerate),591637 ))592638 }593639
@@ -606,11 +652,12 @@ async fn run_gamescope_pipewire(
606652 config: HostConfig,607653 session_clock: SessionClock,608654 target_object: String,655+ video_mode: CaptureVideoMode,609656 sender: watch::Sender<Option<EncodedFrame>>,610657 current_gop_sender: watch::Sender<Option<EncodedGop>>,611658 ) -> Result<()> {612659 let pipewire = gamescope_config(&config)?;613- let args = gamescope_pipeline_args(&config, &target_object)?;660+ let args = gamescope_pipeline_args(&config, &target_object, video_mode)?;614661 let mut command = Command::new(&pipewire.gst_launch_path);615662 command616663 .args(args)
@@ -644,9 +691,9 @@ async fn run_gamescope_pipewire(
644691 &mut stdout,645692 &sender,646693 &current_gop_sender,647- interactive_gop_frames(config.framerate) as usize,648- u16::try_from(config.width).context("configured width exceeds encoder metadata")?,649- u16::try_from(config.height).context("configured height exceeds encoder metadata")?,694+ interactive_gop_frames(video_mode.framerate) as usize,695+ video_mode.width,696+ video_mode.height,650697 )651698 .await;652699 let _ = child.kill().await;
@@ -671,9 +718,10 @@ async fn run_test_pattern(
671718 sender: watch::Sender<Option<EncodedFrame>>,672719 current_gop_sender: watch::Sender<Option<EncodedGop>>,673720 ) -> Result<()> {721+ let (width, height) = config.test_pattern_dimensions()?;674722 let input = format!(675723 "testsrc2=size={}x{}:rate={}",676- config.width, config.height, config.framerate724+ width, height, config.framerate677725 );678726 let gop = interactive_gop_frames(config.framerate).to_string();679727
@@ -727,8 +775,8 @@ async fn run_test_pattern(
727775 &sender,728776 &current_gop_sender,729777 interactive_gop_frames(config.framerate) as usize,730- u16::try_from(config.width).context("configured width exceeds encoder metadata")?,731- u16::try_from(config.height).context("configured height exceeds encoder metadata")?,778+ u16::try_from(width).context("configured width exceeds encoder metadata")?,779+ u16::try_from(height).context("configured height exceeds encoder metadata")?,732780 )733781 .await;734782 if result.is_ok() {
@@ -1392,7 +1440,11 @@ fn gamescope_config(config: &HostConfig) -> Result<&GamescopePipewireConfig> {
13921440 .context("gamescope_pipewire configuration is missing")13931441 }13941442 1395-fn gamescope_pipeline_args(config: &HostConfig, target_object: &str) -> Result<Vec<OsString>> {1443+fn gamescope_pipeline_args(1444+ config: &HostConfig,1445+ target_object: &str,1446+ video_mode: CaptureVideoMode,1447+) -> Result<Vec<OsString>> {13961448 let pipewire = gamescope_config(config)?;13971449 ensure!(13981450 !target_object.is_empty()
@@ -1402,7 +1454,7 @@ fn gamescope_pipeline_args(config: &HostConfig, target_object: &str) -> Result<V
14021454 );14031455 let caps = format!(14041456 "video/x-raw,format=NV12,width={},height={},framerate={}/1",1405- config.width, config.height, config.framerate1457+ video_mode.width, video_mode.height, video_mode.framerate14061458 );14071459 let encoded_caps = "video/x-h264,stream-format=byte-stream,alignment=au";14081460 let mut args = vec![
@@ -1430,7 +1482,7 @@ fn gamescope_pipeline_args(config: &HostConfig, target_object: &str) -> Result<V
14301482 // Gamescope is damage-driven. Never manufacture replacement frames:14311483 // doing so can turn an irregular live source into stale catch-up work.14321484 "drop-only=true".to_owned(),1433- format!("max-rate={}", config.framerate),1485+ format!("max-rate={}", video_mode.framerate),14341486 "!".to_owned(),14351487 "videoconvert".to_owned(),14361488 "!".to_owned(),
@@ -1464,7 +1516,10 @@ fn gamescope_pipeline_args(config: &HostConfig, target_object: &str) -> Result<V
14641516 }14651517 args.extend([14661518 "target-usage=7".to_owned(),1467- format!("key-int-max={}", interactive_gop_frames(config.framerate)),1519+ format!(1520+ "key-int-max={}",1521+ interactive_gop_frames(video_mode.framerate)1522+ ),14681523 "b-frames=0".to_owned(),14691524 "ref-frames=1".to_owned(),14701525 "aud=true".to_owned(),
@@ -1510,26 +1565,26 @@ pub(crate) async fn resolve_pipewire_node_by_properties(
15101565 resolve_pipewire_node_exact(&output.stdout, node_name, media_class, match_properties)15111566 }15121567 1513-fn resolve_pipewire_node(1514- output: &[u8],1515- config: &GamescopePipewireConfig,1516-) -> Result<GamescopePreflight> {1568+fn resolve_pipewire_node(output: &[u8], config: &HostConfig) -> Result<GamescopePreflight> {1569+ let pipewire = gamescope_config(config)?;15171570 let target_object = resolve_pipewire_node_exact(15181571 output,1519- &config.node_name,1520- &config.media_class,1521- &config.match_properties,1572+ &pipewire.node_name,1573+ &pipewire.media_class,1574+ &pipewire.match_properties,15221575 )?;15231576 let pointer_surface_dimensions = resolve_pointer_surface_dimensions(15241577 output,15251578 &target_object,1526- &config.node_name,1527- &config.media_class,1528- &config.match_properties,1579+ &pipewire.node_name,1580+ &pipewire.media_class,1581+ &pipewire.match_properties,15291582 )?;1583+ let video_mode = CaptureVideoMode::resolve(config, pointer_surface_dimensions)?;15301584 Ok(GamescopePreflight {15311585 target_object,15321586 pointer_surface_dimensions,1587+ video_mode,15331588 })15341589 }15351590
@@ -2004,8 +2059,8 @@ mod tests {
20042059 identity_path: PathBuf::from("/tmp/host.key"),20052060 state_path: PathBuf::from("/tmp/state"),20062061 source: VideoSource::GamescopePipewire,2007- width: 1280,2008- height: 800,2062+ width: Some(1280),2063+ height: Some(800),20092064 framerate: 60,20102065 codec: "h264".into(),20112066 input_mode: InputMode::Disabled,
@@ -2033,6 +2088,14 @@ mod tests {
20332088 }20342089 }20352090 2091+ fn configured_video_mode() -> CaptureVideoMode {2092+ CaptureVideoMode {2093+ width: 1_280,2094+ height: 800,2095+ framerate: 60,2096+ }2097+ }2098+20362099 #[tokio::test]20372100 async fn encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr() {20382101 let (control, mut desired, status) = EncoderControl::new();
@@ -2260,7 +2323,9 @@ mod tests {
22602323 let mut config = gamescope_config();22612324 config.gamescope_pipewire.as_mut().unwrap().encoder_backend =22622325 GamescopeEncoderBackend::InProcessGstreamer;2263- let description = gamescope_in_process_pipeline_description(&config, "1234").unwrap();2326+ let description =2327+ gamescope_in_process_pipeline_description(&config, "1234", configured_video_mode())2328+ .unwrap();22642329 22652330 assert!(description.contains(22662331 "queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream"
@@ -2452,7 +2517,7 @@ mod tests {
24522517 #[test]24532518 fn builds_bounded_low_latency_gamescope_pipeline() {24542519 let config = gamescope_config();2455- let args: Vec<String> = gamescope_pipeline_args(&config, "1234")2520+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())24562521 .unwrap()24572522 .into_iter()24582523 .map(|arg| arg.into_string().unwrap())
@@ -2497,7 +2562,30 @@ mod tests {
24972562 let videoconvert = args.iter().position(|arg| arg == "videoconvert").unwrap();24982563 let videoscale = args.iter().position(|arg| arg == "videoscale").unwrap();24992564 assert!(queue < videorate && videorate < videoconvert && videoconvert < videoscale);2500- assert!(gamescope_pipeline_args(&config, "gamescope").is_err());2565+ assert!(gamescope_pipeline_args(&config, "gamescope", configured_video_mode()).is_err());2566+ }2567+2568+ #[test]2569+ fn builds_pipeline_for_resolved_non_fixture_mode() {2570+ let config = gamescope_config();2571+ let mode = CaptureVideoMode {2572+ width: 1_920,2573+ height: 1_080,2574+ framerate: 72,2575+ };2576+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", mode)2577+ .unwrap()2578+ .into_iter()2579+ .map(|arg| arg.into_string().unwrap())2580+ .collect();2581+2582+ assert!(2583+ args.contains(2584+ &"video/x-raw,format=NV12,width=1920,height=1080,framerate=72/1".to_owned()2585+ )2586+ );2587+ assert!(args.contains(&"max-rate=72".to_owned()));2588+ assert!(args.contains(&"key-int-max=36".to_owned()));25012589 }25022590 25032591 #[test]
@@ -2517,7 +2605,7 @@ mod tests {
25172605 pipewire.quantizer = Some(24);25182606 config.validate().unwrap();25192607 2520- let args: Vec<String> = gamescope_pipeline_args(&config, "1234")2608+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())25212609 .unwrap()25222610 .into_iter()25232611 .map(|arg| arg.into_string().unwrap())
@@ -2532,7 +2620,6 @@ mod tests {
25322620 #[test]25332621 fn resolves_exactly_one_pipewire_node_by_properties() {25342622 let config = gamescope_config();2535- let pipewire = config.gamescope_pipewire.as_ref().unwrap();25362623 let dump = br#"[25372624 {"type":"PipeWire:Interface:Node","info":{"props":{25382625 "node.name":"gamescope","media.class":"Video/Source",
@@ -2549,10 +2636,11 @@ mod tests {
25492636 }}}25502637 ]"#;25512638 assert_eq!(2552- resolve_pipewire_node(dump, pipewire).unwrap(),2639+ resolve_pipewire_node(dump, &config).unwrap(),25532640 GamescopePreflight {25542641 target_object: "731".into(),25552642 pointer_surface_dimensions: PointerSurfaceDimensions::new(2_560, 1_600).unwrap(),2643+ video_mode: configured_video_mode(),25562644 }25572645 );25582646
@@ -2566,13 +2654,48 @@ mod tests {
25662654 "device.bus-path":"pci-0000:04:00.0","object.serial":"732"25672655 }}}25682656 ]"#;2569- assert!(resolve_pipewire_node(duplicate, pipewire).is_err());2657+ assert!(resolve_pipewire_node(duplicate, &config).is_err());2658+2659+ let mut native_config = config.clone();2660+ native_config.width = None;2661+ native_config.height = None;2662+ let native = resolve_pipewire_node(dump, &native_config).unwrap();2663+ assert_eq!(2664+ native.video_mode,2665+ CaptureVideoMode {2666+ width: 2_560,2667+ height: 1_600,2668+ framerate: 60,2669+ }2670+ );2671+ assert_eq!(native.pointer_surface_dimensions.width, 2_560);2672+ assert_eq!(native.pointer_surface_dimensions.height, 1_600);2673+2674+ let mut distorted = config.clone();2675+ distorted.width = Some(1_920);2676+ distorted.height = Some(1_080);2677+ assert!(2678+ resolve_pipewire_node(dump, &distorted)2679+ .unwrap_err()2680+ .to_string()2681+ .contains("must preserve native 2560x1600 aspect ratio")2682+ );2683+2684+ let portrait_dump = std::str::from_utf8(dump)2685+ .unwrap()2686+ .replace("2560", "WIDTH")2687+ .replace("1600", "2560")2688+ .replace("WIDTH", "1600");2689+ let portrait = resolve_pipewire_node(portrait_dump.as_bytes(), &native_config).unwrap();2690+ assert_eq!(2691+ (portrait.video_mode.width, portrait.video_mode.height),2692+ (1_600, 2_560)2693+ );25702694 }25712695 25722696 #[test]25732697 fn rejects_ambiguous_or_unbounded_gamescope_native_size() {25742698 let config = gamescope_config();2575- let pipewire = config.gamescope_pipewire.as_ref().unwrap();25762699 let dump = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{25772700 "node.name":"gamescope","media.class":"Video/Source",25782701 "device.bus-path":"pci-0000:04:00.0","object.serial":731
@@ -2580,7 +2703,7 @@ mod tests {
25802703 {"mediaType":"video","mediaSubtype":"raw","size":{"width":2560,"height":1600}},25812704 {"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}25822705 ]}}}]"#;2583- assert!(resolve_pipewire_node(dump, pipewire).is_err());2706+ assert!(resolve_pipewire_node(dump, &config).is_err());25842707 25852708 let unbounded = std::str::from_utf8(dump)25862709 .unwrap()
@@ -2589,7 +2712,19 @@ mod tests {
25892712 r#",{"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}"#,25902713 "",25912714 );2592- assert!(resolve_pipewire_node(unbounded.as_bytes(), pipewire).is_err());2715+ assert!(resolve_pipewire_node(unbounded.as_bytes(), &config).is_err());2716+2717+ let mut native_config = config.clone();2718+ native_config.width = None;2719+ native_config.height = None;2720+ let odd = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{2721+ "node.name":"gamescope","media.class":"Video/Source",2722+ "device.bus-path":"pci-0000:04:00.0","object.serial":7312723+ },"params":{"EnumFormat":[2724+ {"mediaType":"video","mediaSubtype":"raw","size":{"width":1365,"height":768}}2725+ ]}}}]"#;2726+ let error = resolve_pipewire_node(odd, &native_config).unwrap_err();2727+ assert!(error.to_string().contains("must be even"), "{error:#}");25932728 }25942729 25952730 #[test]
M docs/fresh-bazzite-host.md +22 -16
@@ -1053,8 +1053,6 @@ cat >"$probe_config" <<EOF
10531053 identity_path = "$HOME/.local/share/sigil-spark/identity/host.key"10541054 state_path = "$HOME/.local/state/sigil-spark/runtime"10551055 source = "gamescope-pipewire"1056-width = 12801057-height = 80010581056 framerate = 6010591057 codec = "h264"10601058 input_mode = "uinput"
@@ -1082,6 +1080,12 @@ EOF
10821080 chmod 0600 "$probe_config"10831081 ```10841082 1083+With `width` and `height` absent, Sigil uses the single bounded native size1084+advertised by the selected Gamescope node. Set both fields only to request an1085+explicit same-aspect encoded downscale; partial or distorting overrides are1086+rejected. `framerate` is a maximum encoder cadence because current Gamescope1087+PipeWire caps advertise `0/1` rather than a trustworthy native rate.1088+10851089 Omitting `encoder_backend` has the same external default. After the external10861090 baseline passes, a locally built Linux binary containing the feature can use:10871091
@@ -1257,21 +1261,22 @@ configured—prints the exact resolved sink target:
12571261 ```12581262 12591263 Node enumeration and preflight are not enough. Consume 300 frames and verify a1260-decodable H.264 keyframe, the configured output size, and a changing sequence:1264+decodable H.264 keyframe, the resolved output size, and a changing sequence:12611265 12621266 ```bash12631267 "$HOME/.local/libexec/sigil-spark/current/sigil" capture probe \12641268 --source gamescope-pipewire \12651269 --config "$probe_config" \12661270 --frames 300 \1267- --expect-size 1280x800 \12681271 --minimum-fps 5512691272 ```12701273 1271-The pipeline explicitly negotiates and converts its encoder input to NV12 at1272-1280×800/60; do not infer that the underlying Gamescope/KMS output itself has1273-that size. Record Gamescope's native PipeWire caps separately. The capture1274-pipeline requests two PipeWire source buffers and then uses an explicit1274+The probe prints `resolved_encoded_mode=<width>x<height>@<cap>` before capture1275+and the dimensions observed on encoded frames afterward. Repeat with1276+`--expect-size <width>x<height>` when recording a strict benchmark. The1277+pipeline explicitly negotiates and converts its encoder input to NV12 at that1278+resolved size and configured cadence ceiling. The capture pipeline bounds the1279+PipeWire source pool between one and four buffers and then uses an explicit12751280 one-buffer, leaky-downstream queue; encoder-internal buffering is separate and12761281 must be measured on the installed GstVA version. Gamescope's stream is12771282 damage-driven and may emit no buffers while the image is static. The versioned
@@ -1297,19 +1302,21 @@ configuration whose `encoder_backend` is `in-process-gstreamer`. The external
12971302 12981303 Connect Portal, enable host debug logs, and repeat at least ten cycles of a12991304 high-motion 3D scene followed by a static high-frequency test card. For each1300-cycle retain the host journal and Portal console. The host decision record must1301-show an applied 960x600 motion/pressure target, followed by an applied 1280x8001305+cycle retain the host journal and Portal console. When the resolved native1306+dimensions admit an exact even three-quarter tier, the host decision record1307+must show that reduced motion/pressure target followed by the resolved native13021308 recovery target only after at least two seconds of stillness, three fresh clean13031309 feedback windows, and the three-second transition cooldown. A stale feedback1304-window must never restore native resolution.1310+window must never restore native resolution. A valid native mode without such1311+a tier must remain streamable with motion adaptation visibly disabled.13051312 13061313 At each boundary verify that the first delivered target-size frame is a13071314 keyframe with SPS/PPS and a discontinuity, no preceding-resolution delta is13081315 presented afterward, and Portal stays connected without a WebCodecs error.13091316 Absolute pointer tests at the canvas center and four corners must continue to13101317 land on the native Gamescope surface at both encoded resolutions. The Portal1311-window must not resize merely because 1280x800 and 960x600 have the same aspect1312-ratio.1318+window must not resize merely because the native and reduced modes have the1319+same aspect ratio.13131320 13141321 Record transition request-to-configured-IDR time and Portal13151322 configured-IDR-to-first-presentation time separately. These are recovery
@@ -1374,7 +1381,8 @@ Only after the headless gate passes:
13741381 the exact `host-gamescope-probe.toml` that will be installed.13751382 3. Install that validated file as `host.toml`; do not reconstruct or partially13761383 merge its strict node, encoder-factory, and render-node selection.1377-4. Leave resolution at 1280×800 and frame rate at 60.1384+4. Leave width and height absent for native resolution; retain frame rate 60 as1385+ the explicit encoder ceiling unless a different measured cap is under test.13781386 5. Start the service and confirm the capture queue is bounded to one frame.13791387 6. Connect from the Mac with the Bazzite host's new development node ID.13801388 7. Enable the service only after the attached and headless capture probes pass.
@@ -1441,7 +1449,6 @@ sudo libinput debug-events --device "$pointer_node"
14411449 target/debug/sigil-probe \14421450 --node-id <host-node-id> \14431451 --frames 120 \1444- --expect-size 1280x800 \14451452 --pointer-smoke14461453 ```14471454
@@ -1465,7 +1472,6 @@ cargo build --locked -p sigil-host --bin sigil-probe
14651472 target/debug/sigil-probe \14661473 --node-id <host-node-id> \14671474 --frames 120 \1468- --expect-size 1280x800 \14691475 --gamepad-smoke14701476 ```14711477