diff --git a/examples/fake_gamescope.rs b/examples/fake_gamescope.rs new file mode 100644 index 0000000..27db1b1 --- /dev/null +++ b/examples/fake_gamescope.rs @@ -0,0 +1,740 @@ +//! Imitates gamescope's PipeWire producer for local end-to-end tests (spec 9.3). +//! +//! Mirrors `src/pipewire.cpp` of gamescope 3.16.28: node properties, the four EnumFormat +//! objects with the custom `requested_size`/`focus_appid` keys, `calculate_capture_size`, +//! MemFd buffers, CORRUPTED marking of buffers whose size does not match the render size, +//! and renegotiation of the format when the capture size changes. +//! +//! cargo run --release --example fake_gamescope -- --name gamescope --width 3840 --height 2160 + +use std::cell::RefCell; +use std::io::{Cursor, IsTerminal}; +use std::rc::Rc; +use std::time::Duration; + +use clap::Parser; +use gamescope_hyperhdr::capture::{ + requested_size_of, SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID, SPA_FORMAT_VIDEO_REQUESTED_SIZE, + SPA_META_REQUESTED_SIZE_SCALE, +}; +use pipewire as pw; +use pw::properties::properties; +use pw::spa; +use pw::stream::{Stream, StreamFlags, StreamRc, StreamState}; +use spa::param::format::{FormatProperties, MediaSubtype, MediaType}; +use spa::param::video::{VideoFormat, VideoInfoRaw}; +use spa::param::ParamType; +use spa::pod::deserialize::PodDeserializer; +use spa::pod::serialize::PodSerializer; +use spa::pod::{ChoiceValue, Object, Pod, Property, PropertyFlags, Value}; +use spa::sys as spa_sys; +use spa::utils::{Choice, ChoiceEnum, ChoiceFlags, Direction, Fraction, Id, Rectangle, SpaTypes}; +use tracing::{debug, error, info, warn}; + +const DRM_FORMAT_MOD_LINEAR: i64 = 0; + +#[derive(Parser)] +#[command(about = "Fake gamescope PipeWire producer")] +struct Opt { + /// node.name of the stream + #[arg(long, default_value = "gamescope")] + name: String, + /// Output (display) width + #[arg(long, default_value_t = 3840)] + width: u32, + /// Output (display) height + #[arg(long, default_value_t = 2160)] + height: u32, + /// Frames per second while streaming + #[arg(long, default_value_t = 60)] + fps: u32, + /// Stop producing frames after the first few correctly sized ones (static picture) + #[arg(long = "static")] + static_picture: bool, + /// Ignore requested_size, like a gamescope without the extension + #[arg(long)] + ignore_requested: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Dim { + w: u32, + h: u32, +} + +#[derive(Debug, Clone, Copy)] +struct Negotiated { + format: VideoFormat, + size: Dim, + stride: u32, + dmabuf: bool, +} + +impl Negotiated { + /// `stride * height`, plus the chroma plane for NV12. + fn byte_size(&self, height: u32) -> u32 { + let mut size = self.stride * height; + if self.format == VideoFormat::NV12 { + size += self.stride * height.div_ceil(2); + } + size + } +} + +struct Fake { + output: Dim, + requested: Dim, + capture: Dim, + format: Option, + streaming: bool, + seq: u64, + frame_no: u64, + good: u64, + corrupted: u64, + out_of_buffers: u64, + static_picture: bool, + ignore_requested: bool, +} + +impl Fake { + /// gamescope's `calculate_capture_size`, including its float math. + fn calculate_capture_size(&mut self) { + let (ow, oh) = (self.output.w, self.output.h); + let (rw, rh) = (self.requested.w, self.requested.h); + let (mut cw, mut ch) = (ow, oh); + if rw > 0 && rh > 0 && (ow > rw || oh > rh) { + let ratio_w = rw as f32 / ow as f32; + let ratio_h = rh as f32 / oh as f32; + if ratio_w <= ratio_h { + cw = rw; + ch = (ratio_w * oh as f32).ceil() as u32; + } else { + cw = (ratio_h * ow as f32).ceil() as u32; + ch = rh; + } + } + self.capture = Dim { + w: cw.next_multiple_of(2), + h: ch.next_multiple_of(2), + }; + } +} + +/// A MemFd buffer; `texture` is the size frames are rendered at (gamescope's CVulkanTexture). +struct ShmBuffer { + fd: i32, + ptr: *mut u8, + len: usize, + video: Negotiated, + texture: Dim, +} + +fn serialize(object: Object) -> Vec { + PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(object)) + .expect("serializing into a Vec cannot fail") + .0 + .into_inner() +} + +fn id_enum(default: u32, alternatives: &[u32]) -> Value { + Value::Choice(ChoiceValue::Id(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Enum { + default: Id(default), + alternatives: alternatives.iter().map(|v| Id(*v)).collect(), + }, + ))) +} + +/// gamescope's `build_format_params`: BGRx and NV12, each with and without a modifier. +fn build_format_params(capture: Dim) -> Vec> { + let mut params = Vec::new(); + for format in [VideoFormat::BGRx, VideoFormat::NV12] { + for with_modifier in [true, false] { + let mut properties = vec![ + Property::new( + FormatProperties::MediaType.as_raw(), + Value::Id(Id(MediaType::Video.as_raw())), + ), + Property::new( + FormatProperties::MediaSubtype.as_raw(), + Value::Id(Id(MediaSubtype::Raw.as_raw())), + ), + Property::new( + FormatProperties::VideoFormat.as_raw(), + Value::Id(Id(format.as_raw())), + ), + Property::new( + FormatProperties::VideoSize.as_raw(), + Value::Rectangle(Rectangle { + width: capture.w, + height: capture.h, + }), + ), + Property::new( + FormatProperties::VideoFramerate.as_raw(), + Value::Fraction(Fraction { num: 0, denom: 1 }), + ), + Property::new( + SPA_FORMAT_VIDEO_REQUESTED_SIZE, + Value::Choice(ChoiceValue::Rectangle(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Range { + default: Rectangle { + width: 0, + height: 0, + }, + min: Rectangle { + width: 0, + height: 0, + }, + max: Rectangle { + width: u32::MAX, + height: u32::MAX, + }, + }, + ))), + ), + Property::new( + SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID, + Value::Choice(ChoiceValue::Long(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Range { + default: 0, + min: i64::MIN, + max: i64::MAX, + }, + ))), + ), + ]; + if format == VideoFormat::NV12 { + properties.push(Property::new( + FormatProperties::VideoColorMatrix.as_raw(), + id_enum( + spa_sys::SPA_VIDEO_COLOR_MATRIX_BT601, + &[ + spa_sys::SPA_VIDEO_COLOR_MATRIX_BT601, + spa_sys::SPA_VIDEO_COLOR_MATRIX_BT709, + ], + ), + )); + properties.push(Property::new( + FormatProperties::VideoColorRange.as_raw(), + id_enum( + spa_sys::SPA_VIDEO_COLOR_RANGE_16_235, + &[ + spa_sys::SPA_VIDEO_COLOR_RANGE_16_235, + spa_sys::SPA_VIDEO_COLOR_RANGE_0_255, + ], + ), + )); + } + if with_modifier { + properties.push(Property { + key: FormatProperties::VideoModifier.as_raw(), + flags: PropertyFlags::MANDATORY, + value: Value::Choice(ChoiceValue::Long(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Enum { + default: DRM_FORMAT_MOD_LINEAR, + alternatives: vec![DRM_FORMAT_MOD_LINEAR], + }, + ))), + }); + } + params.push(serialize(Object { + type_: SpaTypes::ObjectParamFormat.as_raw(), + id: ParamType::EnumFormat.as_raw(), + properties, + })); + } + } + params +} + +fn meta_param(meta_type: u32, size: usize) -> Vec { + serialize(Object { + type_: SpaTypes::ObjectParamMeta.as_raw(), + id: ParamType::Meta.as_raw(), + properties: vec![ + Property::new(spa_sys::SPA_PARAM_META_type, Value::Id(Id(meta_type))), + Property::new(spa_sys::SPA_PARAM_META_size, Value::Int(size as i32)), + ], + }) +} + +/// Buffers and Meta params set by `stream_handle_param_changed`. +fn buffer_params(n: &Negotiated) -> Vec> { + let data_type = if n.dmabuf { + 1 << spa_sys::SPA_DATA_DmaBuf + } else { + 1 << spa_sys::SPA_DATA_MemFd + }; + let buffers = serialize(Object { + type_: SpaTypes::ObjectParamBuffers.as_raw(), + id: ParamType::Buffers.as_raw(), + properties: vec![ + Property::new( + spa_sys::SPA_PARAM_BUFFERS_buffers, + Value::Choice(ChoiceValue::Int(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Range { + default: 4, + min: 1, + max: 8, + }, + ))), + ), + Property::new(spa_sys::SPA_PARAM_BUFFERS_blocks, Value::Int(1)), + Property::new( + spa_sys::SPA_PARAM_BUFFERS_size, + Value::Int(n.byte_size(n.size.h) as i32), + ), + Property::new( + spa_sys::SPA_PARAM_BUFFERS_stride, + Value::Int(n.stride as i32), + ), + Property::new( + spa_sys::SPA_PARAM_BUFFERS_dataType, + Value::Choice(ChoiceValue::Int(Choice( + ChoiceFlags::empty(), + ChoiceEnum::Flags { + default: data_type, + flags: vec![], + }, + ))), + ), + ], + }); + vec![ + buffers, + meta_param( + spa_sys::SPA_META_Header, + size_of::(), + ), + meta_param(SPA_META_REQUESTED_SIZE_SCALE, size_of::()), + ] +} + +fn update_params(stream: &Stream, pods: &[Vec]) { + let mut params: Vec<&Pod> = pods + .iter() + .map(|p| Pod::from_bytes(p).expect("valid pod")) + .collect(); + if let Err(e) = stream.update_params(&mut params) { + error!("pw_stream_update_params failed: {e}"); + } +} + +fn has_modifier(format: &Pod) -> bool { + matches!( + PodDeserializer::deserialize_any_from(format.as_bytes()), + Ok((_, Value::Object(o))) if o.properties.iter().any(|p| p.key == FormatProperties::VideoModifier.as_raw()) + ) +} + +fn on_param_changed(stream: &Stream, fake: &RefCell, id: u32, param: Option<&Pod>) { + if id != ParamType::Format.as_raw() { + return; + } + let Some(param) = param else { + return; + }; + let mut info = VideoInfoRaw::new(); + if info.parse(param).is_err() { + error!("spa_format_video_raw_parse failed"); + return; + } + let requested = requested_size_of(param).map_or(Dim { w: 0, h: 0 }, |r| Dim { + w: r.width, + h: r.height, + }); + let negotiated = { + let mut f = fake.borrow_mut(); + f.requested = if f.ignore_requested { + Dim { w: 0, h: 0 } + } else { + requested + }; + f.calculate_capture_size(); + let size = Dim { + w: info.size().width, + h: info.size().height, + }; + let bpp = if info.format() == VideoFormat::NV12 { + 1 + } else { + 4 + }; + let negotiated = Negotiated { + format: info.format(), + size, + stride: (size.w * bpp).next_multiple_of(4), + dmabuf: has_modifier(param), + }; + f.format = Some(negotiated); + info!( + "format changed (size {}x{}, requested {}x{}, capture {}x{}, format {:?}, stride {}, dmabuf {})", + size.w, + size.h, + requested.w, + requested.h, + f.capture.w, + f.capture.h, + negotiated.format, + negotiated.stride, + negotiated.dmabuf + ); + negotiated + }; + update_params(stream, &buffer_params(&negotiated)); +} + +/// # Safety +/// `pw_buffer` must be the buffer passed to the `add_buffer` event. +unsafe fn on_add_buffer(fake: &Fake, pw_buffer: *mut pw::sys::pw_buffer) { + let spa_buffer = (*pw_buffer).buffer; + if spa_buffer.is_null() || (*spa_buffer).n_datas < 1 { + return; + } + let data = &mut *(*spa_buffer).datas; + let Some(n) = fake.format else { + error!("add_buffer without a format"); + return; + }; + if data.type_ & (1 << spa_sys::SPA_DATA_MemFd) == 0 { + error!( + "unsupported data types {:#x}: the fake only implements MemFd", + data.type_ + ); + data.type_ = spa_sys::SPA_DATA_Invalid; + return; + } + let len = n.byte_size(n.size.h) as usize; + let fd = libc::memfd_create(c"gamescope-fake-pw".as_ptr(), libc::MFD_CLOEXEC); + if fd < 0 { + error!("memfd_create failed"); + return; + } + if libc::ftruncate(fd, len as libc::off_t) != 0 { + error!("ftruncate failed"); + libc::close(fd); + return; + } + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ); + if ptr == libc::MAP_FAILED { + error!("mmap failed"); + libc::close(fd); + return; + } + data.type_ = spa_sys::SPA_DATA_MemFd; + data.flags = spa_sys::SPA_DATA_FLAG_READABLE; + data.fd = i64::from(fd); + data.mapoffset = 0; + data.maxsize = len as u32; + data.data = ptr; + debug!( + len, + "added buffer, texture {}x{}", fake.capture.w, fake.capture.h + ); + + let buffer = Box::new(ShmBuffer { + fd, + ptr: ptr.cast(), + len, + video: n, + texture: fake.capture, + }); + (*pw_buffer).user_data = Box::into_raw(buffer).cast(); +} + +/// # Safety +/// `pw_buffer` must be the buffer passed to the `remove_buffer` event. +unsafe fn on_remove_buffer(pw_buffer: *mut pw::sys::pw_buffer) { + let ptr = (*pw_buffer).user_data.cast::(); + if ptr.is_null() { + return; + } + (*pw_buffer).user_data = std::ptr::null_mut(); + let buffer = Box::from_raw(ptr); + libc::munmap(buffer.ptr.cast(), buffer.len); + libc::close(buffer.fd); +} + +/// RGB colour bars that move by one bar every 8 frames. +fn render(buffer: &mut ShmBuffer, frame_no: u64) { + const BARS: [[u8; 3]; 8] = [ + [235, 235, 235], + [235, 235, 16], + [16, 235, 235], + [16, 235, 16], + [235, 16, 235], + [235, 16, 16], + [16, 16, 235], + [16, 16, 16], + ]; + // SAFETY: `ptr` maps `len` bytes for the lifetime of the buffer. + let mem = unsafe { std::slice::from_raw_parts_mut(buffer.ptr, buffer.len) }; + let (w, h) = (buffer.texture.w as usize, buffer.texture.h as usize); + let stride = buffer.video.stride as usize; + if buffer.video.format == VideoFormat::NV12 { + mem.fill(128); + return; + } + let shift = (frame_no / 8) as usize; + for x in 0..w { + let [r, g, b] = BARS[(x * BARS.len() / w + shift) % BARS.len()]; + mem[x * 4..x * 4 + 4].copy_from_slice(&[b, g, r, 0xFF]); + } + for y in 1..h { + mem.copy_within(0..w * 4, y * stride); + } +} + +/// gamescope's `paint_pipewire` + `dispatch_nudge` + `copy_buffer`, once per frame. +fn tick(stream: &Stream, fake: &RefCell) { + let renegotiate = { + let f = fake.borrow(); + if !f.streaming { + return; + } + let current = f.format.map_or(Dim { w: 0, h: 0 }, |n| n.size); + (f.capture != current).then(|| { + debug!( + "renegotiating stream params (size: {}x{})", + f.capture.w, f.capture.h + ); + build_format_params(f.capture) + }) + }; + if let Some(pods) = renegotiate { + update_params(stream, &pods); + } + + let mut f = fake.borrow_mut(); + if f.static_picture && f.good >= 5 { + return; + } + // SAFETY: the buffer is queued back below and only accessed in between. + unsafe { + let raw = stream.dequeue_raw_buffer(); + if raw.is_null() { + f.out_of_buffers += 1; + if f.out_of_buffers.is_power_of_two() { + warn!("out of buffers ({} times)", f.out_of_buffers); + } + return; + } + let shm = (*raw).user_data.cast::(); + if shm.is_null() { + stream.queue_raw_buffer(raw); + return; + } + let shm = &mut *shm; + let spa_buffer = (*raw).buffer; + let needs_reneg = shm.video.size != shm.texture; + + let header = spa_sys::spa_buffer_find_meta_data( + spa_buffer, + spa_sys::SPA_META_Header, + size_of::(), + ) + .cast::(); + if !header.is_null() { + (*header).pts = -1; + (*header).flags = if needs_reneg { + spa_sys::SPA_META_HEADER_FLAG_CORRUPTED + } else { + 0 + }; + (*header).seq = f.seq; + (*header).dts_offset = 0; + } + f.seq += 1; + let scale = spa_sys::spa_buffer_find_meta_data( + spa_buffer, + SPA_META_REQUESTED_SIZE_SCALE, + size_of::(), + ) + .cast::(); + if !scale.is_null() { + *scale = shm.texture.w as f32 / f.output.w as f32; + } + + let data = &mut *(*spa_buffer).datas; + let chunk = &mut *data.chunk; + chunk.flags = if needs_reneg { + spa_sys::SPA_CHUNK_FLAG_CORRUPTED as i32 + } else { + 0 + }; + // Like gamescope, the chunk size follows the current format, the stride the buffer. + let current = f.format.unwrap_or(shm.video); + chunk.offset = 0; + chunk.size = Negotiated { + stride: shm.video.stride, + ..current + } + .byte_size(current.size.h); + chunk.stride = shm.video.stride as i32; + + if needs_reneg { + f.corrupted += 1; + } else { + render(shm, f.frame_no); + f.good += 1; + } + f.frame_no += 1; + drop(f); + stream.queue_raw_buffer(raw); + } +} + +fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + tracing_subscriber::fmt() + .with_target(false) + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .init(); + + pw::init(); + let mainloop = pw::main_loop::MainLoopRc::new(None)?; + let context = pw::context::ContextRc::new(&mainloop, None)?; + let core = context.connect_rc(None)?; + + let _signals = [pw::loop_::Signal::INT, pw::loop_::Signal::TERM].map(|signal| { + let mainloop_weak = mainloop.downgrade(); + mainloop.loop_().add_signal_local(signal, move || { + if let Some(mainloop) = mainloop_weak.upgrade() { + mainloop.quit(); + } + }) + }); + let _core_listener = { + let mainloop_weak = mainloop.downgrade(); + core.add_listener_local() + .error(move |id, _, res, message| { + error!(id, res, "PipeWire error: {message}"); + if id == pw::sys::PW_ID_CORE { + if let Some(mainloop) = mainloop_weak.upgrade() { + mainloop.quit(); + } + } + }) + .register() + }; + + let fake = Rc::new(RefCell::new(Fake { + output: Dim { + w: opt.width, + h: opt.height, + }, + requested: Dim { w: 0, h: 0 }, + capture: Dim { w: 0, h: 0 }, + format: None, + streaming: false, + seq: 0, + frame_no: 0, + good: 0, + corrupted: 0, + out_of_buffers: 0, + static_picture: opt.static_picture, + ignore_requested: opt.ignore_requested, + })); + fake.borrow_mut().calculate_capture_size(); + + // gamescope passes only media.class. With PipeWire 1.6 the stream name becomes media.name + // and node.name defaults to the program name, which is "gamescope" for the real binary, + // so the fake sets node.name explicitly. + let stream = StreamRc::new( + core.clone(), + &opt.name, + properties! { + *pw::keys::MEDIA_CLASS => "Video/Source", + *pw::keys::NODE_NAME => opt.name.as_str(), + }, + )?; + let _listener = { + let mainloop_weak = mainloop.downgrade(); + stream + .add_local_listener_with_user_data(fake.clone()) + .state_changed(move |stream, fake, old, new| { + info!("stream state changed: {old:?} -> {new:?}"); + let mut f = fake.borrow_mut(); + match new { + StreamState::Paused => { + info!("stream available on node ID: {}", stream.node_id()); + f.streaming = false; + f.seq = 0; + } + StreamState::Streaming => f.streaming = true, + StreamState::Error(_) | StreamState::Unconnected => { + if let Some(mainloop) = mainloop_weak.upgrade() { + mainloop.quit(); + } + } + StreamState::Connecting => {} + } + }) + .param_changed(|stream, fake, id, param| on_param_changed(stream, fake, id, param)) + // SAFETY: the pointers come straight from the stream events. + .add_buffer(|_, fake, buffer| unsafe { on_add_buffer(&fake.borrow(), buffer) }) + .remove_buffer(|_, _, buffer| unsafe { on_remove_buffer(buffer) }) + .register()? + }; + + let pods = build_format_params(fake.borrow().capture); + let mut params: Vec<&Pod> = pods + .iter() + .map(|p| Pod::from_bytes(p).expect("valid pod")) + .collect(); + stream.connect( + Direction::Output, + None, + StreamFlags::DRIVER | StreamFlags::ALLOC_BUFFERS, + &mut params, + )?; + + let frame_timer = { + let (stream, fake) = (stream.clone(), fake.clone()); + mainloop.loop_().add_timer(move |_| tick(&stream, &fake)) + }; + let interval = Duration::from_secs(1) / opt.fps.max(1); + let _ = frame_timer.update_timer(Some(interval), Some(interval)); + + let report_timer = { + let fake = fake.clone(); + mainloop.loop_().add_timer(move |_| { + let f = fake.borrow(); + info!( + "frames: {} ok, {} corrupted, out of buffers {}, streaming {}, capture {}x{}", + f.good, f.corrupted, f.out_of_buffers, f.streaming, f.capture.w, f.capture.h + ); + }) + }; + let _ = report_timer.update_timer(Some(Duration::from_secs(5)), Some(Duration::from_secs(5))); + + info!( + "fake gamescope '{}' output {}x{} at {} fps{}{}", + opt.name, + opt.width, + opt.height, + opt.fps, + if opt.static_picture { ", static" } else { "" }, + if opt.ignore_requested { + ", ignoring requested_size" + } else { + "" + } + ); + mainloop.run(); + Ok(()) +} diff --git a/scripts/e2e-fake.sh b/scripts/e2e-fake.sh new file mode 100755 index 0000000..f5c9a05 --- /dev/null +++ b/scripts/e2e-fake.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# End-to-end check in the current PipeWire session (spec 9.3): +# examples/fake_gamescope + gamescope-hyperhdr + examples/mock_hyperhdr. +# +# The fake node gets a unique name, so a real gamescope node is never touched. +# Requires: pw-link, pw-dump, jq; gst-launch-1.0 with pipewiresink for scenario 4. +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" +cargo build --release --bin gamescope-hyperhdr --example fake_gamescope --example mock_hyperhdr || exit 1 + +bin="$root/target/release" +logs="$(mktemp -d /tmp/gshdr-e2e.XXXXXX)" +name="gamescope-e2e-$$" +client_node="gamescope-hyperhdr" +port=$((20000 + RANDOM % 20000)) +failures=0 +mock_pid="" fake_pid="" client_pid="" gst_pid="" + +pass() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; failures=$((failures + 1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$*"; } +check() { + local desc=$1 + shift + if "$@"; then pass "$desc"; else fail "$desc"; fi +} + +# wait_pid PID TIMEOUT_S: returns the exit status, or kills the process and returns 124. +wait_pid() { + local pid=$1 end=$((SECONDS + $2)) + while [[ -e /proc/$pid && "$(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null)" != Z ]]; do + if ((SECONDS >= end)); then + kill -9 "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + return 124 + fi + sleep 0.05 + done + wait "$pid" 2>/dev/null +} + +cleanup() { + for pid in $client_pid $fake_pid $gst_pid $mock_pid; do + kill "$pid" 2>/dev/null + wait_pid "$pid" 3 + done + echo "logs: $logs" +} +trap cleanup EXIT + +# wait_count FILE REGEX MIN_COUNT TIMEOUT_S [FROM_LINE] +wait_count() { + local file=$1 re=$2 min=$3 from=${5:-1} end=$((SECONDS + $4)) + while :; do + local n + n=$(tail -n "+$from" "$file" 2>/dev/null | grep -Ec -- "$re") + ((n >= min)) && return 0 + ((SECONDS >= end)) && return 1 + sleep 0.2 + done +} +wait_log() { wait_count "$1" "$2" 1 "${3:-10}" "${4:-1}"; } +next_line() { echo $(($(wc -l <"$1") + 1)); } +absent_since() { ! tail -n "+$3" "$1" | grep -Eq -- "$2"; } + +start_mock() { + "$bin/examples/mock_hyperhdr" --listen "127.0.0.1:$port" --timeout-secs 5 >>"$logs/mock.log" 2>&1 & + mock_pid=$! +} +start_fake() { + echo "--- start $*" >>"$logs/fake.log" + "$bin/examples/fake_gamescope" --name "$name" "$@" >>"$logs/fake.log" 2>&1 & + fake_pid=$! +} +stop_fake() { + kill "$fake_pid" 2>/dev/null + wait_pid "$fake_pid" 3 + fake_pid="" +} +start_client() { + local log=$1 + shift + "$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" --target "$name" "$@" >"$log" 2>&1 & + client_pid=$! +} +stop_client() { + kill -TERM "$client_pid" 2>/dev/null + wait_pid "$client_pid" 3 + client_pid="" +} + +# " -> " for every link. +links() { + pw-link -l 2>/dev/null | awk '/^[^ \t]/ { port = $0 } /\|->/ { sub(/^[ \t]*\|->[ \t]*/, ""); print port " -> " $0 }' +} +linked_to_fake() { links | grep -q "^$name:.* -> $client_node:"; } +foreign_links() { links | grep " -> $client_node:" | grep -v "^$name:"; } +node_exists() { + pw-dump 2>/dev/null | jq -e --arg n "$1" \ + 'any(.[]; .type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n)' >/dev/null +} +# Negotiated Format of the client's ports, e.g. "BGRx 320x180". +client_port_format() { + pw-dump 2>/dev/null | jq -r --arg n "$client_node" ' + [.[] | select(.type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n) | .id] as $ids + | .[] | select(.type == "PipeWire:Interface:Port" and (.info.props["node.id"] as $id | $ids | index($id))) + | .info.params.Format[]? | "\(.format) \(.size.width)x\(.size.height)"' +} + +echo "== setup (fake node '$name', mock on port $port, logs in $logs)" +start_mock +wait_log "$logs/mock.log" "listening on" 5 || { fail "mock did not start"; exit 1; } +start_client "$logs/client.log" +wait_log "$logs/client.log" "waiting for a '$name'" 5 || { fail "client did not start"; exit 1; } +start_fake --width 3840 --height 2160 --fps 60 + +echo "== 1. requested_size renegotiation" +check "1st Format event is the output size 3840x2160" wait_log "$logs/client.log" 'format: BGRx 3840x2160.*event=1' +check "2nd Format event is 320x180" wait_log "$logs/client.log" 'format: BGRx 320x180.*event=2' +check "frames arrive as 320x180" wait_log "$logs/client.log" 'receiving frames: 320x180 .*sending 320x180' +check "registered with priority 150" wait_log "$logs/client.log" 'registered with priority 150' +check "mock receives 320x180 RGB" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true' + +echo "== 2. graph" +sleep 1 +links >"$logs/links.txt" +client_port_format >"$logs/format.txt" +check "client is linked to the fake node" linked_to_fake +check "nothing else is linked to the client" test -z "$(foreign_links)" +check "pw-dump: negotiated $(tr '\n' ' ' <"$logs/format.txt")" grep -q "BGRx 320x180" "$logs/format.txt" + +echo "== 3. source disappears and returns" +mock_mark=$(next_line "$logs/mock.log") +client_mark=$(next_line "$logs/client.log") +stop_fake +check "client sends Clear when the node disappears" wait_log "$logs/client.log" 'source gone: sent Clear' 5 "$client_mark" +check "mock gets Clear{150}" wait_log "$logs/mock.log" 'Clear \{ priority: 150 \}' 5 "$mock_mark" +check "mock sees the client close the connection" wait_log "$logs/mock.log" 'Disconnected\(ClosedByClient\)' 5 "$mock_mark" +sleep 1 +mock_mark=$(next_line "$logs/mock.log") +start_fake --width 3840 --height 2160 --fps 60 +check "client re-registers without a restart" wait_log "$logs/mock.log" 'Register \{ origin: "gamescope", priority: 150 \}' 10 "$mock_mark" +check "frames flow again" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true' 10 "$mock_mark" + +echo "== 4. foreign Video/Source is ignored" +client_mark=$(next_line "$logs/client.log") +stop_fake +wait_log "$logs/client.log" 'destroying stream' 5 "$client_mark" +client_mark=$(next_line "$logs/client.log") +if command -v gst-launch-1.0 >/dev/null; then + foreign="e2e-foreign-$$" + gst-launch-1.0 -q videotestsrc is-live=true \ + ! video/x-raw,format=BGRx,width=320,height=180,framerate=30/1 \ + ! pipewiresink mode=provide "stream-properties=props,media.class=Video/Source,node.name=$foreign" \ + >"$logs/gst.log" 2>&1 & + gst_pid=$! + sleep 3 + if node_exists "$foreign"; then + check "client does not create a stream" absent_since "$logs/client.log" 'stream created' "$client_mark" + check "client node does not exist" test "$(node_exists "$client_node" && echo yes)" != yes + check "nothing is linked to the client" test -z "$(links | grep " -> $client_node:")" + else + skip "pipewiresink did not create node '$foreign'" + fi + kill "$gst_pid" 2>/dev/null + wait_pid "$gst_pid" 3 + gst_pid="" +else + skip "gst-launch-1.0 not installed" +fi + +echo "== 5. SIGTERM" +mock_mark=$(next_line "$logs/mock.log") +start_fake --width 3840 --height 2160 --fps 60 +wait_log "$logs/mock.log" 'image 320x180' 10 "$mock_mark" +started=$(date +%s%N) +kill -TERM "$client_pid" +wait_pid "$client_pid" 3 +status=$? +elapsed_ms=$((($(date +%s%N) - started) / 1000000)) +client_pid="" +check "exit code 0 (got $status)" test "$status" -eq 0 +check "exits within 1 s (${elapsed_ms} ms)" test "$elapsed_ms" -lt 1000 +check "sends Clear on shutdown" grep -q 'shutting down: sent Clear' "$logs/client.log" +check "mock gets Clear{150}" wait_log "$logs/mock.log" 'Clear \{ priority: 150 \}' 2 "$mock_mark" + +echo "== 6. --supersample 2" +mock_mark=$(next_line "$logs/mock.log") +start_client "$logs/client-supersample.log" --supersample 2 +check "requests 640x360" wait_log "$logs/client-supersample.log" 'format: BGRx 640x360.*requested_size 640x360' +check "downscales 640x360 to 320x180" wait_log "$logs/client-supersample.log" 'receiving frames: 640x360 .*sending 320x180' +check "mock receives 320x180" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true' 10 "$mock_mark" +stop_client + +echo "== 7. static picture: keepalive holds the connection" +stop_fake +start_fake --width 3840 --height 2160 --fps 60 --static +mock_mark=$(next_line "$logs/mock.log") +start_client "$logs/client-static.log" +wait_log "$logs/mock.log" 'image 320x180' 10 "$mock_mark" +sleep 8 +check "no idle timeout on the server (5 s)" absent_since "$logs/mock.log" 'Disconnected' "$mock_mark" +check "keepalive images keep arriving" wait_count "$logs/mock.log" 'images in' 3 1 "$mock_mark" +stop_client + +echo +if ((failures == 0)); then + echo "all scenarios passed" +else + echo "$failures check(s) failed" +fi +exit $((failures > 0)) diff --git a/scripts/nested-gamescope.sh b/scripts/nested-gamescope.sh new file mode 100755 index 0000000..1be448a --- /dev/null +++ b/scripts/nested-gamescope.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Spec 9.4.1 / 9.4.3 with a real gamescope: a nested headless gamescope running vkcube, +# gamescope-hyperhdr and the mock HyperHDR, plus a load comparison with the old gst pipeline +# (pipewiresrc → videorate → videoscale → videoconvert, into fakesink instead of v4l2loopback). +# +# Environment: W, H (output size, default 3840x2160), REFRESH (60), SECS (measurement, 10), +# CLIENT_ARGS (extra client flags, e.g. "--supersample 2"). +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" +cargo build --release --bin gamescope-hyperhdr --example mock_hyperhdr || exit 1 + +W=${W:-3840} H=${H:-2160} REFRESH=${REFRESH:-60} SECS=${SECS:-10} +read -r -a client_args <<<"${CLIENT_ARGS:-}" +bin="$root/target/release" +logs="$(mktemp -d /tmp/gshdr-nested.XXXXXX)" +port=$((20000 + RANDOM % 20000)) +mock_pid="" gs_pid="" client_pid="" gst_pid="" + +node_exists() { + pw-dump 2>/dev/null | jq -e --arg n "$1" \ + 'any(.[]; .type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n)' >/dev/null +} +if node_exists gamescope; then + echo "a 'gamescope' node already exists (gamescope-tv running?); stop it first" >&2 + exit 1 +fi + +wait_pid() { + local pid=$1 end=$((SECONDS + $2)) + while [[ -e /proc/$pid && "$(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null)" != Z ]]; do + if ((SECONDS >= end)); then + kill -9 "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + return 124 + fi + sleep 0.02 + done + wait "$pid" 2>/dev/null +} +cleanup() { + for pid in $client_pid $gst_pid $gs_pid $mock_pid; do + kill "$pid" 2>/dev/null + wait_pid "$pid" 5 + done + echo "logs: $logs" +} +trap cleanup EXIT + +wait_log() { + local end=$((SECONDS + $3)) + until grep -Eq -- "$2" "$1" 2>/dev/null; do + ((SECONDS >= end)) && return 1 + sleep 0.1 + done +} +links() { + pw-link -l 2>/dev/null | awk '/^[^ \t]/ { port = $0 } /\|->/ { sub(/^[ \t]*\|->[ \t]*/, ""); print port " -> " $0 }' +} +port_format() { + pw-dump 2>/dev/null | jq -r --arg n "$1" ' + [.[] | select(.type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n) | .id] as $ids + | .[] | select(.type == "PipeWire:Interface:Port" and (.info.props["node.id"] as $id | $ids | index($id))) + | .info.params.Format[]? | "\(.format) \(.size.width)x\(.size.height)"' +} +cpu_ticks() { awk '{ print $14 + $15 }' "/proc/$1/stat"; } +rss_kib() { awk '/^VmRSS:/ { print $2 }' "/proc/$1/status"; } +# measure NAME=PID...: CPU (% of one core) and RSS over SECS seconds +measure() { + local hz item t0 t1 + hz=$(getconf CLK_TCK) + local -A before + for item in "$@"; do before[$item]=$(cpu_ticks "${item#*=}"); done + t0=$(date +%s%N) + sleep "$SECS" + t1=$(date +%s%N) + for item in "$@"; do + local pid=${item#*=} + awk -v ticks="$(($(cpu_ticks "$pid") - before[$item]))" -v hz="$hz" -v ns="$((t1 - t0))" \ + -v name="${item%%=*}" -v rss="$(rss_kib "$pid")" \ + 'BEGIN { printf " %-20s CPU %6.2f %% RSS %6.1f MB\n", name, ticks / hz / (ns / 1e9) * 100, rss / 1024 }' + done +} + +"$bin/examples/mock_hyperhdr" --listen "127.0.0.1:$port" >"$logs/mock.log" 2>&1 & +mock_pid=$! + +echo "== nested gamescope ${W}x${H}@${REFRESH} (headless, vkcube)" +gamescope --backend headless -W "$W" -H "$H" -r "$REFRESH" -- vkcube >"$logs/gamescope.log" 2>&1 & +gs_pid=$! +for _ in $(seq 50); do node_exists gamescope && break; sleep 0.2; done +node_exists gamescope || { echo "gamescope node did not appear, see $logs/gamescope.log"; exit 1; } +sleep 2 + +echo "== gamescope-hyperhdr ${client_args[*]}" +"$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" "${client_args[@]}" >"$logs/client.log" 2>&1 & +client_pid=$! +wait_log "$logs/client.log" 'receiving frames' 15 || echo " no frames within 15 s" +wait_log "$logs/client.log" 'registered with priority' 5 +grep -E 'format:|receiving frames|registered|warn|WARN' "$logs/client.log" | sed 's/^[^ ]* */ /' +echo " links: $(links | grep ' -> gamescope-hyperhdr:' | tr '\n' ' ')" +echo " format: $(port_format gamescope-hyperhdr | tr '\n' ' ')" +echo " load over $SECS s:" +measure gamescope="$gs_pid" gamescope-hyperhdr="$client_pid" +echo " mock: $(grep 'images in' "$logs/mock.log" | tail -1 | sed 's/^.*INFO //')" + +started=$(date +%s%N) +kill -TERM "$client_pid" +wait_pid "$client_pid" 3 +status=$? +client_pid="" +echo " SIGTERM: exit $status after $((($(date +%s%N) - started) / 1000000)) ms, $(grep -c 'Clear { priority' "$logs/mock.log") Clear(s) at the mock" + +if command -v gst-launch-1.0 >/dev/null; then + echo "== old pipeline: pipewiresrc → videorate 30 → videoscale 320x180 → videoconvert YUY2 → fakesink" + sleep 2 + gst-launch-1.0 -q pipewiresrc target-object=gamescope do-timestamp=true keepalive-time=200 \ + ! videorate ! video/x-raw,framerate=30/1 \ + ! videoscale ! video/x-raw,width=320,height=180 \ + ! videoconvert ! video/x-raw,format=YUY2 \ + ! fakesink sync=false >"$logs/gst.log" 2>&1 & + gst_pid=$! + sleep 4 + echo " format: $(links | grep -oE '[^ ]+ -> gst-launch[^ ]*' | head -1) $(port_format gst-launch-1.0 | tr '\n' ' ')" + echo " load over $SECS s:" + measure gamescope="$gs_pid" gst-launch-1.0="$gst_pid" + kill "$gst_pid" + wait_pid "$gst_pid" 3 + gst_pid="" +fi + +echo "== gamescope stops → Clear" +"$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" "${client_args[@]}" >"$logs/client2.log" 2>&1 & +client_pid=$! +wait_log "$logs/client2.log" 'registered with priority' 15 +clears_before=$(grep -c 'Clear { priority' "$logs/mock.log") +started=$(date +%s%N) +kill -TERM "$gs_pid" +until (($(grep -c 'Clear { priority' "$logs/mock.log") > clears_before)); do + (($(date +%s%N) - started > 10000000000)) && break + sleep 0.01 +done +echo " Clear at the mock $((($(date +%s%N) - started) / 1000000)) ms after SIGTERM to gamescope" +wait_pid "$gs_pid" 10 +gs_pid="" +sleep 1 +grep -E 'source node removed|destroying stream|source gone|ERROR' "$logs/client2.log" | sed 's/^[^ ]* */ /'