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

commitac01d76a3d9637ce2da42489c93b2f91a0ef3817
parent1f5d8bd336
authorLucas Galante <[email protected]>
date2026-07-28 15:19
fix: frozen saved-surface copies keep their save-time size — zoom ghosts

The remaining zoom artifacts ("stale shadows around claude that stack
over time") were REAL scene content: frozen buffer copies made by
surfaces.save() (transaction freezes and every unmapping window, which
renders on while Closing) resized by the scale iterators' non-surface
fallback from the buffer's NATURAL size. That size is meaningless for
geometry — HiDPI clients commit scale-2 buffers (2x too big) and
Chromium pads buffers beyond the surface and crops via viewport src
(claude's buffer: 1536x768 natural for a 527x168 window in the nested
repro, ~3x) — so every zoom change ballooned the copies into
full-size-looking ghosts hugging the scaled window, made persistent by
the (correct) full-damage repaint. All four iterator sites now leave
non-surface buffers untouched: a frozen copy keeps the dest/position
it was saved with (correct at save time); zooming mid-transaction
leaves it briefly at the old zoom, which restore corrects.

Also: new `ccectl debug-buffers [app_id|id]` scene introspection (this
found the bug — dumps per-buffer pos/dest/natural/surface-backed for a
window's trees), with river wrappers for dest size and node enabled.

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

 src/cce_ctl.rs                   |  1 +
 src/server/window.rs             | 88 +++++++++++++++-------------------------
 src/server/window_manager.rs     | 58 ++++++++++++++++++++++++++
 src/server/wlroots_log_wrapper.c | 12 ++++++
 wrapper.h                        |  3 ++
 5 files changed, 106 insertions(+), 56 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 014e827..a2fc5fd 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -69,6 +69,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  screenshot                          # capture the screen to ~/Pictures/screenshots");
     print("  screenshot region <x> <y> <w> <h>   # capture an on-screen region (logical px)");
     print("  screenshot window [app_id|id]       # capture a window (focused if omitted; works off-screen)");
+    print("  debug-buffers [app_id|id]           # dump a window's scene buffers (pos/dest/natural/surface)");
     print("  notify <title> [body]");
     print("  bind <mods> <keysym> <action> [args...]");
     print("  pbind <mods> <button> <action>");
diff --git a/src/server/window.rs b/src/server/window.rs
index 175193b..582bcdb 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1993,21 +1993,15 @@ impl Window {
                         let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
                         ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                     }
-                } else if data.scale == 1.0 {
-                    ffi::river_scene_buffer_set_dest_size_if_changed(buffer, 0, 0);
-                    ffi::river_scene_node_set_position_if_changed(node, sx, sy);
-                } else {
-                    let w = ffi::river_scene_buffer_get_width(buffer);
-                    let h = ffi::river_scene_buffer_get_height(buffer);
-                    let dest_w = (w as f64 * data.scale) as i32;
-                    let dest_h = (h as f64 * data.scale) as i32;
-                    ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
-
-                    let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                    let dest_x = (px as f64 * (data.scale - 1.0)) as i32;
-                    let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
-                    ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                 }
+                // Non-surface buffers are frozen SAVED copies (see
+                // save_surface_tree_iter): their natural buffer size is
+                // meaningless for geometry — HiDPI clients commit scale-N
+                // buffers and Chromium pads buffers beyond the surface,
+                // cropping via viewport src — so rescaling from it ballooned
+                // ghosts around the window at any zoom change. A frozen copy
+                // keeps its save-time dest/position; a zoom mid-transaction
+                // 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 };
@@ -2207,21 +2201,15 @@ impl Window {
                     let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
                     ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                 }
-            } else if data.scale == 1.0 {
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, 0, 0);
-                ffi::river_scene_node_set_position_if_changed(node, sx, sy);
-            } else {
-                let w = ffi::river_scene_buffer_get_width(buffer);
-                let h = ffi::river_scene_buffer_get_height(buffer);
-                let dest_w = (w as f64 * data.scale) as i32;
-                let dest_h = (h as f64 * data.scale) as i32;
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
-
-                let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                let dest_x = (px as f64 * (data.scale - 1.0)) as i32;
-                let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
-                ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
             }
+            // Non-surface buffers are frozen SAVED copies (see
+            // save_surface_tree_iter): their natural buffer size is
+            // meaningless for geometry — HiDPI clients commit scale-N
+            // buffers and Chromium pads buffers beyond the surface,
+            // cropping via viewport src — so rescaling from it ballooned
+            // ghosts around the window at any zoom change. A frozen copy
+            // keeps its save-time dest/position; a zoom mid-transaction
+            // 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 };
@@ -3616,21 +3604,15 @@ impl Decoration {
                     let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
                     ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                 }
-            } else if data.scale == 1.0 {
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, 0, 0);
-                ffi::river_scene_node_set_position_if_changed(node, sx, sy);
-            } else {
-                let w = ffi::river_scene_buffer_get_width(buffer);
-                let h = ffi::river_scene_buffer_get_height(buffer);
-                let dest_w = (w as f64 * data.scale) as i32;
-                let dest_h = (h as f64 * data.scale) as i32;
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
-
-                let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                let dest_x = (px as f64 * (data.scale - 1.0)) as i32;
-                let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
-                ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
             }
+            // Non-surface buffers are frozen SAVED copies (see
+            // save_surface_tree_iter): their natural buffer size is
+            // meaningless for geometry — HiDPI clients commit scale-N
+            // buffers and Chromium pads buffers beyond the surface,
+            // cropping via viewport src — so rescaling from it ballooned
+            // ghosts around the window at any zoom change. A frozen copy
+            // keeps its save-time dest/position; a zoom mid-transaction
+            // 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 };
@@ -3692,21 +3674,15 @@ impl Decoration {
                     let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
                     ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                 }
-            } else if data.scale == 1.0 {
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, 0, 0);
-                ffi::river_scene_node_set_position_if_changed(node, sx, sy);
-            } else {
-                let w = ffi::river_scene_buffer_get_width(buffer);
-                let h = ffi::river_scene_buffer_get_height(buffer);
-                let dest_w = (w as f64 * data.scale) as i32;
-                let dest_h = (h as f64 * data.scale) as i32;
-                ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
-
-                let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                let dest_x = (px as f64 * (data.scale - 1.0)) as i32;
-                let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
-                ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
             }
+            // Non-surface buffers are frozen SAVED copies (see
+            // save_surface_tree_iter): their natural buffer size is
+            // meaningless for geometry — HiDPI clients commit scale-N
+            // buffers and Chromium pads buffers beyond the surface,
+            // cropping via viewport src — so rescaling from it ballooned
+            // ghosts around the window at any zoom change. A frozen copy
+            // keeps its save-time dest/position; a zoom mid-transaction
+            // 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 };
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 3425edb..8e3a698 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2191,6 +2191,64 @@ impl WindowManager {
             self.stop_panning_animation();
         }
         match action {
+            // Scene introspection: dump every scene buffer of a window's
+            // trees — position, dest size, natural buffer size,
+            // surface-backed or not. Found the zoom-ghost bug; kept as a
+            // debugging tool.
+            "debug-buffers" => {
+                let win = if parts.len() >= 2 {
+                    self.find_window_by_query(&parts[1..].join(" "))
+                } else {
+                    self.focused_window()
+                };
+                if win.is_null() {
+                    return "error: no matching window\n".to_string();
+                }
+                let mut out = format!(
+                    "window box=({},{},{}x{}) scale={} saved={} tree_en={} surf_en={} saved_en={}\n",
+                    (*win).box_geom.x, (*win).box_geom.y, (*win).box_geom.width, (*win).box_geom.height,
+                    (*win).scale,
+                    (*win).surfaces.saved,
+                    ffi::river_scene_node_get_enabled((*win).tree as *mut ffi::wlr_scene_node),
+                    ffi::river_scene_node_get_enabled((*win).surfaces.tree as *mut ffi::wlr_scene_node),
+                    ffi::river_scene_node_get_enabled((*win).surfaces.saved_tree as *mut ffi::wlr_scene_node),
+                );
+                unsafe extern "C" fn dump_iter(
+                    buffer: *mut ffi::wlr_scene_buffer,
+                    sx: i32,
+                    sy: i32,
+                    user_data: *mut std::ffi::c_void,
+                ) {
+                    let out = &mut *(user_data as *mut String);
+                    let node = buffer as *mut ffi::wlr_scene_node;
+                    let surface = ffi::river_scene_node_get_surface(node);
+                    out.push_str(&format!(
+                        "  buf sx={} sy={} dest={}x{} natural={}x{} surface={} enabled={}\n",
+                        sx,
+                        sy,
+                        ffi::river_scene_buffer_get_dest_width(buffer),
+                        ffi::river_scene_buffer_get_dest_height(buffer),
+                        ffi::river_scene_buffer_get_width(buffer),
+                        ffi::river_scene_buffer_get_height(buffer),
+                        !surface.is_null(),
+                        ffi::river_scene_node_get_enabled(node),
+                    ));
+                }
+                for (name, node) in [
+                    ("surfaces", (*win).surfaces.tree as *mut ffi::wlr_scene_node),
+                    ("saved", (*win).surfaces.saved_tree as *mut ffi::wlr_scene_node),
+                    ("popup", (*win).popup_tree as *mut ffi::wlr_scene_node),
+                    ("whole-tree", (*win).tree as *mut ffi::wlr_scene_node),
+                ] {
+                    out.push_str(&format!("[{}]\n", name));
+                    ffi::wlr_scene_node_for_each_buffer(
+                        node,
+                        Some(dump_iter),
+                        &mut out as *mut String as *mut std::ffi::c_void,
+                    );
+                }
+                return out;
+            }
             "status-hide-mode" => {
                 let enable = if parts.len() >= 2 {
                     match parts[1] {
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index a39fe76..e3e3313 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -898,3 +898,15 @@ int river_scene_buffer_get_height(struct wlr_scene_buffer *scene_buffer) {
 	}
 	return scene_buffer->dst_height;
 }
+
+int river_scene_buffer_get_dest_width(struct wlr_scene_buffer *scene_buffer) {
+	return scene_buffer->dst_width;
+}
+
+int river_scene_buffer_get_dest_height(struct wlr_scene_buffer *scene_buffer) {
+	return scene_buffer->dst_height;
+}
+
+bool river_scene_node_get_enabled(struct wlr_scene_node *node) {
+	return node->enabled;
+}
diff --git a/wrapper.h b/wrapper.h
index 384b290..bd996e3 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -269,6 +269,9 @@ void river_scene_node_set_corner_radius(struct wlr_scene_node *node, int radius)
 void river_scene_buffer_set_dest_size_if_changed(struct wlr_scene_buffer *scene_buffer, int width, int height);
 int river_scene_buffer_get_width(struct wlr_scene_buffer *scene_buffer);
 int river_scene_buffer_get_height(struct wlr_scene_buffer *scene_buffer);
+int river_scene_buffer_get_dest_width(struct wlr_scene_buffer *scene_buffer);
+int river_scene_buffer_get_dest_height(struct wlr_scene_buffer *scene_buffer);
+bool river_scene_node_get_enabled(struct wlr_scene_node *node);
 
 void river_scene_node_set_position_if_changed(struct wlr_scene_node *node, int x, int y);