diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 5ea993f..2662eee 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; pub(crate) mod capture; mod crtc_assignment; mod diagnostics; +pub(crate) mod feedback; pub(crate) mod gamma; mod lid; mod mirror; @@ -22,14 +23,15 @@ use diagnostics::{Diagnostics, PrimaryOutcome}; use output_power::{OutputPower, Power}; use redraw::{AnimationFallback, SchedulingMetrics}; use scheduling::*; -use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; +use smithay::backend::allocator::{Buffer, Fourcc}; use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags, PrimaryPlaneElement}; use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter; use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, NodeType}; use smithay::backend::egl::{EGLContext, EGLDisplay}; use smithay::backend::input::InputEvent; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; +use smithay::backend::renderer::element::{RenderElement, UnderlyingStorage}; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::backend::renderer::utils::CommitCounter; use smithay::backend::renderer::{ImportDma, ImportMemWl, Renderer}; @@ -277,6 +279,7 @@ struct DirectOutput { capture_texture: Option, render_metrics: RenderMetrics, diagnostics: Diagnostics, + dmabuf_feedback: feedback::OutputFeedback, frame_pending: bool, completion: recovery::Completion, completion_timer: Option, @@ -413,6 +416,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() output.gamma.suspend(); cancel_output_timers(&handle, output); output.surface.reset_buffers(); + output.dmabuf_feedback.invalidate(); output.primary_commit = None; output.capture_texture = None; output.frame_pending = false; @@ -971,6 +975,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result(&display_handle, &feedback); state.dmabuf_imports.register(dmabuf_global); + state.dmabuf_feedback.register(dmabuf_global, feedback); state .direct_backend .as_mut() @@ -1334,6 +1339,22 @@ fn render_output( ) .inspect_err(|_| output.diagnostics.render_failed())?; + let primary_format = match &result.primary_element { + PrimaryPlaneElement::Swapchain(primary) => Some(primary.buffer().format()), + PrimaryPlaneElement::Element(element) => match element.underlying_storage(&mut device.renderer) { + Some(UnderlyingStorage::Wayland(buffer)) => smithay::wayland::dmabuf::get_dmabuf(buffer) + .ok() + .map(|buffer| buffer.format()), + _ => None, + }, + }; + let feedback_candidates = feedback::candidates( + &mut device.renderer, + &elements, + &output.output, + flags.contains(FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT), + ); + if result.needs_sync() && let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element { @@ -1347,6 +1368,9 @@ fn render_output( .into_iter() .any(|cursor| state.has_pending_screencopy(&output.output, cursor)) }); + output + .dmabuf_feedback + .observed_frame(primary_format, &result.states, feedback_candidates); if output.mirror_source.is_some() { capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output); } else { @@ -1420,6 +1444,9 @@ fn render_output( flags.contains(FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT), ); capture::after_queue(queued, || { + output + .dmabuf_feedback + .observed_frame(primary_format, &visibility, feedback_candidates); if output.mirror_source.is_none() { state.display_presentation.queued(&output.output, &visibility); } @@ -1663,6 +1690,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) { state.loop_handle.remove(device.notifier); state.dmabuf_imports.remove(device.dmabuf_global); + state.dmabuf_feedback.remove(device.dmabuf_global); state .dmabuf_state .disable_global::(&state.display_handle, &device.dmabuf_global); @@ -2252,6 +2280,12 @@ fn create_direct_output( surface.set_output_mode_source((&output).into()); let render_metrics = RenderMetrics::from_environment(output.name()); let diagnostics = Diagnostics::from_environment(output.name()); + let dmabuf_feedback = feedback::OutputFeedback::new( + render_node.dev_id(), + node.dev_id(), + renderer.dmabuf_formats(), + surface.surface().plane_info().formats.clone(), + ); tracing::info!(?crtc, connector = %connector, "initialized DRM output"); Ok(DirectOutput { @@ -2274,6 +2308,7 @@ fn create_direct_output( capture_texture: None, render_metrics, diagnostics, + dmabuf_feedback, frame_pending: false, completion: Default::default(), completion_timer: None, diff --git a/compositor/src/backends/direct/feedback.rs b/compositor/src/backends/direct/feedback.rs new file mode 100644 index 0000000..82d3841 --- /dev/null +++ b/compositor/src/backends/direct/feedback.rs @@ -0,0 +1,478 @@ +//! Conservative output feedback for the existing primary-plane policy. +use std::collections::{HashMap, HashSet}; + +use smithay::backend::allocator::Format; +use smithay::backend::allocator::format::FormatSet; +use smithay::backend::renderer::element::{ + Element, Id, RenderElement, RenderElementPresentationState, RenderElementStates, RenderingReason, +}; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::renderer::utils::with_renderer_surface_state; +use smithay::output::Output; +use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags; +use smithay::utils::{Physical, Rectangle, Scale}; +use smithay::wayland::dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal}; + +use crate::Ferese; +use crate::render::AnimatedWindowRenderElement; + +fn compatible(render: &FormatSet, primary: &FormatSet, observed: Format) -> bool { + // Do not equate implicit and explicit modifiers, alpha and opaque FourCCs, + // or formats from cursor/overlay planes. Exact intersection preserves import. + render.contains(&observed) && primary.contains(&observed) +} + +pub(super) struct OutputFeedback { + render_device: libc::dev_t, + scanout_device: libc::dev_t, + render_formats: FormatSet, + primary_formats: FormatSet, + observed: Option, + preferred: Option, + visible: HashSet, + candidates: HashSet, +} + +impl OutputFeedback { + pub fn new( + render_device: libc::dev_t, + scanout_device: libc::dev_t, + render_formats: FormatSet, + primary_formats: FormatSet, + ) -> Self { + Self { + render_device, + scanout_device, + render_formats, + primary_formats, + observed: None, + preferred: None, + visible: HashSet::new(), + candidates: HashSet::new(), + } + } + + pub fn invalidate(&mut self) { + self.observed = None; + self.preferred = None; + self.visible.clear(); + self.candidates.clear(); + } + + fn observe_format(&mut self, format: Option) { + let Some(format) = format else { + return; + }; + if self.observed == Some(format) { + return; + } + self.observed = Some(format); + self.preferred = None; + if compatible(&self.render_formats, &self.primary_formats, format) { + // Restrict the preference to the observed primary format/modifier: + // without ALLOW_PRIMARY_PLANE_SCANOUT_ANY Smithay compares the full + // format with the swapchain. Renderer-only formats remain in fallback. + match DmabufFeedbackBuilder::new(self.render_device, self.render_formats.iter().copied()) + .add_preference_tranche(self.scanout_device, Some(TrancheFlags::Scanout), [format]) + .build() + { + Ok(feedback) => self.preferred = Some(feedback), + Err(error) => tracing::warn!(%error, "output DMA-BUF feedback unavailable; retaining render fallback"), + } + } + } + + pub fn observed_frame(&mut self, format: Option, states: &RenderElementStates, candidates: HashSet) { + self.observe_format(format); + self.visible = states + .states + .iter() + .filter(|(_, state)| crate::display_presentation::is_visible(**state)) + .map(|(id, _)| id.clone()) + .collect(); + self.candidates = candidates + .into_iter() + .filter(|id| { + states.states.get(id).is_some_and(|state| { + crate::display_presentation::is_visible(*state) + && state.presentation_state + != RenderElementPresentationState::Rendering { + reason: Some(RenderingReason::ScanoutFailed), + } + }) + }) + .collect(); + } +} + +pub(super) fn candidates( + renderer: &mut GlesRenderer, + elements: &[AnimatedWindowRenderElement], + output: &Output, + allowed: bool, +) -> HashSet { + if !allowed { + return HashSet::new(); + } + let Some(mode) = output.current_mode() else { + return HashSet::new(); + }; + let bounds = Rectangle::from_size(output.current_transform().transform_size(mode.size)); + let scale = output.current_scale().fractional_scale().into(); + elements + .iter() + .filter(|&element| { + candidate_geometry(element, bounds, scale, || { + element.underlying_storage(renderer).is_some() + }) + }) + .map(|element| element.id().clone()) + .collect() +} + +fn candidate_geometry( + element: &impl Element, + bounds: Rectangle, + scale: Scale, + has_storage: impl FnOnce() -> bool, +) -> bool { + let geometry = element.geometry(scale); + if element.alpha() != 1.0 || !geometry.contains_rect(bounds) { + return false; + } + // A single opaque rectangle covering the output is deliberately more + // conservative than reconstructing an arbitrary union of client regions. + element + .opaque_regions(scale) + .iter() + .any(|region| Rectangle::new(region.loc + geometry.loc, region.size).contains_rect(bounds)) + && has_storage() +} + +struct OutputView<'a> { + global: DmabufGlobal, + feedback: &'a OutputFeedback, + eligible: Option, +} + +fn select<'a>( + global: DmabufGlobal, + surface: &Id, + outputs: impl IntoIterator>, +) -> Option<&'a DmabufFeedback> { + let mut visible = outputs + .into_iter() + .filter(|output| output.feedback.visible.contains(surface)); + let output = visible.next()?; + // A surface visible on multiple outputs gets render fallback rather than + // whichever output happened to render last. Cross-GPU import is not assumed. + if visible.next().is_some() + || output.global != global + || output.eligible.as_ref() != Some(surface) + || !output.feedback.candidates.contains(surface) + { + return None; + } + output.feedback.preferred.as_ref() +} + +pub(crate) fn refresh(state: &mut Ferese) { + let mut preferred = HashMap::new(); + if let Some(backend) = state.direct_backend.as_ref() { + for (surface, global) in state.dmabuf_feedback.requested() { + let id = Id::from(surface); + let outputs = backend + .devices + .values() + .flat_map(|device| device.outputs.values().map(move |output| (device, output))) + .filter(|(_, output)| state.output_id(&output.output).is_some()); + let views = outputs.map(|(device, output)| { + let eligible = (backend.can_render() + && device.drm.is_active() + && output.power.can_render() + && output.mirror_source.is_none() + && !state.session_lock.active() + && !state.output_has_animations(&output.output) + && state.output_has_fullscreen(&output.output)) + .then(|| { + state + .output_id(&output.output) + .and_then(|id| state.output_workspaces.active_workspace(id)) + .and_then(|workspace| state.workspaces.workspace(workspace)) + .and_then(|workspace| workspace.fullscreen) + .filter(|id| { + state + .current_window_presentation(*id) + .is_some_and(|presentation| presentation.alpha() == 1.0 && presentation.dim == 0.0) + }) + .and_then(|id| state.windows.window(id)) + .and_then(|window| window.toplevel()) + .filter(|toplevel| { + with_renderer_surface_state(toplevel.wl_surface(), |state| state.buffer().is_some()) + .unwrap_or(false) + }) + .map(|toplevel| Id::from(toplevel.wl_surface())) + }) + .flatten(); + OutputView { + global: device.dmabuf_global, + feedback: &output.dmabuf_feedback, + eligible, + } + }); + if let Some(feedback) = select(global, &id, views) { + preferred.insert(surface.clone(), feedback.clone()); + } + } + } + state.dmabuf_feedback.refresh(&preferred); +} + +#[cfg(test)] +mod tests { + use super::*; + use smithay::backend::allocator::{Fourcc, Modifier}; + use smithay::backend::renderer::element::{RenderElementPresentationState, RenderElementState}; + use smithay::reexports::wayland_server::Display; + use smithay::wayland::dmabuf::DmabufState; + + fn format(code: Fourcc, modifier: Modifier) -> Format { + Format { code, modifier } + } + fn formats(values: &[Format]) -> FormatSet { + values.iter().copied().collect() + } + fn output(render: &[Format], primary: &[Format]) -> OutputFeedback { + OutputFeedback::new(10, 20, formats(render), formats(primary)) + } + fn create_globals() -> (Display, DmabufGlobal, DmabufGlobal) { + let display = Display::new().unwrap(); + let mut state = DmabufState::new(); + let feedback = DmabufFeedbackBuilder::new(10, [format(Fourcc::Argb8888, Modifier::Linear)]) + .build() + .unwrap(); + let global = state.create_global_with_default_feedback::(&display.handle(), &feedback); + let other = state.create_global_with_default_feedback::(&display.handle(), &feedback); + (display, global, other) + } + fn visible(output: &mut OutputFeedback, id: &Id, f: Format) { + let mut states = RenderElementStates::default(); + states.states.insert( + id.clone(), + RenderElementState { + visible_area: 100, + presentation_state: RenderElementPresentationState::Rendering { reason: None }, + }, + ); + output.observed_frame(Some(f), &states, HashSet::from([id.clone()])); + } + + #[test] + fn preferences_require_exact_renderer_and_primary_intersection() { + let linear = format(Fourcc::Argb8888, Modifier::Linear); + let implicit = format(Fourcc::Argb8888, Modifier::Invalid); + let opaque = format(Fourcc::Xrgb8888, Modifier::Linear); + assert!(compatible(&formats(&[linear, implicit]), &formats(&[linear]), linear)); + assert!(!compatible(&formats(&[linear]), &formats(&[implicit]), linear)); + assert!(!compatible(&formats(&[linear]), &formats(&[opaque]), linear)); + assert!(!compatible(&formats(&[linear]), &formats(&[implicit]), implicit)); + assert!(!compatible(&formats(&[opaque]), &formats(&[linear]), linear)); + } + + #[test] + fn observed_format_limits_preference_and_retains_renderer_fallback() { + let linear = format(Fourcc::Argb8888, Modifier::Linear); + let other = format(Fourcc::Abgr8888, Modifier::Linear); + let mut o = output(&[linear, other], &[linear, other]); + assert!(o.preferred.is_none()); + o.observe_format(Some(linear)); + let expected = DmabufFeedbackBuilder::new(10, [linear, other]) + .add_preference_tranche(20, Some(TrancheFlags::Scanout), [linear]) + .build() + .unwrap(); + assert_eq!(o.preferred.as_ref(), Some(&expected)); + o.observe_format(Some(other)); + assert_ne!(o.preferred.as_ref(), Some(&expected)); + o.observe_format(Some(format(Fourcc::Rgb565, Modifier::Linear))); + assert!(o.preferred.is_none()); + } + + #[test] + fn migration_multi_output_cross_gpu_and_policy_changes_fall_back() { + let (_display, global, other_global) = create_globals(); + let id = Id::new(); + let f = format(Fourcc::Argb8888, Modifier::Linear); + let mut a = output(&[f], &[f]); + let mut b = output(&[f], &[f]); + visible(&mut a, &id, f); + visible(&mut b, &id, f); + fn view(global: DmabufGlobal, feedback: &OutputFeedback, eligible: Option) -> OutputView<'_> { + OutputView { + global, + feedback, + eligible, + } + } + assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_some()); + assert!(select(global, &id, [view(global, &a, None)]).is_none()); + assert!( + select( + global, + &id, + [view(global, &a, Some(id.clone())), view(global, &b, Some(id.clone()))] + ) + .is_none() + ); + assert!(select(other_global, &id, [view(global, &a, Some(id.clone()))]).is_none()); + a.invalidate(); + assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_none()); + assert!( + select( + global, + &id, + [view(global, &a, Some(id.clone())), view(global, &b, Some(id.clone()))] + ) + .is_some() + ); + b.candidates.clear(); + assert!(select(global, &id, [view(global, &b, Some(id.clone()))]).is_none()); + } + + #[test] + fn reconfiguration_invalidates_observations_until_a_new_frame() { + let (_display, global, _) = create_globals(); + let id = Id::new(); + let f = format(Fourcc::Argb8888, Modifier::Linear); + let mut o = output(&[f], &[f]); + visible(&mut o, &id, f); + o.invalidate(); + assert!(o.observed.is_none() && o.preferred.is_none() && o.visible.is_empty() && o.candidates.is_empty()); + o.observe_format(None); + assert!(o.preferred.is_none()); + visible(&mut o, &id, f); + assert!( + select( + global, + &id, + [OutputView { + global, + feedback: &o, + eligible: Some(id.clone()) + }] + ) + .is_some() + ); + } + + #[test] + fn candidate_geometry_requires_covering_opaque_content_and_storage() { + use smithay::backend::renderer::element::Kind; + use smithay::backend::renderer::element::solid::SolidColorRenderElement; + use smithay::backend::renderer::utils::CommitCounter; + use smithay::utils::Transform; + for transform in [Transform::Normal, Transform::_90] { + let bounds = Rectangle::from_size(transform.transform_size((100, 60).into())); + for scale in [1.0, 1.25, 2.0] { + let solid = |geometry, alpha| { + SolidColorRenderElement::new( + Id::new(), + geometry, + CommitCounter::default(), + [1., 1., 1., alpha], + Kind::Unspecified, + ) + }; + let opaque = solid(bounds, 1.0); + assert!(candidate_geometry(&opaque, bounds, scale.into(), || true)); + assert!( + !candidate_geometry(&opaque, bounds, scale.into(), || false), + "clipped storage must remain hidden" + ); + let faded = solid(bounds, 0.8); + assert!(!candidate_geometry(&faded, bounds, scale.into(), || panic!( + "faded elements must not probe storage" + ))); + let mut partial = bounds; + partial.size.w -= 1; + assert!(!candidate_geometry( + &solid(partial, 1.0), + bounds, + scale.into(), + || panic!("partial content must not probe storage") + )); + } + } + } + + #[test] + fn failed_scanout_and_occlusion_withdraw_preferences_but_format_hints_can_recover() { + let (_display, global, _) = create_globals(); + let id = Id::new(); + let f = format(Fourcc::Argb8888, Modifier::Linear); + let mut o = output(&[f], &[f]); + for (state, expected) in [ + ( + RenderElementState { + visible_area: 100, + presentation_state: RenderElementPresentationState::Rendering { + reason: Some(RenderingReason::FormatUnsupported), + }, + }, + true, + ), + ( + RenderElementState { + visible_area: 100, + presentation_state: RenderElementPresentationState::Rendering { + reason: Some(RenderingReason::ScanoutFailed), + }, + }, + false, + ), + ( + RenderElementState { + visible_area: 0, + presentation_state: RenderElementPresentationState::Skipped, + }, + false, + ), + ( + RenderElementState { + visible_area: 100, + presentation_state: RenderElementPresentationState::ZeroCopy, + }, + true, + ), + ] { + let states = RenderElementStates { + states: HashMap::from([(id.clone(), state)]), + }; + o.observed_frame(Some(f), &states, HashSet::from([id.clone()])); + assert_eq!( + select( + global, + &id, + [OutputView { + global, + feedback: &o, + eligible: Some(id.clone()) + }] + ) + .is_some(), + expected + ); + } + } + + #[test] + fn queue_failure_never_publishes_prepared_feedback_observations() { + let f = format(Fourcc::Argb8888, Modifier::Linear); + let id = Id::new(); + let mut o = output(&[f], &[f]); + let failed = super::super::capture::after_queue(Err::<(), _>("queue failed"), || visible(&mut o, &id, f)); + assert!(failed.is_err()); + assert!(o.visible.is_empty() && o.preferred.is_none()); + super::super::capture::after_queue(Ok::<(), ()>(()), || visible(&mut o, &id, f)).unwrap(); + assert!(o.visible.contains(&id) && o.preferred.is_some()); + } +} diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index 5d2227a..2f2acbe 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -274,6 +274,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { for (crtc, output) in &mut device.outputs { cancel_output_timers(&state.loop_handle, output); output.surface.reset_buffers(); + output.dmabuf_feedback.invalidate(); output.primary_commit = None; output.capture_texture = None; output.frame_pending = false; diff --git a/compositor/src/backends/direct/transaction.rs b/compositor/src/backends/direct/transaction.rs index a448f2c..98c63e7 100644 --- a/compositor/src/backends/direct/transaction.rs +++ b/compositor/src/backends/direct/transaction.rs @@ -262,6 +262,7 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> { .surface .set_connectors(&[selection.connector.handle()]) .map_err(|error| error.to_string())?; + output.dmabuf_feedback.invalidate(); prepare( &mut output.surface, &mut self.device.renderer, @@ -280,6 +281,7 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> { let replacement = self.staged.remove(&crtc).unwrap(); let previous = self.device.outputs.get_mut(&crtc).unwrap(); previous.surface = replacement.surface; + previous.dmabuf_feedback = replacement.dmabuf_feedback; self.replaced.insert(crtc); } self.staged @@ -345,11 +347,13 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> { .map_err(|error| error.to_string())?; let previous = self.device.outputs.get_mut(&crtc).unwrap(); previous.surface = replacement.surface; + previous.dmabuf_feedback = replacement.dmabuf_feedback; previous.surface.commit_frame().map_err(|error| error.to_string())?; } let Some(output) = self.device.outputs.get_mut(&crtc) else { return Ok(()); }; + output.dmabuf_feedback.invalidate(); output.surface.reset_state().map_err(|error| error.to_string())?; let restored = output .surface diff --git a/compositor/src/dmabuf_feedback.rs b/compositor/src/dmabuf_feedback.rs new file mode 100644 index 0000000..501f333 --- /dev/null +++ b/compositor/src/dmabuf_feedback.rs @@ -0,0 +1,78 @@ +//! Allocation hints retain the import global's renderer as a composition fallback. +use std::collections::HashMap; + +use smithay::reexports::wayland_server::Resource; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::wayland::compositor::with_states; +use smithay::wayland::dmabuf::{DmabufFeedback, DmabufGlobal, SurfaceDmabufFeedbackState}; + +struct Surface { + global: DmabufGlobal, + fallback: DmabufFeedback, +} + +#[derive(Default)] +pub(crate) struct FeedbackManager { + globals: HashMap, + surfaces: HashMap, +} + +impl FeedbackManager { + pub fn register(&mut self, global: DmabufGlobal, render: DmabufFeedback) { + self.globals.insert(global, render); + } + + pub fn new_surface(&mut self, surface: &WlSurface, global: DmabufGlobal) -> Option { + let fallback = self.globals.get(&global)?.clone(); + self.surfaces.insert( + surface.clone(), + Surface { + global, + fallback: fallback.clone(), + }, + ); + Some(fallback) + } + + pub fn requested(&self) -> impl Iterator { + self.surfaces.iter().map(|(surface, record)| (surface, record.global)) + } + + pub fn refresh(&mut self, preferred: &HashMap) { + self.surfaces.retain(|surface, _| surface.is_alive()); + for (surface, record) in &self.surfaces { + let feedback = if self.globals.contains_key(&record.global) { + preferred.get(surface).unwrap_or(&record.fallback) + } else { + &record.fallback + }; + // Smithay owns subscriptions, protocol messages and equality suppression. + with_states(surface, |states| { + if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) { + state.set_feedback(feedback); + } + }); + } + } + + pub fn remove(&mut self, global: DmabufGlobal) { + self.globals.remove(&global); + for (surface, record) in self.surfaces.iter().filter(|(_, record)| record.global == global) { + // V4 requires a main-device tranche. Retain last-known render + // capabilities, withdraw scanout hints and retire the global. Bound + // resources cannot import through the retired global or another GPU. + with_states(surface, |states| { + if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) { + state.set_feedback(&record.fallback); + } + }); + } + } + + pub fn destroyed(&mut self, surface: &WlSurface) { + self.surfaces.remove(surface); + } +} + +#[cfg(test)] +mod tests; diff --git a/compositor/src/dmabuf_feedback/tests.rs b/compositor/src/dmabuf_feedback/tests.rs new file mode 100644 index 0000000..33cde74 --- /dev/null +++ b/compositor/src/dmabuf_feedback/tests.rs @@ -0,0 +1,205 @@ +//! Real v4 feedback subscriptions over a private Wayland socket; no DRM/EGL. +use std::io::Read; +use std::os::unix::net::UnixStream; + +use smithay::backend::allocator::{Format, Fourcc, Modifier}; +use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; +use smithay::reexports::calloop::EventLoop; +use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags; +use smithay::reexports::wayland_server::Display; +use smithay::wayland::dmabuf::DmabufFeedbackBuilder; + +use super::*; +use crate::Ferese; +use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window}; + +fn messages(wire: &mut UnixStream) -> Vec<(u32, u32, Vec)> { + wire.set_nonblocking(true).unwrap(); + let mut bytes = Vec::new(); + let _ = wire.read_to_end(&mut bytes); + wire.set_nonblocking(false).unwrap(); + let mut offset = 0; + let mut result = Vec::new(); + while offset < bytes.len() { + let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()); + let size = (header >> 16) as usize; + result.push(( + object, + header & 0xffff, + bytes[offset + 8..offset + size] + .chunks_exact(4) + .map(|word| u32::from_ne_bytes(word.try_into().unwrap())) + .collect(), + )); + offset += size; + } + result +} + +fn flags(events: &[(u32, u32, Vec)], object: u32) -> Vec { + events + .iter() + .filter(|(id, opcode, _)| *id == object && *opcode == 6) + .map(|(_, _, args)| args[0]) + .collect() +} + +fn done(events: &[(u32, u32, Vec)], object: u32) -> usize { + events + .iter() + .filter(|(id, opcode, _)| *id == object && *opcode == 0) + .count() +} + +#[test] +fn subscriptions_update_deduplicate_and_retire_device_preferences() { + if !crate::startup_tests::private_runtime( + "dmabuf_feedback::tests::subscriptions_update_deduplicate_and_retire_device_preferences", + ) { + return; + } + let mut events = EventLoop::try_new().unwrap(); + let mut config = crate::config::Config::default().runtime_config().unwrap(); + config.wallpaper.path = None; + let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap(); + let output = Output::new( + "feedback-test".into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(Mode { + size: (100, 100).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "feedback-test".into()); + let f = Format { + code: Fourcc::Argb8888, + modifier: Modifier::Linear, + }; + let fallback = DmabufFeedbackBuilder::new(10, [f]).build().unwrap(); + let preferred = DmabufFeedbackBuilder::new(10, [f]) + .add_preference_tranche(20, Some(TrancheFlags::Scanout), [f]) + .build() + .unwrap(); + let global = state + .dmabuf_state + .create_global_with_default_feedback::(&state.display_handle, &fallback); + state.dmabuf_feedback.register(global, fallback.clone()); + let (window, mut wire) = test_window(&mut state, &mut events, 0xffabcdef); + let surface = window.toplevel().unwrap().wl_surface().clone(); + request(&mut wire, 1, 1, &[7], None); // wl_display.get_registry + dispatch(&mut events, &mut state); + let registry = messages(&mut wire); + let dmabuf_name = registry + .iter() + .find_map(|(object, opcode, args)| { + if *object != 7 || *opcode != 0 { + return None; + } + let bytes = args[2..].iter().flat_map(|word| word.to_ne_bytes()).collect::>(); + bytes.starts_with(b"zwp_linux_dmabuf_v1\0").then_some(args[0]) + }) + .expect("DMA-BUF v4 global"); + let name = b"zwp_linux_dmabuf_v1\0"; + let mut padded = name.to_vec(); + padded.resize(name.len().next_multiple_of(4), 0); + let bind = [dmabuf_name, name.len() as u32] + .into_iter() + .chain( + padded + .chunks_exact(4) + .map(|word| u32::from_ne_bytes(word.try_into().unwrap())), + ) + .chain([4, 8]) + .collect::>(); + request(&mut wire, 7, 0, &bind, None); + request(&mut wire, 8, 3, &[9, 2], None); // get_surface_feedback + dispatch(&mut events, &mut state); + let initial = messages(&mut wire); + assert_eq!(flags(&initial, 9), [0]); + assert_eq!(done(&initial, 9), 1); + assert_eq!(state.dmabuf_feedback.requested().count(), 1); + let main = initial.iter().find(|(id, opcode, _)| *id == 9 && *opcode == 2).unwrap(); + assert_eq!(main.2[0] as usize, std::mem::size_of::()); + assert_eq!(main.2[1], 10, "main device remains the renderer"); + + let selected = HashMap::from([(surface.clone(), preferred.clone())]); + state.dmabuf_feedback.refresh(&selected); + state.display_handle.flush_clients().unwrap(); + let changed = messages(&mut wire); + assert_eq!(flags(&changed, 9), [TrancheFlags::Scanout.bits(), 0]); + assert_eq!(done(&changed, 9), 1); + let target = changed.iter().find(|(id, opcode, _)| *id == 9 && *opcode == 4).unwrap(); + assert_eq!(target.2[1], 20, "scanout tranche targets the primary DRM device"); + state.dmabuf_feedback.refresh(&selected); + state.display_handle.flush_clients().unwrap(); + assert!(messages(&mut wire).is_empty(), "Smithay suppresses unchanged feedback"); + + request(&mut wire, 8, 3, &[10, 2], None); + dispatch(&mut events, &mut state); + let second = messages(&mut wire); + assert_eq!(flags(&second, 10), [TrancheFlags::Scanout.bits(), 0]); + state.dmabuf_feedback.refresh(&HashMap::new()); // migration/ineligibility fallback + state.display_handle.flush_clients().unwrap(); + let fallback_events = messages(&mut wire); + assert_eq!(flags(&fallback_events, 9), [0]); + assert_eq!(flags(&fallback_events, 10), [0]); + assert_eq!(done(&fallback_events, 9), 1); + assert_eq!(done(&fallback_events, 10), 1); + + request(&mut wire, 9, 0, &[], None); + request(&mut wire, 10, 0, &[], None); + dispatch(&mut events, &mut state); + messages(&mut wire); + request(&mut wire, 8, 3, &[11, 2], None); + request(&mut wire, 8, 2, &[12], None); // get_default_feedback + dispatch(&mut events, &mut state); + let renewed = messages(&mut wire); + assert_eq!(flags(&renewed, 11), [0], "renewal starts with safe render feedback"); + assert_eq!(flags(&renewed, 12), [0]); + state.dmabuf_feedback.refresh(&selected); + state.display_handle.flush_clients().unwrap(); + let promoted = messages(&mut wire); + assert_eq!(flags(&promoted, 11), [TrancheFlags::Scanout.bits(), 0]); + state.dmabuf_feedback.remove(global); + state + .dmabuf_state + .disable_global::(&state.display_handle, &global); + state.display_handle.flush_clients().unwrap(); + let retired = messages(&mut wire); + assert_eq!(done(&retired, 11), 1); + assert_eq!( + flags(&retired, 11), + [0], + "retirement keeps a valid main-device render tranche" + ); + assert_eq!( + done(&retired, 12), + 0, + "default renderer capabilities remain unchanged on existing bindings" + ); + assert!( + retired + .iter() + .any(|(id, opcode, args)| *id == 7 && *opcode == 1 && args[0] == dmabuf_name) + ); + state.dmabuf_feedback.refresh(&selected); // A stale choice cannot resurrect a retired preference. + state.display_handle.flush_clients().unwrap(); + assert!(messages(&mut wire).is_empty()); + assert!(state.dmabuf_feedback.new_surface(&surface, global).is_none()); + drop(wire); + dispatch(&mut events, &mut state); + state.dmabuf_feedback.refresh(&HashMap::new()); + assert_eq!(state.dmabuf_feedback.requested().count(), 0); +} diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index a5ae9cc..e35156e 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -84,6 +84,7 @@ impl CompositorHandler for Ferese { } fn destroyed(&mut self, surface: &WlSurface) { + self.dmabuf_feedback.destroyed(surface); if self.session_lock.active() { self.session_lock .surfaces @@ -112,6 +113,14 @@ impl DmabufHandler for Ferese { &mut self.dmabuf_state } + fn new_surface_feedback( + &mut self, + surface: &WlSurface, + global: &DmabufGlobal, + ) -> Option { + self.dmabuf_feedback.new_surface(surface, *global) + } + fn dmabuf_imported(&mut self, global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) { self.dmabuf_imports .enqueue(*global, dmabuf, notifier, std::time::Instant::now()); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index feeaadc..1ff7063 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -5,6 +5,7 @@ mod config; mod cursor; mod daemon; mod display_presentation; +mod dmabuf_feedback; mod dmabuf_imports; mod effects; mod floating; @@ -243,6 +244,7 @@ fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Fe warn!(%error, "could not schedule wallpaper upload retry"); } + crate::backends::direct::feedback::refresh(state); state.publish_ipc_events(); // Clients need registry/sync/configure replies even when no frame is drawn. diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 9107dff..7aeeaf8 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -514,6 +514,7 @@ pub struct Ferese { pub direct_backend: Option, _ipc_socket: Option, pub(crate) dmabuf_imports: crate::dmabuf_imports::ImportManager, + pub(crate) dmabuf_feedback: crate::dmabuf_feedback::FeedbackManager, pub(crate) activation_inputs: crate::handlers::activation::InputHistory, pub(crate) pending_screencopies: Vec, // Screenshot requests outlive the readback that filled them: the @@ -818,6 +819,7 @@ impl Ferese { direct_backend: None, _ipc_socket: None, dmabuf_imports: crate::dmabuf_imports::ImportManager::default(), + dmabuf_feedback: Default::default(), activation_inputs: Default::default(), pending_screencopies: Vec::new(), screenshot: Coordinator::new(), diff --git a/docs/README.md b/docs/README.md index ae3848a..be6b5c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ and [guides at that tag](https://github.com/ferese-wm/ferese/tree/v0.1.0-beta/do - [Animation model](animation-model.md): geometry, spring motion, gestures and prediction. - [Desktop reconciliation](desktop-reconciliation.md): topology publication and presentation ownership. - [Rendering diagnostics](rendering-performance.md): outcome counters, capture cost and repeatable hardware baselines. +- [DMA-BUF feedback](dmabuf-feedback.md): conservative allocation hints, fallback and hardware validation. - [Output management tests](output-management-testing.md): profiles, hotplug and DRM failures. - [Nested resize checks](nested-resize-testing.md). - [Client soak testing](native-soak-testing.md). diff --git a/docs/development.md b/docs/development.md index c44a64c..d681b9a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,6 +30,7 @@ host. Hardware checks remain separate. - [Live Activities checks](#live-activities-checks): core, CLI, shell hooks, installer setup and native rendering. - [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership. - [Rendering diagnostics](rendering-performance.md): per-output outcomes and repeatable hardware baselines. +- [DMA-BUF feedback](dmabuf-feedback.md): conservative allocation hints, fallback and hardware validation. - [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. diff --git a/docs/dmabuf-feedback.md b/docs/dmabuf-feedback.md new file mode 100644 index 0000000..0bd18ef --- /dev/null +++ b/docs/dmabuf-feedback.md @@ -0,0 +1,94 @@ +# DMA-BUF allocation feedback + +DRM sessions publish renderer formats through a Linux DMA-BUF v4 global for each +render device. Its default feedback identifies that renderer's render node (or +its primary node when no render node is available). Per-surface feedback starts +with this same render fallback; requesting feedback does not require a mapped +window or a scanout-capable buffer. + +After a successful display queue, or a successful render returning no display +changes, Ferese can add an output-specific `scanout` preference. It uses the +primary plane selected by that output's DRM surface, not a union of all planes. +Only an exact FourCC/modifier pair shared by that plane and the renderer is +preferred. The pair must have been observed on the primary result. This narrow +choice follows the current policy's full-format comparison with the swapchain; +it does not advertise broader primary formats, cursor formats or overlay formats. +Implicit modifiers are not equated with explicit linear modifiers, and alpha +formats are not equated with their opaque counterparts. + +The preference's target device is the output's primary DRM node; its main device +and renderer fallback remain the originating global's render device. Every +preferred pair is also renderer-importable. Failed preference construction retains +render feedback rather than failing an already queued display frame. + +## Surface selection and invalidation + +Only the current mapped fullscreen toplevel can receive the preference. Its last +successful frame must expose underlying buffer storage, opaque content covering +the output and full opacity. Rounded shader clipping continues to hide storage. +Current opacity or dimming also withdraws the preference immediately. +The surface must be visible on exactly one registered output, and that output +must belong to the originating import global. Animation, overview, locking, +paused/inactive outputs and mirror targets receive render fallback. A failed +scanout reported by Smithay withdraws the preference; a format-unsupported result +can still receive a compatible allocation hint. + +Feedback selection refreshes after event dispatch, before flushing protocol +messages, even when no frame is drawn. Migration, fullscreen exit, unmapping and +changes in eligibility restore render-only feedback. Multiple visible outputs do +not race to replace one another's feedback. Subsurfaces, popups, layer surfaces +and cursors currently retain render-only feedback; eligibility expansion is a +separate hardware-gated phase. + +Mode changes, surface replacement, rollback and session pause/reactivation +invalidate output observations. A fresh successful frame is needed before a +preference returns. Retiring a GPU withdraws scanout preferences and retires its +global. Version 4 +requires a main-device tranche, so existing bindings retain last-known renderer +capabilities rather than receiving empty feedback or being retargeted to another +GPU. These retained capabilities do not promise device availability: imports +through that retired global are rejected. A reconnected device publishes a new +global. Destroyed surface requests are pruned. +Smithay owns the subscriptions, format-table transport, tranche encoding and +suppression of unchanged updates. Its feedback state is shared by all subscriptions +for one surface; the initial live request determines the originating global. +Destroy all feedback objects before requesting the surface through a different +global to change that association. + +Cross-GPU migration keeps the originating renderer's fallback instead of +advertising unverified formats or retargeting imports to another GPU. This does +not establish cross-GPU composition compatibility: imports are still checked +through the originating global's renderer, and the existing destination-renderer +behavior needs physical validation. Version 3 clients have no per-surface feedback +updates; their existing import checks remain authoritative. + +Allocation feedback is an optimization hint, not proof that a particular scene +or client buffer will scan out. The compositor still owns plane assignment, +clipping, blur, capture privacy, lock protection, gamma and composition fallback. +No plane eligibility flags change here. + +## Checks and hardware gate + +```sh +cargo test --locked -p ferese backends::direct::feedback -- --nocapture +cargo test --locked -p ferese dmabuf_feedback -- --nocapture +``` + +Deterministic tests cover exact intersections, device identities, observed format +changes, candidate geometry, queue failure, migration, multiple outputs, policy +withdrawal, scanout failure and reconfiguration. A private Wayland v4 client checks +initial render feedback, subscriptions, preference/fallback changes, unchanged +update suppression, feedback renewal and retired-device preference withdrawal. It does not +create a real DMA-BUF, use DRM or establish hardware acceptance. + +Before expanding eligibility, record the build, GPU/driver/kernel, output modes, +scale/transforms, application and its actual allocated FourCC/modifier. Decode +v4 feedback device IDs and format tables and verify preferences against the live +renderer and selected primary plane. Test fullscreen entry/exit, animations, +opacity/clipping, popups, sharing and lock/unlock; compare captured pixels and +allocation outcomes. Exercise mixed outputs, mirrors, migration, hotplug, +reconfiguration/rollback, pause/resume, gamma and failed scanout. Include v3 clients +and applications that ignore or follow feedback, and explicitly validate +cross-GPU imports. Use the [output-management hardware checks](output-management-testing.md). +Physical scanout rate, GPU work, power and presentation latency remain unmeasured +until those hardware baselines are collected. diff --git a/docs/rendering-performance.md b/docs/rendering-performance.md index 7f0fbb0..f12ffd6 100644 --- a/docs/rendering-performance.md +++ b/docs/rendering-performance.md @@ -86,5 +86,5 @@ actual framebuffer recapture counts/cost, asynchronous capture encoding and window-capture readback separately. Current effect metrics are placeholders; `capture_us` covers monitor capture batches only. Exact cursor-only KMS updates and detailed scanout rejection causes require additional upstream observability. -Output/per-surface DMA-BUF feedback and eligibility expansion remain later phases, -with broader primary and overlay policies validated separately on hardware. +See [DMA-BUF feedback](dmabuf-feedback.md) for the current allocation hints. +Broader primary-plane and overlay policies still need hardware validation.