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

commitda95c6ff2f6d10eed9c39c817f44dcc6b057daa4
parent5a5ed12e6d
authorLucas Galante <[email protected]>
date2026-09-08 11:49
feat(xwayland): HiDPI X11 — a physical-pixel screen for Xwayland, drawn at 1/scale

On the scale-2 panel every X11 app was blurry: Xwayland sized its screen
from xdg-output's LOGICAL size, so Houdini rendered at 1920x1200 and the
compositor upscaled the buffer 2x. This is the standard Xwayland HiDPI
trade, done the way Hyprland's force_zero_scaling does it.

With `window_manager { xwayland_hidpi }` on (the default) the xdg-output
global is hidden from the Xwayland client through a wl_display global
filter, so Xwayland falls back to the wl_output MODE and X11 becomes a
physical-pixel world. Every position and size crossing into or out of X11
now converts through `xwayland_window::to_x11` / `from_x11` with the first
output's scale (`x11_scale`): the toplevel configure paths, the restored
saved size, the size read back for the dimensions report, the wine frame
margin, and override-redirect placement. X11 surface buffers draw at
1/scale on top of the overview zoom (`Window::x11_buffer_scale`, folded
into every ScaleData site) and override-redirect trees get the same
treatment from a per-frame pass over a new registry, since the scene's
commit listener resets a committed buffer's dest size. The Xwayland-ready
hook pushes Xft.dpi = 96×scale (and Xcursor.size) with xrdb so Qt 6 —
Houdini — and Xft toolkits scale themselves to match; GTK on X11 still
wants GDK_SCALE in its own environment.

`xwayland_hidpi (bool)false` restores the previous logical-X11 behaviour,
where the factor is 1 and the ff65b0b coordinate fix still holds.

Verified headless at output scale 2 with GTK4 under Xwayland (GDK_SCALE=2):
X reports the screen at 1280x720 and the window at 800x600 while the
compositor keeps it 400x300 logical; Xft.dpi reads 192; text renders
sharp; a menu popover's 260x348 buffer draws at 130x174 under its button;
clicks land on the button and on a popover item; a transient dialog keeps
its logical 300x200.

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

 src/server/config.rs                     |  14 +++-
 src/server/output.rs                     |   7 +-
 src/server/server.rs                     |  69 ++++++++++++++++++
 src/server/window.rs                     |  47 +++++++++----
 src/server/window_manager.rs             |  10 +++
 src/server/xwayland_override_redirect.rs |  71 +++++++++++++++----
 src/server/xwayland_window.rs            | 116 ++++++++++++++++++++-----------
 7 files changed, 265 insertions(+), 69 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 3c6d740..9265b4b 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -393,6 +393,12 @@ pub struct WindowManagerConfig {
     /// KDL: `bevel_apps "*claude*"`. Globbed like `rounded_apps`; reported by
     /// `ccectl windows` as `beveled=`.
     pub bevel_apps: Option<Vec<String>>,
+    /// Present Xwayland with a PHYSICAL-pixel screen (the xdg-output global is
+    /// hidden from it, so it sizes its root from the wl_output mode) and draw
+    /// X11 surfaces at 1/scale, so HiDPI-aware X11 apps render sharp instead
+    /// 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>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -2263,7 +2269,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let corner_shape = get_child_arg_f64_opt(node, "corner_shape");
         let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
         let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
-        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 });
+        let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
+        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 });
     }
 
     Ok(Config {
@@ -2319,6 +2326,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     }
 
     state.output_scale = 1.0f32;
+    state.xwayland_hidpi = config
+        .window_manager
+        .as_ref()
+        .and_then(|wm| wm.xwayland_hidpi)
+        .unwrap_or(true);
     state.display = config.display.clone();
     state.on_app_exit = config
         .window_manager
diff --git a/src/server/output.rs b/src/server/output.rs
index 1066430..0f5cda2 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -607,10 +607,15 @@ impl Output {
         // Re-apply scale to all windows whose scale is not 1.0 right before rendering
         let wm = &(*self.server).wm;
         for &window in wm.windows.iter() {
-            if !window.is_null() && (*window).scale != 1.0 {
+            if !window.is_null() && ((*window).scale != 1.0 || (*window).x11_buffer_scale() != 1.0) {
                 (*window).scale_only_render_finish();
             }
         }
+        for &or in wm.override_redirects.iter() {
+            if !or.is_null() {
+                (*or).apply_x11_scale();
+            }
+        }
 
         // Overview-delay debugging: while /tmp/cce-ovdbg exists (contents =
         // comma-separated app_id substrings), dump the scene-side truth for
diff --git a/src/server/server.rs b/src/server/server.rs
index 55149b0..6a1a646 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -502,6 +502,40 @@ unsafe extern "C" fn handle_new_xwayland_surface(listener: *mut ffi::wl_listener
     }
 }
 
+/// The first field of `struct wl_interface`; bindgen leaves the type opaque.
+#[repr(C)]
+struct WlInterfaceHead {
+    name: *const std::os::raw::c_char,
+}
+
+/// Hide the xdg-output global from the Xwayland client while
+/// `xwayland_hidpi` is on. Xwayland sizes its root window from xdg-output's
+/// LOGICAL size when it can see one (1920x1200 on the scale-2 panel), and
+/// then every X11 app draws at logical resolution and is upscaled — blurry.
+/// Without xdg-output it falls back to the wl_output mode, the physical
+/// pixel grid, and a DPI-aware X11 app renders sharp; the compositor draws
+/// X11 surfaces at 1/scale and converts X11 geometry to match
+/// (`xwayland_window.rs`). Every other client keeps seeing xdg-output.
+unsafe extern "C" fn xwayland_global_filter(
+    client: *const ffi::wl_client,
+    global: *const ffi::wl_global,
+    data: *mut std::ffi::c_void,
+) -> bool {
+    let server = data as *mut Server;
+    if server.is_null() || (*server).xwayland.is_null() || !(*server).wm.xwayland_hidpi {
+        return true;
+    }
+    let xserver = (*((*server).xwayland as *mut WlrXwayland)).server as *mut ffi::wlr_xwayland_server;
+    if xserver.is_null() || (*xserver).client.is_null() || (*xserver).client as *const ffi::wl_client != client {
+        return true;
+    }
+    let iface = ffi::wl_global_get_interface(global) as *const WlInterfaceHead;
+    if iface.is_null() || (*iface).name.is_null() {
+        return true;
+    }
+    std::ffi::CStr::from_ptr((*iface).name).to_bytes() != b"zxdg_output_manager_v1"
+}
+
 unsafe extern "C" fn handle_xwayland_ready(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let server = container_of!(listener, Server, xwayland_ready);
     let xwayland_cast = (*server).xwayland as *mut WlrXwayland;
@@ -511,6 +545,35 @@ unsafe extern "C" fn handle_xwayland_ready(listener: *mut ffi::wl_listener, _dat
             .into_owned();
         log::info!("Xwayland is ready on display {}", display_name);
         std::env::set_var("DISPLAY", &display_name);
+
+        // Under `xwayland_hidpi` X11 is a physical-pixel world (see
+        // `xwayland_window::x11_scale`), so tell X11 clients the DPI that goes
+        // with it: Qt 6 (Houdini) and Xft-based toolkits read Xft.dpi and scale
+        // themselves to match. GTK on X11 wants GDK_SCALE in its own environment
+        // on top of this; that is the app launcher's to provide.
+        let s = crate::xwayland_window::x11_scale(server);
+        if s != 1.0 {
+            let dpi = (96.0 * s).round() as i32;
+            let cursor = (24.0 * s).round() as i32;
+            match std::process::Command::new("xrdb")
+                .args(["-merge", "-"])
+                .env("DISPLAY", &display_name)
+                .stdin(std::process::Stdio::piped())
+                .stdout(std::process::Stdio::null())
+                .stderr(std::process::Stdio::null())
+                .spawn()
+            {
+                Ok(mut child) => {
+                    use std::io::Write;
+                    if let Some(mut stdin) = child.stdin.take() {
+                        let _ = write!(stdin, "Xft.dpi: {}\nXcursor.size: {}\n", dpi, cursor);
+                    }
+                    std::thread::spawn(move || { let _ = child.wait(); });
+                    log::info!("Xwayland HiDPI: X11 scale {} — set Xft.dpi {} via xrdb", s, dpi);
+                }
+                Err(e) => log::warn!("Xwayland HiDPI: could not run xrdb to set Xft.dpi: {}", e),
+            }
+        }
     }
 }
 
@@ -727,6 +790,12 @@ impl Server {
                     return Err("Failed to create xwayland server");
                 }
                 self.xwayland = xwayland;
+                // See `xwayland_global_filter`.
+                ffi::wl_display_set_global_filter(
+                    wl_server,
+                    Some(xwayland_global_filter),
+                    self as *mut Server as *mut std::ffi::c_void,
+                );
             }
 
             // Setup linux dmabuf if supported
diff --git a/src/server/window.rs b/src/server/window.rs
index e2d0e2f..f0fea0c 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -931,6 +931,18 @@ 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.
+    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
+        } else {
+            1.0
+        }
+    }
+
     pub unsafe fn get_parent(&self) -> *mut Window {
         match self.impl_type {
             WindowImpl::Toplevel(toplevel) => {
@@ -1108,8 +1120,9 @@ impl Window {
                 }
                 WindowImpl::Xwayland(xwindow) => {
                     if !xwindow.is_null() && !(*xwindow).xsurface.is_null() {
-                        (*(*xwindow).xsurface).width = saved.width as u16;
-                        (*(*xwindow).xsurface).height = saved.height as u16;
+                        let s = crate::xwayland_window::x11_scale(self.server);
+                        (*(*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;
                     }
                 }
                 _ => {}
@@ -2600,12 +2613,13 @@ impl Window {
             }
             WindowImpl::Xwayland(xwindow) => {
                 if !xwindow.is_null() {
-                    let mut w = (*(*xwindow).xsurface).width as u32;
-                    let mut h = (*(*xwindow).xsurface).height as u32;
+                    let s = crate::xwayland_window::x11_scale(self.server);
+                    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();
                     if self.is_wine() && !has_parent && !self.is_fullscreen() {
-                        w = w.saturating_sub(32);
-                        h = h.saturating_sub(32);
+                        w = w.saturating_sub((crate::xwayland_window::WINE_MARGIN * 2) as u32);
+                        h = h.saturating_sub((crate::xwayland_window::WINE_MARGIN * 2) as u32);
                     }
                     self.rendering_scheduled.width = w;
                     self.rendering_scheduled.height = h;
@@ -3089,7 +3103,10 @@ impl Window {
         if self.fs_anim.is_some() {
             return;
         }
-        if self.scale == 1.0 {
+        // The zoom the overview asks for, times 1/output-scale for an X11
+        // surface whose buffer is physical pixels (`x11_buffer_scale`).
+        let eff_scale = self.scale * self.x11_buffer_scale();
+        if eff_scale == 1.0 {
             self.last_applied_scale = 1.0;
             return;
         }
@@ -3150,7 +3167,7 @@ impl Window {
             // leaves it briefly at the old zoom, which restore corrects.
         }
 
-        let scale_data_surfaces = ScaleData { scale: self.scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+        let scale_data_surfaces = ScaleData { scale: eff_scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
         ffi::wlr_scene_node_for_each_buffer(
             self.surfaces.tree as *mut ffi::wlr_scene_node,
             Some(set_overview_scale_iterator),
@@ -3158,7 +3175,7 @@ impl Window {
         );
 
         if self.surfaces.saved {
-            let scale_data_saved = ScaleData { scale: self.scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+            let scale_data_saved = ScaleData { scale: eff_scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
             ffi::wlr_scene_node_for_each_buffer(
                 self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
                 Some(set_overview_scale_iterator),
@@ -3166,7 +3183,7 @@ impl Window {
             );
         }
 
-        let scale_data_popup = ScaleData { scale: self.scale, ancestor: self.popup_tree as *mut ffi::wlr_scene_node };
+        let scale_data_popup = ScaleData { scale: eff_scale, ancestor: self.popup_tree as *mut ffi::wlr_scene_node };
         ffi::wlr_scene_node_for_each_buffer(
             self.popup_tree as *mut ffi::wlr_scene_node,
             Some(set_overview_scale_iterator),
@@ -4954,7 +4971,7 @@ impl Decoration {
             // leaves it briefly at the old zoom, which restore corrects.
         }
 
-        let scale_data = ScaleData { scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+        let scale_data = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
         ffi::wlr_scene_node_for_each_buffer(
             self.surfaces.tree as *mut ffi::wlr_scene_node,
             Some(set_overview_scale_iterator),
@@ -4962,7 +4979,7 @@ impl Decoration {
         );
 
         if self.surfaces.saved {
-            let scale_data_saved = ScaleData { scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+            let scale_data_saved = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
             ffi::wlr_scene_node_for_each_buffer(
                 self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
                 Some(set_overview_scale_iterator),
@@ -4978,7 +4995,7 @@ impl Decoration {
 
     pub unsafe fn scale_only_render_finish(&mut self) {
         let scale = (*self.window).scale;
-        if scale == 1.0 {
+        if scale * (*self.window).x11_buffer_scale() == 1.0 {
             return;
         }
 
@@ -5029,7 +5046,7 @@ impl Decoration {
             // leaves it briefly at the old zoom, which restore corrects.
         }
 
-        let scale_data = ScaleData { scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+        let scale_data = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
         ffi::wlr_scene_node_for_each_buffer(
             self.surfaces.tree as *mut ffi::wlr_scene_node,
             Some(set_overview_scale_iterator),
@@ -5037,7 +5054,7 @@ impl Decoration {
         );
 
         if self.surfaces.saved {
-            let scale_data_saved = ScaleData { scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+            let scale_data_saved = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
             ffi::wlr_scene_node_for_each_buffer(
                 self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
                 Some(set_overview_scale_iterator),
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 0937564..b2c7de5 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -114,6 +114,13 @@ pub struct WindowManager {
     pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
     pub status_sender: Option<crate::status_server::StatusSender>,
     pub output_scale: f32,
+    /// Xwayland sees a physical-pixel screen and X11 surfaces draw at
+    /// 1/scale (see `WindowManagerConfig::xwayland_hidpi`).
+    pub xwayland_hidpi: bool,
+    /// Live override-redirect X11 surfaces (menus, tooltips, combo lists),
+    /// so the per-frame pass can re-apply their 1/scale dest size — the
+    /// scene's own commit listener resets it on every commit.
+    pub override_redirects: Vec<*mut XwaylandOverrideRedirect>,
     pub display: std::collections::HashMap<String, f64>,
     pub input_rules: Vec<crate::config::InputDeviceConfigRule>,
     pub input_config: crate::config::InputConfig,
@@ -345,6 +352,7 @@ impl WindowManager {
         self.scheduled.output_config = std::ptr::null_mut();
         self.sent.output_config = std::ptr::null_mut();
         self.output_scale = 1.0;
+        self.xwayland_hidpi = true;
         self.display = std::collections::HashMap::new();
         self.input_rules = Vec::new();
         self.input_config = crate::config::InputConfig::default();
@@ -408,12 +416,14 @@ impl WindowManager {
         self.last_window_states = Vec::new();
         self.exit_orphans = Vec::new();
         self.last_saved_windows = Vec::new();
+        self.override_redirects = Vec::new();
         self.pending_placements = Vec::new();
         self.rounded_apps = Vec::new();
         self.bevel_apps = Vec::new();
         self.shutting_down = false;
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
+        self.xwayland_hidpi = true;
         self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;
         self.restored_focused_window_mapped = false;
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index 1f611a4..0889e48 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -71,6 +71,7 @@ impl XwaylandOverrideRedirect {
         });
 
         let raw = Box::into_raw(override_redirect);
+        (*server).wm.override_redirects.push(raw);
 
         connect_listener(&mut (*xsurface).events.request_configure, &mut (*raw).request_configure, handle_request_configure);
         connect_listener(&mut (*xsurface).events.destroy, &mut (*raw).destroy, handle_destroy);
@@ -88,6 +89,60 @@ impl XwaylandOverrideRedirect {
         Ok(())
     }
 
+    /// Put the surface tree where X11 says the window is, in logical
+    /// pixels (`xwayland_window::x11_scale`).
+    pub unsafe fn place(&mut self) {
+        if self.surface_tree.is_null() {
+            return;
+        }
+        let s = crate::xwayland_window::x11_scale(self.server);
+        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),
+            crate::xwayland_window::from_x11((*self.xsurface).y as i32, s),
+        );
+    }
+
+    /// Draw the physical-pixel X11 buffer at 1/scale. Runs from the
+    /// per-frame pass (output.rs) because the scene's commit listener
+    /// resets a committed buffer's dest size; every setter is change-checked.
+    pub unsafe fn apply_x11_scale(&mut self) {
+        if self.surface_tree.is_null() {
+            return;
+        }
+        let s = crate::xwayland_window::x11_scale(self.server) as f64;
+        if s == 1.0 {
+            return;
+        }
+        unsafe extern "C" fn iter(
+            buffer: *mut ffi::wlr_scene_buffer,
+            _sx: i32,
+            _sy: i32,
+            user_data: *mut std::ffi::c_void,
+        ) {
+            let inv = *(user_data as *const f64);
+            let node = buffer as *mut ffi::wlr_scene_node;
+            let surface = ffi::river_scene_node_get_surface(node);
+            if surface.is_null() {
+                return;
+            }
+            let w = ffi::river_wlr_surface_get_width(surface);
+            let h = ffi::river_wlr_surface_get_height(surface);
+            ffi::river_scene_buffer_set_dest_size_if_changed(
+                buffer,
+                (w as f64 * inv).round() as i32,
+                (h as f64 * inv).round() as i32,
+            );
+            ffi::river_scene_buffer_set_scaled_opaque_region(buffer, surface, inv);
+        }
+        let inv = 1.0 / s;
+        ffi::wlr_scene_node_for_each_buffer(
+            self.surface_tree as *mut ffi::wlr_scene_node,
+            Some(iter),
+            &inv as *const f64 as *mut std::ffi::c_void,
+        );
+    }
+
     pub unsafe fn focus_if_desired(&self) {
         if (*self.server).lock_manager.state != crate::lock_manager::LockState::Unlocked {
             return;
@@ -123,6 +178,7 @@ unsafe extern "C" fn handle_request_configure(_listener: *mut ffi::wl_listener,
 
 unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let or = crate::container_of!(listener, XwaylandOverrideRedirect, destroy);
+    (*(*or).server).wm.override_redirects.retain(|&p| p != or);
 
     wl_listener_remove_safe(&mut (*or).request_configure);
     wl_listener_remove_safe(&mut (*or).destroy);
@@ -186,11 +242,8 @@ unsafe fn handle_map_impl(or: *mut XwaylandOverrideRedirect) {
 
     ffi::river_wlr_surface_set_data(surface, surface_tree as *mut ffi::wlr_scene_node as *mut _);
 
-    ffi::wlr_scene_node_set_position(
-        surface_tree as *mut ffi::wlr_scene_node,
-        (*(*or).xsurface).x as i32,
-        (*(*or).xsurface).y as i32,
-    );
+    (*or).place();
+    (*or).apply_x11_scale();
 
     connect_listener(&mut (*(*or).xsurface).events.set_geometry, &mut (*or).set_geometry, handle_set_geometry);
 
@@ -234,13 +287,7 @@ unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut s
 
 unsafe extern "C" fn handle_set_geometry(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let or = crate::container_of!(listener, XwaylandOverrideRedirect, set_geometry);
-    if !(*or).surface_tree.is_null() {
-        ffi::wlr_scene_node_set_position(
-            (*or).surface_tree as *mut ffi::wlr_scene_node,
-            (*(*or).xsurface).x as i32,
-            (*(*or).xsurface).y as i32,
-        );
-    }
+    (*or).place();
 }
 
 unsafe extern "C" fn handle_set_override_redirect(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index dfd0107..535888e 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -50,6 +50,47 @@ unsafe fn wl_listener_remove_safe(listener: *mut ffi::wl_listener) {
     }
 }
 
+/// Wine draws its own frame in a margin around the window; the compositor
+/// hides it by oversizing and offsetting the X window. Logical pixels.
+pub const WINE_MARGIN: i32 = 16;
+
+/// The factor between X11 root coordinates and the logical layout.
+///
+/// With `xwayland_hidpi` on (the default) the xdg-output global is hidden
+/// from Xwayland (`server.rs`), so it sizes its screen from the wl_output
+/// MODE — the physical pixel grid — and X11 is a physical-pixel world: a
+/// HiDPI-aware X11 app (Houdini, any Qt 6 app reading Xft.dpi) renders at
+/// full resolution and its surfaces are drawn at 1/scale
+/// (`Window::x11_buffer_scale`), sharp instead of upscaled from logical size.
+/// Every position and size crossing into or out of X11 converts through
+/// `to_x11` / `from_x11`; the window's own geometry stays logical.
+///
+/// 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.
+pub unsafe fn x11_scale(server: *mut crate::server::Server) -> f32 {
+    if server.is_null() || !(*server).wm.xwayland_hidpi {
+        return 1.0;
+    }
+    let link = (*server).om.outputs.next;
+    if link != &mut (*server).om.outputs as *mut ffi::wl_list {
+        let output = crate::container_of!(link, crate::output::Output, link);
+        let scale = (*output).current.scale;
+        if scale > 0.0 {
+            return scale;
+        }
+    }
+    1.0
+}
+
+pub fn to_x11(logical: i32, scale: f32) -> i32 {
+    (logical as f32 * scale).round() as i32
+}
+
+pub fn from_x11(x11: i32, scale: f32) -> i32 {
+    (x11 as f32 / scale).round() as i32
+}
+
 impl XwaylandWindow {
     pub unsafe fn create(
         xsurface: *mut ffi::wlr_xwayland_surface,
@@ -119,49 +160,44 @@ impl XwaylandWindow {
         let window = self.window;
         let scheduled = &mut (*window).configure_scheduled;
         let sent = &mut (*window).configure_sent;
+        let s = x11_scale((*window).server);
 
         if scheduled.width == Some(0) {
-            scheduled.width = Some((*self.xsurface).width as u32);
+            scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
         }
         if scheduled.height == Some(0) {
-            scheduled.height = Some((*self.xsurface).height as u32);
+            scheduled.height = Some(from_x11((*self.xsurface).height as i32, s) as u32);
         }
 
         let mut phys_width = if let Some(w) = scheduled.width {
-            w as u16
+            to_x11(w as i32, s) as u16
         } else {
             (*self.xsurface).width
         };
 
         let mut phys_height = if let Some(h) = scheduled.height {
-            h as u16
+            to_x11(h as i32, s) as u16
         } else {
             (*self.xsurface).height
         };
 
-        // X11 root coordinates are the LOGICAL layout: Xwayland sizes its
-        // screen from the wl_output's logical size (1920x1200 on the scale-2
-        // panel), and a window's X size already goes over 1:1. Positions used
-        // to be multiplied by the output scale here, which told X a window at
-        // logical (120, 30) sat at (240, 60) — and every override-redirect
-        // popup the client placed relative to that origin (Houdini's menus,
-        // Qt combo lists) landed displaced by the window's own on-screen
-        // position, down and to the right, since the override-redirect layer
-        // draws them at their raw X coordinates.
-        let mut phys_x = (*window).box_geom.x as i16;
-        let mut phys_y = (*window).box_geom.y as i16;
+        // X11 root coordinates: see `x11_scale`. Everything sent to X goes
+        // through `to_x11`, everything read back through `from_x11`; the
+        // window's own geometry stays logical.
+        let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
+        let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
 
         let has_parent = !(*self.xsurface).parent.is_null();
 
         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
             if scheduled.width.is_some() {
-                phys_width += 32;
+                phys_width += to_x11(WINE_MARGIN * 2, s) as u16;
             }
             if scheduled.height.is_some() {
-                phys_height += 32;
+                phys_height += to_x11(WINE_MARGIN * 2, s) as u16;
             }
-            phys_x -= 16;
-            phys_y -= 16;
+            phys_x -= to_x11(WINE_MARGIN, s) as i16;
+            phys_y -= to_x11(WINE_MARGIN, s) as i16;
         }
 
         if phys_x != (*self.xsurface).x
@@ -188,15 +224,15 @@ impl XwaylandWindow {
             ffi::wlr_xwayland_surface_set_fullscreen(self.xsurface, scheduled.inform_fullscreen);
         }
 
-        let mut width = scheduled.width.unwrap_or((*self.xsurface).width as u32);
-        let mut height = scheduled.height.unwrap_or((*self.xsurface).height as u32);
+        let mut width = scheduled.width.unwrap_or(from_x11((*self.xsurface).width as i32, s) as u32);
+        let mut height = scheduled.height.unwrap_or(from_x11((*self.xsurface).height as i32, s) as u32);
 
         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
             if scheduled.width.is_none() {
-                width = width.saturating_sub(32);
+                width = width.saturating_sub((WINE_MARGIN * 2) as u32);
             }
             if scheduled.height.is_none() {
-                height = height.saturating_sub(32);
+                height = height.saturating_sub((WINE_MARGIN * 2) as u32);
             }
         }
 
@@ -297,7 +333,7 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
 
     if (*(*xwindow).window).is_wine() && !has_parent && !(*(*xwindow).window).is_fullscreen() {
-        ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -16, -16);
+        ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -WINE_MARGIN, -WINE_MARGIN);
     }
 
     ffi::river_wlr_surface_set_data(surface, &mut (*(*xwindow).window).node as *mut crate::wm_node::WmNode as *mut _);
@@ -361,6 +397,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);
     log::info!(
         "XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
         title,
@@ -379,11 +416,10 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
             (*event).width,
             (*event).height,
         );
-        // Logical already — see `configure`.
-        let log_x = (*event).x as i32;
-        let log_y = (*event).y as i32;
-        let log_width = (*event).width as u32;
-        let log_height = (*event).height as u32;
+        let log_x = from_x11((*event).x as i32, s);
+        let log_y = from_x11((*event).y as i32, s);
+        let log_width = from_x11((*event).width as i32, s) as u32;
+        let log_height = from_x11((*event).height as i32, s) as u32;
         
         (*window).box_geom.x = log_x;
         (*window).box_geom.y = log_y;
@@ -410,10 +446,10 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
             let mut w = log_w;
             let mut h = log_h;
             if is_wine && !has_parent && !is_fullscreen {
-                w += 32;
-                h += 32;
+                w += (WINE_MARGIN * 2) as u32;
+                h += (WINE_MARGIN * 2) as u32;
             }
-            (w as u16, h as u16)
+            (to_x11(w as i32, s) as u16, to_x11(h as i32, s) as u16)
         } else {
             ((*event).width, (*event).height)
         }
@@ -421,12 +457,12 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         ((*event).width, (*event).height)
     };
 
-    let mut phys_x = (*window).box_geom.x as i16;
-    let mut phys_y = (*window).box_geom.y as i16;
+    let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
+    let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
 
     if is_wine && !has_parent && !is_fullscreen {
-        phys_x -= 16;
-        phys_y -= 16;
+        phys_x -= to_x11(WINE_MARGIN, s) as i16;
+        phys_y -= to_x11(WINE_MARGIN, s) as i16;
     }
 
     ffi::wlr_xwayland_surface_configure(
@@ -436,11 +472,11 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         phys_width,
         phys_height,
     );
-    let mut log_width = phys_width as u32;
-    let mut log_height = phys_height as u32;
+    let mut log_width = from_x11(phys_width as i32, s) as u32;
+    let mut log_height = from_x11(phys_height as i32, s) as u32;
     if is_wine && !has_parent && !is_fullscreen {
-        log_width = log_width.saturating_sub(32);
-        log_height = log_height.saturating_sub(32);
+        log_width = log_width.saturating_sub((WINE_MARGIN * 2) as u32);
+        log_height = log_height.saturating_sub((WINE_MARGIN * 2) as u32);
     }
     (*window).set_dimensions(log_width, log_height);
 }