Bug: WineHQ bug 57955 — maximized/resized window decorations oscillate
Affects: All compositors (KDE/KWin, GNOME/Mutter), both X11 and Wayland drivers, also Wine desktop mode
Regression range: Wine 8.21 → late 9.0-rc (likely commit cb1c03b926c, Wine 9.16)
Date: 2026-04-14
When a decorated window is maximized, resized, or moved, the window rect oscillates — causing menu bar flicker, content gaps, self-resizing on drag, and bottom content cutoff. The loop is continuous and never converges.
Tested with Ableton Live 12 on KDE/KWin (XWayland), but the bug reproduces:
- On both winex11.drv and winewayland.drv
- In Wine desktop mode (no window manager at all)
- With any app that has standard Win32 decorations (WS_CAPTION | WS_THICKFRAME)
The feedback loop is in win32u’s set_window_pos chain, not in the display drivers. The cycle:
ConfigureNotify (from WM or Wine desktop)
→ WM_WINE_WINDOW_STATE_CHANGED
→ NtUserSetWindowPos (with WM's constrained rect)
→ calc_ncsize → sends WM_NCCALCSIZE → computes client rect with NC area
→ get_window_surface → get_visible_rect → computes visible = window - NC offsets
→ apply_window_pos → stores rects, calls pWindowPosChanged
→ driver reconfigures window → ConfigureNotify with different rect
→ LOOP
Wine’s get_visible_rect (dlls/win32u/window.c:2072) computes the visible rect by subtracting NC offsets from the window rect using NtUserAdjustWindowRect. These offsets use Wine’s built-in NC metrics (border=4, caption=30), which don’t match any real window manager’s frame:
| Metric | Wine (NtUserAdjustWindowRect) | KWin (_NET_FRAME_EXTENTS) |
|---|---|---|
| Left border | 4 | 6 |
| Right border | 4 | 6 |
| Top (caption) | 30 | 27 |
| Bottom border | 4 | 6 |
| Total | 72 | 90 |
Each cycle, window_rect_from_visible converts the WM’s ConfigureNotify rect back to a window rect using Wine’s stored offsets. The mismatch produces a different window rect every time. Specifically, a constant -48px height delta per cycle (observed in traces):
NSPA_CFG 0x1009a config_vis (389,59)-(2430,1818) stored_vis (389,59)-(2430,1899) stored_win (389,59)-(2430,1899)
→ new_win (389,59)-(2430,1818) old_win (389,59)-(2430,1899) delta(0,0,0,-81) flags 0x16
| Symptom | Trigger | Mechanism |
|---|---|---|
| Left drift (-1px/frame) | Window move | Visible rect offset mismatch → position conversion error |
| Height growth (+4px/frame) | Window resize | NC bottom border (4) vs WM bottom frame (6) accumulates |
| Menu bar flicker | Maximize/restore | State transition recalculates NC, offsets flip |
| Bottom content cutoff | Maximize | Visible rect 2px too tall (Wine border=4 vs KWin frame=6) |
In apply_window_pos (dlls/win32u/window.c:2277):
if (old_surface != new_surface) swp_flags |= SWP_FRAMECHANGED;
Any visible rect change that triggers a new surface forces SWP_FRAMECHANGED, which triggers WM_NCCALCSIZE, which recalculates the NC area, which changes the visible rect — amplifying the loop.
cb1c03b926c (Wine 9.16) — “Move visible rect computation out of the drivers”
This commit:
1. Created get_mwm_decorations_for_style() split from get_mwm_decorations()
2. Created X11DRV_GetWindowStyleMasks()
3. Moved get_visible_rect() from the X11 driver to win32u
4. Changed the EqualRect check: old code checked window == client, new code checks window == visible
Before this commit, the X11 driver computed the visible rect directly using its own knowledge of the WM’s frame. After this commit, win32u computes it using NtUserAdjustWindowRect — which returns Wine’s built-in metrics, not the WM’s actual metrics.
_NET_FRAME_EXTENTS driver callback (pGetFrameExtents)Implementation: Added _NET_FRAME_EXTENTS atom + PropertyNotify handler to X11 driver. New pGetFrameExtents callback in struct user_driver_funcs. When KWin reports its frame extents, get_visible_rect uses them instead of NtUserAdjustWindowRect.
Result: Callback works correctly (verified: 5834 calls with correct extents (-6,-27,6,6)). But the loop continues because the app re-inflates to its preferred size via WM_WINDOWPOSCHANGED response. The WM constrains back, Wine re-inflates, loop.
Code is in place: Atom, PropertyNotify handler, pGetFrameExtents callback, X11DRV_GetFrameExtents — all implemented and working. May be useful once the win32u fix is in place.
Tried: EqualRect(window, visible) → no decorations (upstream check) and EqualRect(window, client) → no decorations.
Result: Both created chicken-and-egg loops — no decorations → no frame → check re-triggers → decorations oscillate between present and absent. The window == visible check is self-fulfilling (no decorations → vis==win → check says no decorations).
Implementation: Changed get_visible_rect line 2084 to if (EqualRect(window, client) && !style_mask && !ex_style_mask) — applied decoration offset even when client==window.
Result: Caused 4px/frame unbounded height growth for all windows. Traced via diagnostic ERR: 3378 NCCALCSIZE calls in one session, height increasing by 4px each cycle.
| File | Function | Role |
|---|---|---|
dlls/win32u/window.c:2072 |
get_visible_rect |
Computes visible rect from window rect using NC offsets |
dlls/win32u/window.c:3682 |
calc_ncsize |
Sends WM_NCCALCSIZE, computes client rect |
dlls/win32u/window.c:2151 |
get_window_surface |
Calls get_visible_rect, creates surface |
dlls/win32u/window.c:2251 |
apply_window_pos |
Stores rects, calls driver, forces FRAMECHANGED on surface change |
dlls/win32u/window.c:3935 |
set_window_pos |
Main SWP entry: calc_winpos → calc_ncsize → get_window_surface → apply_window_pos |
dlls/win32u/window.c:4778 |
update_window_state |
Lightweight SWP that recalculates visible rect |
dlls/win32u/message.c:2224 |
WM_WINE_WINDOW_STATE_CHANGED |
Entry from driver ConfigureNotify → NtUserSetWindowPos |
include/wine/gdi_driver.h:63 |
window_rect_from_visible |
Converts visible → window using stored offsets |
dlls/winex11.drv/window.c:1812 |
window_update_client_config |
Converts ConfigureNotify → SWP flags + rect |
struct window_rects {
RECT window; // Full Win32 window area (including NC frame)
RECT client; // Client area (from WM_NCCALCSIZE)
RECT visible; // X11/Wayland surface area (from get_visible_rect)
};
visible = window - decoration_offsets where offsets come from NtUserAdjustWindowRect (Wine’s NC metrics). The problem: these offsets don’t match the actual WM frame.
Any decorated window on any Linux WM. Simple test:
wine notepad # then maximize, resize, move — watch for drift/flicker
Ableton Live is a good test case because it’s large and the artifacts are very visible.
ERR-level traces prefixed with NSPA_DBG and NSPA_CFG are active:
- NSPA_DBG in get_visible_rect: shows which frame source is used (AdjustWindowRect vs WM extents)
- NSPA_DBG in window_net_frame_extents_notify: shows when _NET_FRAME_EXTENTS arrives
- NSPA_CFG in window_update_client_config: shows ConfigureNotify rect vs stored rects vs computed window rect, with delta
get_visible_rect to use actual WM frame metricsThe pGetFrameExtents callback infrastructure is already in place and working. The challenge: making it work for ALL windows (standard NC and custom-chrome) without breaking the surface/drawing model.
Key constraint: the visible rect determines the surface size. If visible < window, the surface is smaller than the app’s drawing area. For standard NC windows this is fine (NC area is drawn by Wine). For custom-chrome windows (client==window), the app expects to draw the full window.
Something in the SWP chain (possibly WM_WINDOWPOSCHANGED → app response, or WM_GETMINMAXINFO, or SWP_FRAMECHANGED amplifier) re-inflates the window after the WM constrains it. Find what and suppress it for WM-initiated position changes.
The SWP_NOSENDCHANGING flag (set for maximized/fullscreen in window_update_client_config) partially addresses this but doesn’t fully prevent the loop.
In apply_window_pos:
if (old_surface != new_surface) swp_flags |= SWP_FRAMECHANGED;
This forces NC recalculation whenever the surface changes. If the visible rect changes by even 1px (due to the offset mismatch), a new surface is created, FRAMECHANGED fires, NCCALCSIZE runs, and the cycle amplifies.
Consider: only set FRAMECHANGED when the surface size actually changes, not just the surface pointer.
In window_update_client_config, suppress position/size updates when the delta is within the known frame-mismatch tolerance. This is a band-aid but might be effective as a short-term fix.
The bug was introduced between Wine 8.21 and 9.0-rc. Key commits to examine:
- cb1c03b926c (Wine 9.16): Moved visible rect computation from drivers to win32u
- c2d46eaa1ef (Wine 9.15): Merged CreateLayeredWindow with CreateWindowSurface
- 659e3b3e14e (Wine 10.8): Fixed uninitialized ex_style_mask from 9.16
On Windows, there’s no WM frame mismatch — the visible rect IS the window rect. The NC area is drawn by USER32, not a window manager. Wine’s model of “visible rect = window minus WM frame” is an abstraction that doesn’t exist on Windows. The fix may need to rethink this abstraction entirely.
All logs are in nspa/docs/logs/. Key files:
| Log file | What it captures |
|---|---|
ableton_diag.log |
NSPA_VIS + NSPA_NC diagnostic traces — shows 4px/frame growth with old NSPA patch, 3378 NCCALCSIZE calls proving the loop. The file that proved the NSPA patch was wrong. |
ableton_win.log |
Full +win WINEDEBUG trace — shows calc_winpos/get_visible_rect/apply_window_pos flow for every SWP. The file that shows the 1px left drift and height growth patterns. |
ableton_frame.log |
pGetFrameExtents callback trace — shows 5834 successful calls with correct extents (-6,-27)-(6,6), proving the callback works but doesn’t fix the loop. |
ableton_cfg.log |
NSPA_CFG trace (first run) — shows ConfigureNotify→window_rect_from_visible conversion with zero offsets (vis==win), proving the offset mismatch is the proximate cause. |
ableton_cfg2.log |
NSPA_CFG trace with frame extents bypass — shows correct offsets but -48px delta per cycle as app re-inflates via WM_WINDOWPOSCHANGED. |
ableton_client.log |
client==window MWM check — shows decoration oscillation (frame extents flipping 6,6,27,6 ↔ 0,0,0,0). |
ableton_mwm.log |
Restored upstream window==visible MWM check — shows self-fulfilling no-decoration loop (frame extents permanently 0,0,0,0). |
NSPA_DBG in get_visible_rect:
NSPA_DBG hwnd 0x1009a using WM frame extents (-6,-27)-(6,6)
NSPA_DBG hwnd 0x1009a using AdjustWindowRect (-4,-30)-(4,4) (no frame extents yet)
NSPA_CFG in window_update_client_config:
NSPA_CFG 0x1009a config_vis (389,59)-(2430,1818) stored_vis (389,59)-(2430,1899) stored_win (389,59)-(2430,1899) → new_win (389,59)-(2430,1818) old_win (389,59)-(2430,1899) delta(0,0,0,-81) flags 0x16
- config_vis: what KWin reported in ConfigureNotify (the actual X11 window position)
- stored_vis/stored_win: what Wine has stored from last SWP cycle
- new_win: what window_rect_from_visible computed (will become the new SetWindowPos input)
- delta: difference between new_win and old_win (position_x, position_y, width_change, height_change)
- flags: SWP flags (0x16 = SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOSENDCHANGING)
The working tree has these NSPA changes vs upstream:
1. _NET_FRAME_EXTENTS infrastructure (atom, PropertyNotify, pGetFrameExtents callback) — in place, working
2. get_mwm_decorations: always requests decorations for managed windows based on style (stable, no chicken-and-egg)
3. get_visible_rect: upstream behavior restored (EqualRect + NtUserAdjustWindowRect)
4. Diagnostic ERR traces (NSPA_DBG, NSPA_CFG) — should be removed before shipping
5. WS_EX_LAYERED MWM exclusion removed (from earlier session, still correct)