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

commit81d798a9a00826f5ffe97b708a77e39751b96ef8
parente1da81b578
authorLucas Galante <[email protected]>
date2026-09-18 20:23
fix(xwayland): a hidpi-exempt game keeps its own size and takes a fullscreen

Trackmania could not be made fullscreen: every Fullscreen press undid
itself within the frame. The chain: saved state restored the launcher-
sized 1214x689 onto the game's window at map, the game (windowedfull)
pinned that size in fixed hints, and on the fullscreen configure it
re-requested 1214x690 — which the floating rule granted, shrinking the X
window back, so Wine no longer saw a screen-sized rect and withdrew the
_NET_WM_STATE_FULLSCREEN the compositor had set. Meanwhile its request
for the desktop origin was refused ~170 times a second.

A window named in xwayland_hidpi_except is now treated as the
full-screen game the key is for (`window_is_hidpi_exempt`): try_restore
skips it (it sizes itself), handle_request_configure grants its
position like a transient's and moves the virtual origin with it, and a
FULLSCREEN window of any kind holds its size against a client's smaller
request, like a tiled one. handle_request_fullscreen logs.

Verified in an --xwayland --scale 2 shadow with a fixed-size GTK4 X11
window named in the key: a seeded 300x200 saved state is skipped (maps
at 800x500), a self-move to (0,0) is granted with one configure, a
compositor fullscreen holds 640x360 against the client's 800x500
re-request and stays Fullscreen, and floating returns it to 800x500.

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

 src/server/config.rs          |  9 ++++++
 src/server/window.rs          | 13 ++++++++
 src/server/xwayland_window.rs | 75 +++++++++++++++++++++++++++++++++++++------
 3 files changed, 88 insertions(+), 9 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index e5d55b8..6f88473 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -415,6 +415,15 @@ pub struct WindowManagerConfig {
     /// 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"`.
+    ///
+    /// A named window is treated as the full-screen X11 game it is
+    /// (`xwayland_window::window_is_hidpi_exempt`): it is drawn at 1 in the
+    /// logical world, it is NOT restored to a saved size at map (it sizes
+    /// itself to the screen — a restored 1214x689 is what Trackmania then
+    /// pinned in its hints), its own position requests are granted (its
+    /// "windowedfull" asks for the desktop origin, and refusing that was a
+    /// ~170/s configure loop), and only a compositor fullscreen or tiling
+    /// overrides its size.
     pub xwayland_hidpi_except: Option<Vec<String>>,
     /// Apps whose windows turn trackpad input into a view drag (Space +
     /// button) — see `cursor::ViewDrag`. A scroll over one of their popups
diff --git a/src/server/window.rs b/src/server/window.rs
index 325bce9..f8bf111 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1117,6 +1117,19 @@ impl Window {
         if matches!(self.impl_type, WindowImpl::Xwayland(_)) && self.state != WindowState::Mapped {
             return;
         }
+        // A full-screen X11 game (`xwayland_hidpi_except`) sizes itself to
+        // the screen; restoring a saved size onto it is what shrank
+        // Trackmania to the launcher's 1214x689 — the game then pinned that
+        // size in its hints and no fullscreen could take. Mark it restored
+        // so nothing else tries.
+        if crate::xwayland_window::window_is_hidpi_exempt(self as *const Window) {
+            log::info!(
+                "Not restoring saved state for {:?}: named in xwayland_hidpi_except, it places itself",
+                self.get_title_string().unwrap_or_default()
+            );
+            self.restored = true;
+            return;
+        }
         let app_id_str = self.get_app_id_string().unwrap_or_default();
         if app_id_str.is_empty()
             || app_id_str.starts_with("cce-status")
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 95f6e16..a8e4206 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -242,6 +242,38 @@ pub unsafe fn x11_scale_for_surface(
 /// WM_CLASS class, the WM_CLASS instance and the title with the
 /// `app_id_matches` rules (case-insensitive, `*` wildcards). Empty fields
 /// never match.
+/// Whether `window` is an X11 window named in `xwayland_hidpi_except` — a
+/// full-screen X11 game, by the key's definition. Such a window sizes and
+/// places itself to the screen, and the compositor stays out of its way:
+/// no saved-state restore (`Window::try_restore`), its position requests
+/// are granted (`handle_request_configure`), and only a compositor
+/// fullscreen overrides its size.
+pub unsafe fn window_is_hidpi_exempt(window: *const crate::window::Window) -> bool {
+    if window.is_null() {
+        return false;
+    }
+    let crate::window::WindowImpl::Xwayland(xwindow) = (*window).impl_type else {
+        return false;
+    };
+    if xwindow.is_null() || (*xwindow).xsurface.is_null() {
+        return false;
+    }
+    let server = (*window).server;
+    if server.is_null() || !(*server).wm.xwayland_hidpi || (*server).wm.xwayland_hidpi_except.is_empty() {
+        return false;
+    }
+    let xsurface = (*xwindow).xsurface;
+    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() }
+    };
+    hidpi_exempt(
+        &(*server).wm.xwayland_hidpi_except,
+        &text((*xsurface).class),
+        &text((*xsurface).instance),
+        &text((*xsurface).title),
+    )
+}
+
 pub fn hidpi_exempt(patterns: &[String], class: &str, instance: &str, title: &str) -> bool {
     use crate::window_manager::app_id_matches;
     patterns.iter().any(|p| {
@@ -665,7 +697,22 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         (*(*xwindow).xsurface).x, (*(*xwindow).xsurface).y, (*(*xwindow).xsurface).width, (*(*xwindow).xsurface).height,
     );
 
-    if has_parent {
+    let is_tiled = unsafe {
+        (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Utility)
+    };
+    let is_fullscreen = unsafe { (*window).is_fullscreen() };
+
+    // A window named in `xwayland_hidpi_except` is a full-screen X11 game
+    // that sizes AND places itself to the screen (Trackmania's
+    // "windowedfull" asks for (0, 0) at the desktop size). Refusing the
+    // position — answering every request with the compositor's placement —
+    // had Wine re-asking ~170 times a second for as long as the window was
+    // up. It gets the parented treatment: position and size granted, the
+    // virtual origin moved with it. Not while the compositor has it
+    // fullscreen or tiled: then the size is the compositor's (below).
+    let exempt_self_placed = !has_parent && !is_fullscreen && !is_tiled && window_is_hidpi_exempt(window);
+
+    if has_parent || exempt_self_placed {
         // Granted on the logical grid rather than verbatim — see `snap_x11`.
         // The logical values below are what the window's geometry becomes, so
         // handing X anything else is handing it a number this compositor
@@ -700,19 +747,24 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
         (*window).virtual_x = vx;
         (*window).virtual_y = vy;
+        if exempt_self_placed {
+            // Placed by the client: the camera must not pan to it on spawn.
+            (*window).hint_placed = true;
+        }
         (*window).set_dimensions(log_width, log_height);
         return;
     }
 
-    let is_tiled = unsafe {
-        (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Utility)
-    };
-
-    let is_fullscreen = unsafe { (*window).is_fullscreen() };
-
     // A floating window normally gets the size it asks for; not while an
-    // output is coming or going (see `note_output_change`).
-    let hold_size = is_tiled || in_output_change_grace();
+    // output is coming or going (see `note_output_change`), and not while
+    // the compositor has it FULLSCREEN: Wine syncs a window's
+    // _NET_WM_STATE from its own idea of the window rect, so a game whose
+    // fixed-size hints were granted here shrank the X window back the
+    // instant the fullscreen configure went out, then withdrew the
+    // fullscreen state Wine no longer saw as true — every Fullscreen press
+    // on Trackmania undid itself within the same frame. Held at the
+    // fullscreen size, Wine sees a screen-sized rect and keeps the state.
+    let hold_size = is_tiled || is_fullscreen || in_output_change_grace();
     if hold_size && !is_tiled {
         log::info!(
             "XWayland configure request: holding floating '{}' at its own size during output-change grace",
@@ -859,6 +911,11 @@ unsafe extern "C" fn handle_request_maximize(listener: *mut ffi::wl_listener, _d
 unsafe extern "C" fn handle_request_fullscreen(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let xwindow = crate::container_of!(listener, XwaylandWindow, request_fullscreen);
     let fullscreen = (*(*xwindow).xsurface).fullscreen;
+    log::info!(
+        "XWayland fullscreen request: title='{}' fullscreen={}",
+        (*(*xwindow).window).get_title_string().unwrap_or_default(),
+        fullscreen,
+    );
     (*(*xwindow).window).wm_scheduled.fullscreen_requested = if fullscreen {
         crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut())
     } else {