git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commiteaf19d5b44938f4102aad0450ae486d969385bbc
parentc3891613cc
authorLucas Galante <[email protected]>
date2026-09-08 20:30
feat(xwayland): per-app exception to xwayland_hidpi for full-screen X11 games

Since da95c6f every X11 client sees a physical-pixel screen, and a game
that sizes itself to that screen renders scale² as many pixels as it did
before: Trackmania under Proton (borderless "windowedfull") went from a
1920x1200 swapchain to 3840x2400 on the scale-2 panel, with 4x MSAA on
top. The screen Xwayland shows is one thing for every client, so the
exception works on the compositor's side of the window instead:
`window_manager { xwayland_hidpi_except "Trackmania" }` names windows
whose configures go out in logical pixels and whose buffers draw at 1
— the pre-hidpi arrangement — so the fullscreen answer to "give me the
whole root" is the logical size and that is what the game renders.

`xwayland_window::x11_scale_for(server, xsurface)` resolves the factor
for one surface and every per-window caller now goes through it
(configure, request_configure, the saved-size restore, the dimensions
readback, `Window::x11_buffer_scale`, override-redirect placement and
per-frame scaling, and the pointer hit-test in `Scene::at_impl`); the
screen-wide `x11_scale` remains for what has no window (Xft.dpi). A
pattern is matched against WM_CLASS class, WM_CLASS instance and the
title, since every Proton window shares the class `steam_proton`.

Verified headless at scale 2 (Xwayland-enabled shadow, 640x360
logical): a fullscreen GTK4 X11 window titled Trackmania is configured
to 640x360 in X, its buffer draws 640x360 at 1:1 and a pointer move to
logical (300,200) reaches it as (300,200); a zenity window in the same
session stays physical (300x266 in X, 150x133 logical, drawn at 1/2).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/config.rs                     | 17 ++++++-
 src/server/scene.rs                      |  8 +++-
 src/server/window.rs                     | 12 +++--
 src/server/window_manager.rs             |  5 ++
 src/server/xwayland_override_redirect.rs |  4 +-
 src/server/xwayland_window.rs            | 82 ++++++++++++++++++++++++++++++--
 6 files changed, 115 insertions(+), 13 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 7d87fc6..df58989 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -399,6 +399,15 @@ pub struct WindowManagerConfig {
     /// of being upscaled from logical size. Default on. KDL:
     /// `xwayland_hidpi (bool)false` to get the old blurry-but-1:1 behaviour.
     pub xwayland_hidpi: Option<bool>,
+    /// Per-app exception to `xwayland_hidpi`: windows matching one of these
+    /// patterns are configured and drawn in the logical world (factor 1)
+    /// while every other X11 window stays physical. Meant for games and other
+    /// X11 clients that size themselves to the whole screen and would render
+    /// scale² times the pixels for nothing. A pattern is tried against the
+    /// window's WM_CLASS class, its WM_CLASS instance and its title, since
+    /// every Proton window shares the class `steam_proton`; `*` wildcards as
+    /// in `rounded_apps`. KDL: `xwayland_hidpi_except "Trackmania"`.
+    pub xwayland_hidpi_except: Option<Vec<String>>,
     /// Apps whose windows turn trackpad input into a view drag (Space +
     /// button) — see `cursor::ViewDrag`. KDL: `touchpad_view_apps "Houdini FX"`.
     pub touchpad_view_apps: Option<Vec<String>>,
@@ -2280,11 +2289,12 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
         let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
         let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
+        let xwayland_hidpi_except = get_child_args_string_vec_opt(node, "xwayland_hidpi_except");
         let touchpad_view_apps = get_child_args_string_vec_opt(node, "touchpad_view_apps");
         let touchpad_view_swipe = get_child_arg_string_opt(node, "touchpad_view_swipe");
         let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
         let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, touchpad_view_invert });
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, touchpad_view_invert });
     }
 
     Ok(Config {
@@ -2345,6 +2355,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
         .as_ref()
         .and_then(|wm| wm.xwayland_hidpi)
         .unwrap_or(true);
+    state.xwayland_hidpi_except = config
+        .window_manager
+        .as_ref()
+        .and_then(|wm| wm.xwayland_hidpi_except.clone())
+        .unwrap_or_default();
     {
         let tv = config.window_manager.as_ref();
         state.touchpad_view_apps = tv.and_then(|w| w.touchpad_view_apps.clone()).unwrap_or_default();
diff --git a/src/server/scene.rs b/src/server/scene.rs
index ab0e31d..54b450c 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -218,12 +218,16 @@ impl Scene {
                             if !window.is_null()
                                 && matches!((*window).impl_type, crate::window::WindowImpl::Xwayland(_)) =>
                         {
-                            let s = crate::xwayland_window::x11_scale((*window).server) as f64;
+                            let xsurface = match (*window).impl_type {
+                                crate::window::WindowImpl::Xwayland(xw) if !xw.is_null() => (*xw).xsurface as *const _,
+                                _ => std::ptr::null(),
+                            };
+                            let s = crate::xwayland_window::x11_scale_for((*window).server, xsurface) as f64;
                             let zoom = if (*window).scale > 0.0 { (*window).scale } else { 1.0 };
                             (s != 1.0).then_some(s / zoom)
                         }
                         SceneNodeDataVal::OverrideRedirect(or) if !or.is_null() => {
-                            let s = crate::xwayland_window::x11_scale((*or).server) as f64;
+                            let s = crate::xwayland_window::x11_scale_for((*or).server, (*or).xsurface) as f64;
                             (s != 1.0).then_some(s)
                         }
                         _ => None,
diff --git a/src/server/window.rs b/src/server/window.rs
index 1318f6f..8431756 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -934,10 +934,12 @@ impl Window {
     /// Dest-size factor for this window's surface buffers on top of the
     /// overview zoom: 1/output-scale for an X11 window under
     /// `xwayland_hidpi`, whose buffer is physical pixels (see
-    /// `xwayland_window::x11_scale`); 1 for everything else.
+    /// `xwayland_window::x11_scale_for`); 1 for everything else, including
+    /// an X11 window named in `xwayland_hidpi_except`.
     pub unsafe fn x11_buffer_scale(&self) -> f64 {
-        if matches!(self.impl_type, WindowImpl::Xwayland(_)) {
-            1.0 / crate::xwayland_window::x11_scale(self.server) as f64
+        if let WindowImpl::Xwayland(xwindow) = self.impl_type {
+            let xsurface = if xwindow.is_null() { std::ptr::null() } else { (*xwindow).xsurface as *const _ };
+            1.0 / crate::xwayland_window::x11_scale_for(self.server, xsurface) as f64
         } else {
             1.0
         }
@@ -1120,7 +1122,7 @@ impl Window {
                 }
                 WindowImpl::Xwayland(xwindow) => {
                     if !xwindow.is_null() && !(*xwindow).xsurface.is_null() {
-                        let s = crate::xwayland_window::x11_scale(self.server);
+                        let s = crate::xwayland_window::x11_scale_for(self.server, (*xwindow).xsurface);
                         (*(*xwindow).xsurface).width = crate::xwayland_window::to_x11(saved.width as i32, s) as u16;
                         (*(*xwindow).xsurface).height = crate::xwayland_window::to_x11(saved.height as i32, s) as u16;
                     }
@@ -2613,7 +2615,7 @@ impl Window {
             }
             WindowImpl::Xwayland(xwindow) => {
                 if !xwindow.is_null() {
-                    let s = crate::xwayland_window::x11_scale(self.server);
+                    let s = crate::xwayland_window::x11_scale_for(self.server, (*xwindow).xsurface);
                     let mut w = crate::xwayland_window::from_x11((*(*xwindow).xsurface).width as i32, s) as u32;
                     let mut h = crate::xwayland_window::from_x11((*(*xwindow).xsurface).height as i32, s) as u32;
                     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8f79015..4f2f714 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -117,6 +117,9 @@ pub struct WindowManager {
     /// Xwayland sees a physical-pixel screen and X11 surfaces draw at
     /// 1/scale (see `WindowManagerConfig::xwayland_hidpi`).
     pub xwayland_hidpi: bool,
+    /// X11 windows kept in the logical world while `xwayland_hidpi` is on
+    /// (see `xwayland_window::x11_scale_for`).
+    pub xwayland_hidpi_except: Vec<String>,
     /// Trackpad-to-view-drag emulation (see `cursor::ViewDrag`).
     pub touchpad_view_apps: Vec<String>,
     pub touchpad_view_swipe_tumble: bool,
@@ -358,6 +361,7 @@ impl WindowManager {
         self.sent.output_config = std::ptr::null_mut();
         self.output_scale = 1.0;
         self.xwayland_hidpi = true;
+        self.xwayland_hidpi_except = Vec::new();
         self.touchpad_view_apps = Vec::new();
         self.touchpad_view_swipe_tumble = false;
         self.touchpad_view_sensitivity = 1.0;
@@ -433,6 +437,7 @@ impl WindowManager {
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
         self.xwayland_hidpi = true;
+        self.xwayland_hidpi_except = Vec::new();
         self.touchpad_view_apps = Vec::new();
         self.touchpad_view_swipe_tumble = false;
         self.touchpad_view_sensitivity = 1.0;
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index b81f47c..3b4072c 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -100,7 +100,7 @@ impl XwaylandOverrideRedirect {
         if self.surface_tree.is_null() {
             return;
         }
-        let s = crate::xwayland_window::x11_scale(self.server);
+        let s = crate::xwayland_window::x11_scale_for(self.server, self.xsurface);
         ffi::wlr_scene_node_set_position(
             self.surface_tree as *mut ffi::wlr_scene_node,
             crate::xwayland_window::from_x11((*self.xsurface).x as i32, s),
@@ -115,7 +115,7 @@ impl XwaylandOverrideRedirect {
         if self.surface_tree.is_null() {
             return;
         }
-        let s = crate::xwayland_window::x11_scale(self.server) as f64;
+        let s = crate::xwayland_window::x11_scale_for(self.server, self.xsurface) as f64;
         if s == 1.0 {
             return;
         }
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 1e37a58..518161d 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -67,7 +67,10 @@ pub const WINE_MARGIN: i32 = 16;
 ///
 /// Off, X11 is the logical layout (Xwayland reads xdg-output) and the
 /// factor is 1. Multi-output with differing scales is not a case X11 can
-/// express — the first output's scale stands for the screen.
+/// express — the first output's scale stands for the screen. A single
+/// window can opt out through `xwayland_hidpi_except` — see `x11_scale_for`,
+/// which every per-window caller goes through; this screen-wide value is
+/// only for what has no window, like the Xft.dpi pushed at Xwayland-ready.
 ///
 /// The factor must survive the output going away. A lid-close suspend
 /// destroys the DRM output and re-creates it on resume, and X11 windows
@@ -143,6 +146,52 @@ pub fn in_output_change_grace() -> bool {
         .is_some_and(|deadline| std::time::Instant::now() < deadline)
 }
 
+/// `x11_scale` for one X11 surface: 1 when the window is named in
+/// `window_manager { xwayland_hidpi_except }`, else the screen's factor.
+///
+/// The screen Xwayland shows is one thing for every client, so an exempt
+/// window still SEES a physical-pixel root; what changes is what the
+/// compositor does with it. Its configures go out in logical pixels and its
+/// buffer is drawn at 1 (`Window::x11_buffer_scale`), the pre-`xwayland_hidpi`
+/// arrangement — so a borderless-fullscreen game that asks for the whole
+/// root is answered with the logical size and renders that many pixels,
+/// not scale² as many. Matched by WM_CLASS class, WM_CLASS instance or
+/// title (`hidpi_exempt`), re-evaluated on every use so a title that
+/// arrives after the first configure still takes effect.
+pub unsafe fn x11_scale_for(
+    server: *mut crate::server::Server,
+    xsurface: *const ffi::wlr_xwayland_surface,
+) -> f32 {
+    if server.is_null() || !(*server).wm.xwayland_hidpi {
+        return 1.0;
+    }
+    if !xsurface.is_null() && !(*server).wm.xwayland_hidpi_except.is_empty() {
+        let text = |p: *const libc::c_char| -> String {
+            if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
+        };
+        let class = text((*xsurface).class);
+        let instance = text((*xsurface).instance);
+        let title = text((*xsurface).title);
+        if hidpi_exempt(&(*server).wm.xwayland_hidpi_except, &class, &instance, &title) {
+            return 1.0;
+        }
+    }
+    x11_scale(server)
+}
+
+/// Whether any of `patterns` names this window: each is tried against the
+/// WM_CLASS class, the WM_CLASS instance and the title with the
+/// `app_id_matches` rules (case-insensitive, `*` wildcards). Empty fields
+/// never match.
+pub fn hidpi_exempt(patterns: &[String], class: &str, instance: &str, title: &str) -> bool {
+    use crate::window_manager::app_id_matches;
+    patterns.iter().any(|p| {
+        [class, instance, title]
+            .iter()
+            .any(|field| !field.is_empty() && app_id_matches(p, field))
+    })
+}
+
 pub fn to_x11(logical: i32, scale: f32) -> i32 {
     (logical as f32 * scale).round() as i32
 }
@@ -220,7 +269,7 @@ impl XwaylandWindow {
         let window = self.window;
         let scheduled = &mut (*window).configure_scheduled;
         let sent = &mut (*window).configure_sent;
-        let s = x11_scale((*window).server);
+        let s = x11_scale_for((*window).server, self.xsurface);
 
         if scheduled.width == Some(0) {
             scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
@@ -457,7 +506,7 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
     let is_wine = (*window).is_wine();
 
     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
-    let s = x11_scale((*window).server);
+    let s = x11_scale_for((*window).server, (*xwindow).xsurface);
     log::info!(
         "XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
         title,
@@ -684,6 +733,33 @@ mod tests {
         assert_eq!(resolve_x11_scale(None, &last), 1.5);
     }
 
+    fn pats(list: &[&str]) -> Vec<String> {
+        list.iter().map(|s| s.to_string()).collect()
+    }
+
+    #[test]
+    fn exempt_matches_class_instance_or_title() {
+        // Proton: every window is class steam_proton, so the game is told
+        // apart by its instance (the exe) or its title.
+        let p = pats(&["Trackmania"]);
+        assert!(hidpi_exempt(&p, "steam_proton", "trackmania.exe", "Trackmania"));
+        assert!(hidpi_exempt(&p, "steam_proton", "", "Trackmania"));
+        assert!(hidpi_exempt(&p, "Trackmania", "", ""));
+        assert!(!hidpi_exempt(&p, "steam_proton", "upc.exe", "Ubisoft Connect"));
+        // Wildcards and case follow app_id_matches.
+        let p = pats(&["trackmania*"]);
+        assert!(hidpi_exempt(&p, "steam_proton", "Trackmania.exe", ""));
+        assert!(!hidpi_exempt(&p, "steam_proton", "", "My Trackmania"));
+    }
+
+    #[test]
+    fn exempt_ignores_empty_fields_and_lists() {
+        assert!(!hidpi_exempt(&[], "steam_proton", "trackmania.exe", "Trackmania"));
+        // An empty field must not match a pattern that is itself empty-ish.
+        assert!(!hidpi_exempt(&pats(&["*"]), "", "", ""));
+        assert!(hidpi_exempt(&pats(&["*"]), "x", "", ""));
+    }
+
     #[test]
     fn scale_before_any_output_is_one() {
         let last = AtomicU32::new(0);