* refactor(sigil): probe GStreamer properties programmatically Add a runtime-loaded GObject property probe and route GstVA preflight through a bounded Sigil command instead of parsing gst-inspect formatting. * fix(sigil): own loaded GStreamer symbols safely Copy C function pointers while retaining their runtime libraries, avoiding a self-referential libloading structure on Linux. * test(sigil): use the property probe in hardware admission Route UAT node/factory selection through Sigil’s bounded GObject preflight, retain registry-only factory discovery, and document multi-GPU selection without property-text parsing. * docs(agents): require real hardware connection tests Make the bounded dev-auth Portal-to-UMPC Gamescope session and authenticated-service restoration explicit completion gates for behavior-affecting changes. * fix(sigil): bound enum metadata before ABI traversal Reject empty, null, or unreasonably large GObject enum metadata before constructing the rate-control value slice.
- commit
- 125ce65e879e8d7bc0a785d650d30b04d8f7dc9e
- parent
- f6cc570a803c3d7469b1369609df1cc07f85b901
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | AGENTS.md | 12 | + |
| M | Cargo.lock | 13 | +- |
| M | crates/ | 1 | + |
| M | crates/ | 65 | ++++++ |
| A | crates/ | 340 | ++++++++++++++++++++++++++++++ |
| M | crates/ | 2 | + |
| M | crates/ | 152 | ++------------ |
| M | docs/ | 12 | +- |
| M | docs/ | 9 | +- |
| M | docs/ | 27 | ++- |
| M | scripts/ | 97 | +++------ |
| M | scripts/ | 59 | +++-- |
@@ -181,6 +181,18 @@ Sigil host.
181181 --minimum-fps 55182182 ```183183 184+- Any change that can affect Sigil startup, capture, encoding, transport,185+ decoding, A/V, input, or session behavior requires a real Portal -> UMPC186+ Gamescope connection from the exact candidate commit. A successful config187+ check, capture probe, headless probe, or loopback is supporting evidence, not188+ a substitute for that end-to-end session. For development hardware testing,189+ build Sigil with `demo-auth-bypass`, run it with190+ `--dev-allow-unauthorized` and a bounded `--max-runtime-seconds`, and connect191+ a debug Portal with `--dev-connect`. Verify the affected live diagnostics192+ and interaction path, then stop the bypass process, undo temporary Gamescope193+ controls, and restore and verify the ordinary authenticated194+ `sigil-host.service` before calling the test complete.195+184196 - On Linux with NVIDIA, set `WEBKIT_DISABLE_DMABUF_RENDERER=1` for Portal.185197 - Preserve `/Users/dami/Developer/sigil` untouched; it is the inherited source,186198 not this working repository.@@ -3458,7 +3458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
34583458 checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"34593459 dependencies = [34603460 "gtk-sys",3461- "libloading",3461+ "libloading 0.7.4",34623462 "once_cell",34633463 ]34643464 @@ -3487,6 +3487,16 @@ dependencies = [
34873487 "winapi",34883488 ]34893489 3490+[[package]]3491+name = "libloading"3492+version = "0.8.9"3493+source = "registry+https://github.com/rust-lang/crates.io-index"3494+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"3495+dependencies = [3496+ "cfg-if",3497+ "windows-link 0.2.1",3498+]3499+34903500 [[package]]34913501 name = "libredox"34923502 version = "0.1.18"@@ -5704,6 +5714,7 @@ dependencies = [
57045714 "iroh",57055715 "iroh-moq",57065716 "libc",5717+ "libloading 0.8.9",57075718 "moq-json",57085719 "moq-net",57095720 "serde",@@ -51,6 +51,7 @@ gstreamer = { version = "=0.24.5", optional = true }
5151 gstreamer-app = { version = "=0.24.5", optional = true }5252 gstreamer-video = { version = "=0.24.5", optional = true }5353 input-linux = "0.7.1"54+libloading = "0.8"5455 x11rb = { version = "0.13.2", default-features = false }5556 5657 [dev-dependencies]@@ -201,6 +201,24 @@ enum ConfigCommand {
201201 enum CaptureCommand {202202 /// Consume a finite number of frames without starting iroh.203203 Probe(CaptureProbeArgs),204+ /// Validate one GstVA encoder against an exact render node.205+ EncoderPreflight(EncoderPreflightArgs),206+}207+208+209+struct EncoderPreflightArgs {210+ 211+ vaapi_encoder: String,212+ 213+ vaapi_render_node: PathBuf,214+ 215+ rate_control: Vec<RateControlArg>,216+}217+218+219+enum RateControlArg {220+ Cbr,221+ Cqp,204222 }205223 206224 @@ -588,6 +606,23 @@ async fn config_command(command: ConfigCommand) -> Result<()> {
588606 589607 async fn capture_command(command: CaptureCommand) -> Result<()> {590608 match command {609+ CaptureCommand::EncoderPreflight(args) => {610+ let rate_controls = args611+ .rate_control612+ .into_iter()613+ .map(|rate_control| match rate_control {614+ RateControlArg::Cbr => crate::config::VaapiRateControl::Cbr,615+ RateControlArg::Cqp => crate::config::VaapiRateControl::Cqp,616+ })617+ .collect::<Vec<_>>();618+ source::probe_encoder_properties(619+ &args.vaapi_encoder,620+ &args.vaapi_render_node,621+ &rate_controls,622+ )?;623+ println!("encoder_preflight=ok");624+ Ok(())625+ }591626 CaptureCommand::Probe(args) => {592627 ensure!(593628 (1..=36_000).contains(&args.frames),@@ -1207,6 +1242,36 @@ mod tests {
12071242 assert!(validate_capture_probe_args(&args(Some(f64::NAN)), 60).is_err());12081243 }12091244 1245+ 1246+ fn encoder_preflight_requires_an_exact_pair_and_rate_control() {1247+ let valid = [1248+ "sigil",1249+ "capture",1250+ "encoder-preflight",1251+ "--vaapi-encoder",1252+ "varenderD129h264enc",1253+ "--vaapi-render-node",1254+ "/dev/dri/renderD129",1255+ "--rate-control",1256+ "cbr",1257+ "--rate-control",1258+ "cqp",1259+ ];1260+ assert!(Cli::try_parse_from(valid).is_ok());1261+ assert!(1262+ Cli::try_parse_from([1263+ "sigil",1264+ "capture",1265+ "encoder-preflight",1266+ "--vaapi-encoder",1267+ "vah264enc",1268+ "--vaapi-render-node",1269+ "/dev/dri/renderD128",1270+ ])1271+ .is_err()1272+ );1273+ }1274+12101275 12111276 fn direct_serve_requires_identity_and_source() {12121277 assert!(Cli::try_parse_from(["sigil", "serve"]).is_err());@@ -0,0 +1,340 @@
1+use std::path::Path;2+3+use anyhow::Result;4+5+use crate::config::VaapiRateControl;6+7+8+mod linux {9+ use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};10+ use std::mem::MaybeUninit;11+ use std::ptr;12+13+ use anyhow::{Context, Result, ensure};14+ use libloading::Library;15+16+ use super::{Path, VaapiRateControl};17+18+ const G_PARAM_READABLE: u32 = 1 << 0;19+ const G_PARAM_WRITABLE: u32 = 1 << 1;20+ const G_TYPE_ENUM: usize = 12 << 2;21+22+ 23+ struct GTypeInstance {24+ g_class: *mut c_void,25+ }26+27+ 28+ struct GParamSpec {29+ g_type_instance: GTypeInstance,30+ name: *const c_char,31+ flags: u32,32+ value_type: usize,33+ owner_type: usize,34+ }35+36+ 37+ union GValueData {38+ v_int: c_int,39+ v_uint: c_uint,40+ v_long: isize,41+ v_ulong: usize,42+ v_int64: i64,43+ v_uint64: u64,44+ v_float: f32,45+ v_double: f64,46+ v_pointer: *mut c_void,47+ }48+49+ 50+ struct GValue {51+ g_type: usize,52+ data: [GValueData; 2],53+ }54+55+ 56+ struct GTypeClass {57+ g_type: usize,58+ }59+60+ 61+ struct GEnumValue {62+ value: c_int,63+ value_name: *const c_char,64+ value_nick: *const c_char,65+ }66+67+ 68+ struct GEnumClass {69+ g_type_class: GTypeClass,70+ minimum: c_int,71+ maximum: c_int,72+ n_values: c_uint,73+ values: *mut GEnumValue,74+ }75+76+ 77+ struct GError {78+ domain: c_uint,79+ code: c_int,80+ message: *mut c_char,81+ }82+83+ type GstInitCheck =84+ unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char, *mut *mut GError) -> c_int;85+ type GstElementFactoryMake = unsafe extern "C" fn(*const c_char, *const c_char) -> *mut c_void;86+ type GstObjectUnref = unsafe extern "C" fn(*mut c_void);87+ type GObjectClassFindProperty =88+ unsafe extern "C" fn(*mut c_void, *const c_char) -> *mut GParamSpec;89+ type GObjectGetProperty = unsafe extern "C" fn(*mut c_void, *const c_char, *mut GValue);90+ type GValueInit = unsafe extern "C" fn(*mut GValue, usize) -> *mut GValue;91+ type GValueGetString = unsafe extern "C" fn(*const GValue) -> *const c_char;92+ type GValueUnset = unsafe extern "C" fn(*mut GValue);93+ type GTypeFundamental = unsafe extern "C" fn(usize) -> usize;94+ type GTypeClassRef = unsafe extern "C" fn(usize) -> *mut c_void;95+ type GTypeClassUnref = unsafe extern "C" fn(*mut c_void);96+ type GErrorFree = unsafe extern "C" fn(*mut GError);97+98+ struct GstreamerApi {99+ _gstreamer: Library,100+ _gobject: Library,101+ _glib: Library,102+ gst_init_check: GstInitCheck,103+ gst_element_factory_make: GstElementFactoryMake,104+ gst_object_unref: GstObjectUnref,105+ g_object_class_find_property: GObjectClassFindProperty,106+ g_object_get_property: GObjectGetProperty,107+ g_value_init: GValueInit,108+ g_value_get_string: GValueGetString,109+ g_value_unset: GValueUnset,110+ g_type_fundamental: GTypeFundamental,111+ g_type_class_ref: GTypeClassRef,112+ g_type_class_unref: GTypeClassUnref,113+ g_error_free: GErrorFree,114+ }115+116+ impl GstreamerApi {117+ unsafe fn load() -> Result<Self> {118+ let gstreamer = unsafe { Library::new("libgstreamer-1.0.so.0") }119+ .context("loading libgstreamer-1.0.so.0")?;120+ let gobject = unsafe { Library::new("libgobject-2.0.so.0") }121+ .context("loading libgobject-2.0.so.0")?;122+ let glib =123+ unsafe { Library::new("libglib-2.0.so.0") }.context("loading libglib-2.0.so.0")?;124+125+ // SAFETY: every symbol uses the public C ABI declared by the matching126+ // versioned runtime SONAME. The libraries remain owned by this value127+ // for at least as long as all function symbols below.128+ unsafe {129+ Ok(Self {130+ gst_init_check: *gstreamer.get(b"gst_init_check\0")?,131+ gst_element_factory_make: *gstreamer.get(b"gst_element_factory_make\0")?,132+ gst_object_unref: *gstreamer.get(b"gst_object_unref\0")?,133+ g_object_class_find_property: *gobject134+ .get(b"g_object_class_find_property\0")?,135+ g_object_get_property: *gobject.get(b"g_object_get_property\0")?,136+ g_value_init: *gobject.get(b"g_value_init\0")?,137+ g_value_get_string: *gobject.get(b"g_value_get_string\0")?,138+ g_value_unset: *gobject.get(b"g_value_unset\0")?,139+ g_type_fundamental: *gobject.get(b"g_type_fundamental\0")?,140+ g_type_class_ref: *gobject.get(b"g_type_class_ref\0")?,141+ g_type_class_unref: *gobject.get(b"g_type_class_unref\0")?,142+ g_error_free: *glib.get(b"g_error_free\0")?,143+ _gstreamer: gstreamer,144+ _gobject: gobject,145+ _glib: glib,146+ })147+ }148+ }149+150+ unsafe fn property(&self, object: *mut c_void, name: &CStr) -> Result<&GParamSpec> {151+ // SAFETY: GstElement begins with a GTypeInstance, whose class pointer152+ // is valid while the element is alive. GObject returns a class-owned153+ // GParamSpec that remains valid for the lifetime of that class.154+ let class = unsafe { (*(object.cast::<GTypeInstance>())).g_class };155+ let property = unsafe { (self.g_object_class_find_property)(class, name.as_ptr()) };156+ ensure!(157+ !property.is_null(),158+ "encoder has no {} property",159+ name.to_string_lossy()160+ );161+ Ok(unsafe { &*property })162+ }163+164+ unsafe fn string_property(165+ &self,166+ object: *mut c_void,167+ name: &CStr,168+ property: &GParamSpec,169+ ) -> Result<String> {170+ let mut value = MaybeUninit::<GValue>::zeroed();171+ let value = value.as_mut_ptr();172+ unsafe {173+ (self.g_value_init)(value, property.value_type);174+ (self.g_object_get_property)(object, name.as_ptr(), value);175+ }176+ let observed = unsafe { (self.g_value_get_string)(value) };177+ let result = ensure_non_null_string(observed, name);178+ unsafe { (self.g_value_unset)(value) };179+ result180+ }181+182+ unsafe fn enum_nicks(&self, property: &GParamSpec) -> Result<Vec<String>> {183+ ensure!(184+ unsafe { (self.g_type_fundamental)(property.value_type) } == G_TYPE_ENUM,185+ "encoder rate-control property is not an enum"186+ );187+ let class = unsafe { (self.g_type_class_ref)(property.value_type) };188+ ensure!(189+ !class.is_null(),190+ "loading encoder rate-control enum metadata"191+ );192+ let class = class.cast::<GEnumClass>();193+ let count = unsafe { (*class).n_values as usize };194+ let values = unsafe { (*class).values };195+ ensure!(196+ (1..=1024).contains(&count) && !values.is_null(),197+ "encoder rate-control enum metadata is empty or unreasonably large"198+ );199+ let values = unsafe { std::slice::from_raw_parts(values, count) };200+ let mut nicks = Vec::with_capacity(values.len());201+ for value in values {202+ if !value.value_nick.is_null() {203+ nicks.push(204+ unsafe { CStr::from_ptr(value.value_nick) }205+ .to_str()206+ .context("encoder rate-control nickname is not UTF-8")?207+ .to_owned(),208+ );209+ }210+ }211+ unsafe { (self.g_type_class_unref)(class.cast()) };212+ Ok(nicks)213+ }214+ }215+216+ fn ensure_non_null_string(value: *const c_char, name: &CStr) -> Result<String> {217+ ensure!(218+ !value.is_null(),219+ "encoder {} property is null",220+ name.to_string_lossy()221+ );222+ Ok(unsafe { CStr::from_ptr(value) }223+ .to_str()224+ .with_context(|| format!("encoder {} property is not UTF-8", name.to_string_lossy()))?225+ .to_owned())226+ }227+228+ pub(super) fn probe(229+ factory: &str,230+ expected_device_path: &Path,231+ rate_controls: &[VaapiRateControl],232+ ) -> Result<()> {233+ let factory = CString::new(factory).context("VA encoder factory contains NUL")?;234+ let expected_device_path = expected_device_path235+ .to_str()236+ .context("VAAPI render node is not UTF-8")?;237+ let device_path = c"device-path";238+ let rate_control = c"rate-control";239+240+ // SAFETY: all interaction is through the versioned public GStreamer and241+ // GObject C ABIs. Host preflight invokes this probe in a bounded child,242+ // so plugin faults cannot unwind through the daemon.243+ unsafe {244+ let api = GstreamerApi::load()?;245+ let mut error = ptr::null_mut();246+ if (api.gst_init_check)(ptr::null_mut(), ptr::null_mut(), &mut error) == 0 {247+ let message = if error.is_null() || (*error).message.is_null() {248+ "unknown GStreamer initialization error".to_owned()249+ } else {250+ CStr::from_ptr((*error).message)251+ .to_string_lossy()252+ .into_owned()253+ };254+ if !error.is_null() {255+ (api.g_error_free)(error);256+ }257+ anyhow::bail!("initializing GStreamer: {message}");258+ }259+260+ let element = (api.gst_element_factory_make)(factory.as_ptr(), ptr::null());261+ ensure!(262+ !element.is_null(),263+ "creating configured VA encoder factory {:?}",264+ factory.to_string_lossy()265+ );266+267+ let result = (|| {268+ let device_property = api.property(element, device_path)?;269+ ensure!(270+ device_property.flags & G_PARAM_READABLE != 0,271+ "encoder device-path property is not readable"272+ );273+ let observed_device_path =274+ api.string_property(element, device_path, device_property)?;275+ ensure!(276+ observed_device_path == expected_device_path,277+ "configured VA encoder factory {:?} uses device-path {observed_device_path:?}, expected {expected_device_path:?}; on multi-GPU hosts select the per-device GstVA factory reported for that render node",278+ factory.to_string_lossy()279+ );280+281+ for property_name in [282+ c"aud",283+ c"b-frames",284+ c"key-int-max",285+ rate_control,286+ c"ref-frames",287+ c"target-usage",288+ ] {289+ let property = api.property(element, property_name)?;290+ ensure!(291+ property.flags & G_PARAM_WRITABLE != 0,292+ "encoder {} property is not writable",293+ property_name.to_string_lossy()294+ );295+ }296+297+ let rate_control_property = api.property(element, rate_control)?;298+ let rate_control_nicks = api.enum_nicks(rate_control_property)?;299+ for required in rate_controls {300+ let (nickname, mode_properties): (&str, &[&CStr]) = match required {301+ VaapiRateControl::Cbr => ("cbr", &[c"bitrate"]),302+ VaapiRateControl::Cqp => ("cqp", &[c"qpi", c"qpp"]),303+ };304+ ensure!(305+ rate_control_nicks.iter().any(|value| value == nickname),306+ "configured VA encoder factory {:?} does not support {nickname} rate control",307+ factory.to_string_lossy()308+ );309+ for property_name in mode_properties {310+ let property = api.property(element, property_name)?;311+ ensure!(312+ property.flags & G_PARAM_WRITABLE != 0,313+ "encoder {} property is not writable",314+ property_name.to_string_lossy()315+ );316+ }317+ }318+ Ok(())319+ })();320+ (api.gst_object_unref)(element);321+ result322+ }323+ }324+}325+326+pub(crate) fn probe_encoder_properties(327+ factory: &str,328+ expected_device_path: &Path,329+ rate_controls: &[VaapiRateControl],330+) -> Result<()> {331+ 332+ {333+ linux::probe(factory, expected_device_path, rate_controls)334+ }335+ 336+ {337+ let _ = (factory, expected_device_path, rate_controls);338+ anyhow::bail!("GstVA encoder property probing requires Linux")339+ }340+}@@ -18,6 +18,7 @@ use crate::config::{GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig
1818 1919 mod annexb;2020 mod external;21+mod gstreamer_properties;2122 mod in_process;2223 mod preflight;2324 @@ -27,6 +28,7 @@ use external::{
2728 forward_annex_b_stream, spawn_gamescope_pipewire_with_target,2829 spawn_gamescope_pipewire_with_target_and_shutdown,2930 };31+pub(crate) use gstreamer_properties::probe_encoder_properties;3032 3133 pub(crate) use in_process::EncoderControlTestHarness;3234 @@ -51,94 +51,38 @@ pub(super) async fn preflight_gamescope_static(config: &HostConfig) -> Result<()
5151 "GStreamer element {element:?} is unavailable: {}",5252 diagnostic(&output.stderr)5353 );54- if element == pipewire.vaapi_encoder {55- validate_encoder_inspection(&output.stdout, pipewire)?;56- }5754 }55+ preflight_encoder_properties(pipewire).await?;5856 5957 Ok(())6058 }6159 62-fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {63- let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;64- let lines: Vec<&str> = output.lines().collect();65- let device_block = gst_property_block(&lines, "device-path")66- .context("configured VA encoder does not report a device-path property")?;67- let expected = config.vaapi_render_node.to_string_lossy();68- let expected_default = format!("Default: \"{expected}\"");69- ensure!(70- device_block.contains(&expected_default),71- "configured VA encoder {:?} does not report expected device-path {}",72- config.vaapi_encoder,73- config.vaapi_render_node.display()74- );75- for property in [76- "aud",77- "b-frames",78- "key-int-max",79- "rate-control",80- "ref-frames",81- "target-usage",82- ] {83- let block = gst_property_block(&lines, property).with_context(|| {84- format!(85- "configured VA encoder {:?} has no {property} property",86- config.vaapi_encoder87- )88- })?;89- ensure!(90- block.contains("writable") || block.contains("Writable"),91- "configured VA encoder {:?} property {property} is not writable",92- config.vaapi_encoder93- );94- }95- let rate_control =96- gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");97- let expected_rate_control = match config.rate_control {60+async fn preflight_encoder_properties(config: &GamescopePipewireConfig) -> Result<()> {61+ let executable = std::env::current_exe().context("resolving the running Sigil executable")?;62+ let rate_control = match config.rate_control {9863 VaapiRateControl::Cbr => "cbr",9964 VaapiRateControl::Cqp => "cqp",10065 };66+ let mut command = Command::new(executable);67+ command.args([68+ "capture",69+ "encoder-preflight",70+ "--vaapi-encoder",71+ config.vaapi_encoder.as_str(),72+ "--vaapi-render-node",73+ ]);74+ command.arg(&config.vaapi_render_node);75+ command.args(["--rate-control", rate_control]);76+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;10177 ensure!(102- rate_control.contains(expected_rate_control),103- "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",104- config.vaapi_encoder78+ output.status.success(),79+ "configured VA encoder {:?} failed programmatic property preflight: {}",80+ config.vaapi_encoder,81+ diagnostic(&output.stderr)10582 );106- let mode_properties: &[&str] = match config.rate_control {107- VaapiRateControl::Cbr => &["bitrate"],108- VaapiRateControl::Cqp => &["qpi", "qpp"],109- };110- for property in mode_properties {111- let block = gst_property_block(&lines, property).with_context(|| {112- format!(113- "configured VA encoder {:?} has no {property} property",114- config.vaapi_encoder115- )116- })?;117- ensure!(118- block.contains("writable") || block.contains("Writable"),119- "configured VA encoder {:?} property {property} is not writable",120- config.vaapi_encoder121- );122- }12383 Ok(())12484 }12585 126-fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {127- let start = lines.iter().position(|line| {128- line.strip_prefix(" ").is_some_and(|line| {129- line.starts_with(property) && line[property.len()..].starts_with(' ')130- })131- })?;132- let end = lines[start + 1..]133- .iter()134- .position(|line| {135- line.strip_prefix(" ")136- .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())137- })138- .map_or(lines.len(), |offset| start + 1 + offset);139- Some(lines[start..end].join("\n"))140-}141-14286 pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {14387 let metadata = fs::symlink_metadata(path)14488 .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;@@ -208,61 +152,3 @@ fn validate_render_node(path: &std::path::Path) -> Result<()> {
208152 })?;209153 Ok(())210154 }211-212-213-mod tests {214- use super::super::tests::gamescope_config;215- use super::*;216-217- 218- fn requires_encoder_to_report_configured_render_node() {219- let config = gamescope_config();220- let pipewire = config.gamescope_pipewire.as_ref().unwrap();221- let inspect = br#"222-Element Properties:223- device-path : The DRM device path used for VA operation224- flags: readable225- String. Default: "/dev/dri/renderD128"226- aud : Insert an AU delimiter for each frame227- flags: readable, writable228- b-frames : Number of B-frames229- flags: readable, writable230- bitrate : Target bitrate231- flags: readable, writable232- key-int-max : Maximum keyframe distance233- flags: readable, writable234- rate-control : Rate control mode235- flags: readable, writable236- Enum. Default: cbr237- (2): cbr - Constant Bitrate238- ref-frames : Number of reference frames239- flags: readable, writable240- target-usage : Speed and quality tradeoff241- flags: readable, writable242-"#;243- validate_encoder_inspection(inspect, pipewire).unwrap();244-245- let index = inspect246- .windows(b"renderD128".len())247- .position(|window| window == b"renderD128")248- .unwrap();249- let mut wrong = inspect.to_vec();250- wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");251- assert!(validate_encoder_inspection(&wrong, pipewire).is_err());252-253- let mut cqp = gamescope_config();254- let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();255- pipewire.vaapi_encoder = "vah264lpenc".into();256- pipewire.rate_control = VaapiRateControl::Cqp;257- pipewire.bitrate_kbps = None;258- pipewire.quantizer = Some(24);259- let cqp_inspect = String::from_utf8_lossy(inspect)260- .replace("Default: cbr", "Default: cqp")261- .replace("(2): cbr", "(16): cqp")262- .replace(263- " ref-frames",264- " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",265- );266- validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();267- }268-}@@ -1132,10 +1132,11 @@ this dedicated group single-purpose. Do not add
11321132 `PrivateDevices=true` to the user unit because that would deliberately hide the11331133 validated device.11341134 1135-Set `vaapi_encoder` to one factory whose inspected read-only `device-path`1136-exactly equals `render_node`. This is deliberate: GstVA selects a DRM device by1137-factory and exposes `device-path` as read-only, so Sigil verifies the factory's1138-reported device before starting capture. Set `rate_control = "cbr"` only when1135+Set `vaapi_encoder` to one factory whose programmatically queried read-only1136+`device-path` exactly equals `render_node`. This is deliberate: GstVA selects a1137+DRM device by factory and exposes `device-path` as read-only, so Sigil verifies1138+the live element's reported device before starting capture. Set1139+`rate_control = "cbr"` only when11391140 that factory advertises CBR; otherwise select a low-power factory that11401141 advertises CQP and use `rate_control = "cqp"`. Do not silently substitute a11411142 different factory, render node, or software encoder.@@ -1592,7 +1593,8 @@ unchanged, and the host log must contain one `Gamescope Xwayland pointer
15921593 feedback reconnected` event per restart. The packaged hardware UAT performs15931594 this sequence after its fixed-mode session checks. That runner independently15941595 enumerates accessible DRM render nodes and matches the exact GstVA H.2641595-factory by its inspected `device-path` and required CBR/CQP properties; it1596+factory by its programmatically queried `device-path` and required CBR/CQP1597+properties; it15961598 never assumes a kernel driver, `renderD128`, or the generic `vah264enc` factory.15971599 If the panel's resolved native15981600 mode is the same 1280x800 mode as the fixed performance leg, the native-config@@ -59,13 +59,14 @@ explicitly and rerun:
5959 ```6060 6161 The two override options are inseparable, and the requested pair must pass the62-same device, factory binding, property, CBR, and CQP checks as automatic62+same device, factory binding, programmatic property, CBR, and CQP checks as automatic6363 selection. The runner rejects reused output directories and concurrent hardware6464 runs.6565 Before generating its isolated configs, it enumerates accessible DRM render66-nodes and matches each one to the exact dynamically registered GstVA H.26467-factory whose read-only `device-path` names that node. No kernel-driver name is68-required. The chosen factory must advertise both CBR (for66+nodes and uses Sigil's GObject probe to match each one to the exact dynamically67+registered GstVA H.264 factory whose read-only `device-path` names that node.68+No kernel-driver name or `gst-inspect` property formatting is required. The69+chosen factory must advertise both CBR (for6970 the in-process fixed/native legs) and CQP (for the external compatibility leg).7071 The gate therefore does not assume either `/dev/dri/renderD128` or the generic7172 `vah264enc` factory. The selected pair is recorded in `summary.env`.@@ -95,7 +95,8 @@ to the host identity represented by this file.
9595 9696 ## 3. Select the exact VA-API render node and GstVA H.264 factory9797 98-Sigil binds the encoder factory to its inspected read-only `device-path`; a98+Sigil binds the encoder factory to its programmatically queried read-only99+`device-path`; a99100 factory name alone is not enough on a multi-GPU machine. The following chooses100101 automatically only when there is one usable capability-backed node/factory101102 pair, independent of its kernel driver. Set@@ -132,6 +133,26 @@ bounded_gst_inspect() {
132133 rm -f -- "$output_path"133134 }134135 136+bounded_encoder_preflight() {137+ local node="$1"138+ local factory="$2"139+ local output_path140+ local output_size141+ output_path="$(mktemp "$inspection_dir/encoder-preflight.XXXXXX")"142+ if ! "$timeout_path" --signal=TERM --kill-after=2s 5s \143+ "$sigil" capture encoder-preflight \144+ --vaapi-encoder "$factory" --vaapi-render-node "$node" \145+ --rate-control cbr --rate-control cqp 2>&1 \146+ | head -c "$((inspect_max_bytes + 1))" >"$output_path"147+ then148+ rm -f -- "$output_path"149+ return 1150+ fi151+ output_size="$(wc -c <"$output_path" | tr -d '[:space:]')"152+ rm -f -- "$output_path"153+ test "$output_size" -le "$inspect_max_bytes"154+}155+135156 mapfile -t render_nodes < <(136157 for node in /dev/dri/renderD*; do137158 test -c "$node" && test -r "$node" && test -w "$node" && printf '%s\n' "$node"@@ -148,9 +169,7 @@ mapfile -t va_factories < <(
148169 eligible_pairs=()149170 for node in "${render_nodes[@]}"; do150171 for factory in "${va_factories[@]}"; do151- inspection="$(bounded_gst_inspect "$factory")" || continue152- grep -Fq "Default: \"$node\"" <<<"$inspection" || continue153- grep -Eq '\([0-9]+\): cbr([[:space:]]|$)' <<<"$inspection" || continue172+ bounded_encoder_preflight "$node" "$factory" || continue154173 eligible_pairs+=("$node $factory")155174 done156175 done@@ -3,6 +3,7 @@
33 set -euo pipefail44 55 GST_INSPECT_MAX_BYTES=10485766+ENCODER_PREFLIGHT_MAX_BYTES=104857667 78 run_bounded_gst_inspect() {89 local timeout_path="$1"@@ -21,54 +22,26 @@ run_bounded_gst_inspect() {
2122 [[ "$output_size" =~ ^[0-9]+$ && "$output_size" -le "$GST_INSPECT_MAX_BYTES" ]]2223 }2324 24-gst_property_block_contains() {25- local property="$1"26- local needle="$2"27-28- awk -v property="$property" -v needle="$needle" '29- $0 ~ ("^ " property "[[:space:]]+:") {30- inside = 131- }32- inside && index($0, needle) {33- found = 134- }35- inside && $0 !~ ("^ " property "[[:space:]]+:") \36- && $0 ~ /^ [^[:space:]].*[[:space:]]+:/ {37- exit38- }39- END { exit !found }40- '41-}42-43-gst_property_block_is_writable() {44- local property="$1"45-46- awk -v property="$property" '47- $0 ~ ("^ " property "[[:space:]]+:") {48- inside = 149- }50- inside && index(tolower($0), "writable") {51- found = 152- }53- inside && $0 !~ ("^ " property "[[:space:]]+:") \54- && $0 ~ /^ [^[:space:]].*[[:space:]]+:/ {55- exit56- }57- END { exit !found }58- '59-}60-61-gstva_factory_supports_uat() {62- local inspection="$1"63- local property25+run_bounded_encoder_preflight() {26+ local timeout_path="$1"27+ local sigil_path="$2"28+ local output_path="$3"29+ local factory="$4"30+ local render_node="$5"31+ local output_size6432 65- gst_property_block_contains rate-control cbr <<<"$inspection" || return 166- gst_property_block_contains rate-control cqp <<<"$inspection" || return 167- for property in \68- aud b-frames key-int-max rate-control ref-frames target-usage bitrate qpi qpp69- do70- gst_property_block_is_writable "$property" <<<"$inspection" || return 171- done33+ if ! "$timeout_path" --signal=TERM --kill-after=2s 5s \34+ "$sigil_path" capture encoder-preflight \35+ --vaapi-encoder "$factory" \36+ --vaapi-render-node "$render_node" \37+ --rate-control cbr --rate-control cqp 2>&1 \38+ | head -c "$((ENCODER_PREFLIGHT_MAX_BYTES + 1))" >"$output_path"39+ then40+ return 141+ fi42+ output_size="$(wc -c <"$output_path" | tr -d '[:space:]')"43+ [[ "$output_size" =~ ^[0-9]+$ \44+ && "$output_size" -le "$ENCODER_PREFLIGHT_MAX_BYTES" ]]7245 }7346 7447 classify_native_uat_mode() {@@ -86,17 +59,17 @@ classify_native_uat_mode() {
8659 }8760 8861 select_gstva_h264_encoder() {89- local gst_inspect_path="$1"90- local device_root="${2:-/dev/dri}"91- local require_character="${3:-true}"92- local override_node="${4:-}"93- local override_factory="${5:-}"94- local timeout_path="${6:?timeout command is required}"95- local output_root="${7:?bounded inspection output directory is required}"62+ local sigil_path="$1"63+ local gst_inspect_path="$2"64+ local device_root="${3:-/dev/dri}"65+ local require_character="${4:-true}"66+ local override_node="${5:-}"67+ local override_factory="${6:-}"68+ local timeout_path="${7:?timeout command is required}"69+ local output_root="${8:?bounded inspection output directory is required}"9670 local factory9771 local factories98- local factory_output99- local inspection72+ local probe_output10073 local candidate10174 local pair10275 local candidate_count=0@@ -127,16 +100,12 @@ select_gstva_h264_encoder() {
127100 128101 while IFS= read -r factory; do129102 [[ -n "$factory" ]] || continue130- factory_output="$output_root/gst-inspect.$factory"131- if ! LC_ALL=C run_bounded_gst_inspect \132- "$timeout_path" "$gst_inspect_path" "$factory_output" "$factory";103+ probe_output="$output_root/encoder-preflight.$(basename "$candidate").$factory"104+ if ! LC_ALL=C run_bounded_encoder_preflight \105+ "$timeout_path" "$sigil_path" "$probe_output" "$factory" "$candidate";133106 then134107 continue135108 fi136- inspection="$(<"$factory_output")"137- gst_property_block_contains device-path "Default: \"$candidate\"" \138- <<<"$inspection" || continue139- gstva_factory_supports_uat "$inspection" || continue140109 pair="$candidate $factory"141110 if [[ -n "$va_candidates" ]]; then142111 va_candidates="$va_candidates@@ -422,7 +391,7 @@ ffmpeg="$(command -v ffmpeg)"
422391 [[ -S "$XDG_RUNTIME_DIR/pipewire-0" ]]423392 selection_status=0424393 select_gstva_h264_encoder \425- "$gst_inspect" /dev/dri true \394+ "$sigil" "$gst_inspect" /dev/dri true \426395 "$render_node_override" "$va_encoder_override" \427396 "$timeout_command" "$private" || selection_status=$?428397 if [[ "$selection_status" -ne 0 ]]; then@@ -60,37 +60,44 @@ cat >"$bin/gst-inspect-1.0" <<'EOF'
6060 set -euo pipefail6161 6262 fixture="${SIGIL_TEST_FACTORY_FIXTURE:?}"63-if [[ "${SIGIL_TEST_GST_MODE:-}" == oversize-factory && $# -gt 0 ]]; then64- awk 'BEGIN { for (i = 0; i < 1048578; i++) printf "x" }'65- exit 066-fi6763 if [[ $# -eq 0 ]]; then6864 awk -F '\t' '{ printf "va: %s: fixture encoder\n", $1 }' "$fixture"6965 exit 07066 fi67+exit 168+EOF69+chmod 0755 "$bin/gst-inspect-1.0"70+cat >"$bin/sigil" <<'EOF'71+#!/usr/bin/env bash72+set -euo pipefail7173 72-record="$(awk -F '\t' -v factory="$1" '$1 == factory { print; exit }' "$fixture")"73-[[ -n "$record" ]] || exit 174-IFS=$'\t' read -r factory device modes completeness <<<"$record"75-printf '%s\n' \76- ' device-path : DRM render device' \77- ' flags: readable' \78- " String. Default: \"$device\"" \79- ' rate-control : Rate control mode' \80- ' flags: readable, writable'81-case ",$modes," in82- *,cqp,*) printf '%s\n' ' (1): cqp' ;;83-esac84-case ",$modes," in85- *,cbr,*) printf '%s\n' ' (2): cbr' ;;86-esac87-for property in aud b-frames key-int-max ref-frames target-usage bitrate qpi qpp; do88- [[ "$completeness" == "missing-$property" ]] && continue89- printf ' %-20s: fixture property\n' "$property"90- printf '%s\n' ' flags: readable, writable'74+fixture="${SIGIL_TEST_FACTORY_FIXTURE:?}"75+[[ "$1" == capture && "$2" == encoder-preflight ]]76+shift 277+factory=''78+device=''79+required_modes=''80+while [[ $# -gt 0 ]]; do81+ case "$1" in82+ --vaapi-encoder) factory="$2"; shift 2 ;;83+ --vaapi-render-node) device="$2"; shift 2 ;;84+ --rate-control) required_modes="${required_modes:+$required_modes,}$2"; shift 2 ;;85+ *) exit 64 ;;86+ esac9187 done88+if [[ "${SIGIL_TEST_GST_MODE:-}" == oversize-factory ]]; then89+ awk 'BEGIN { for (i = 0; i < 1048578; i++) printf "x" }'90+ exit 091+fi92+[[ "$required_modes" == cbr,cqp ]]93+awk -F '\t' -v factory="$factory" -v device="$device" '94+ $1 == factory && $2 == device && $4 == "complete" \95+ && ("," $3 ",") ~ /,cbr,/ && ("," $3 ",") ~ /,cqp,/ { found = 1 }96+ END { exit !found }97+' "$fixture"98+printf '%s\n' encoder_preflight=ok9299 EOF93-chmod 0755 "$bin/gst-inspect-1.0"100+chmod 0755 "$bin/sigil"94101 cat >"$bin/timeout" <<'EOF'95102 #!/usr/bin/env bash96103 set -euo pipefail@@ -109,7 +116,7 @@ export SIGIL_TEST_FACTORY_FIXTURE="$factory_fixture"
109116 110117 select_fixture() {111118 select_gstva_h264_encoder \112- "$bin/gst-inspect-1.0" "$device_root" false \119+ "$bin/sigil" "$bin/gst-inspect-1.0" "$device_root" false \113120 "${1:-}" "${2:-}" "$bin/timeout" "$fixture"114121 }115122 @@ -221,7 +228,7 @@ fi
221228 rm -f -- "$device_root/renderD130"222229 install -m 0600 /dev/null "$device_root/renderD130"223230 if select_gstva_h264_encoder \224- "$bin/gst-inspect-1.0" "$device_root" true '' '' \231+ "$bin/sigil" "$bin/gst-inspect-1.0" "$device_root" true '' '' \225232 "$bin/timeout" "$fixture"226233 then227234 fail 'a regular file was accepted as a production DRM character device'