From f3059c334e2c80966b06de011548fd38937a6872 Mon Sep 17 00:00:00 2001 From: m4ko Date: Sat, 12 Sep 2026 11:32:46 +0200 Subject: [PATCH] Reworked some game window and gamescope methods for Cinnamon. --- src/ptlinux/presentation/game_window.py | 41 +++++++---- src/ptlinux/presentation/gamescope.py | 96 +++++++++++++++++++------ 2 files changed, 101 insertions(+), 36 deletions(-) diff --git a/src/ptlinux/presentation/game_window.py b/src/ptlinux/presentation/game_window.py index b45189c..4461642 100644 --- a/src/ptlinux/presentation/game_window.py +++ b/src/ptlinux/presentation/game_window.py @@ -11,6 +11,7 @@ import select import subprocess import sys import time +from collections import deque from collections.abc import Collection, Iterable, Mapping, Sequence from ..desktop_identity import ( @@ -322,10 +323,10 @@ class _X11: def find_window_by_pid(self, expected_pid: int) -> int | None: """Find a top-level host window created by one native process.""" - queue = self._root_windows() + queue = deque(self._root_windows()) visited: set[int] = set() while queue and len(visited) < 4096: - window = queue.pop(0) + window = queue.popleft() if window in visited: continue visited.add(window) @@ -351,14 +352,17 @@ class _X11: current_size, increments, ) - self._replace_long_property( - window, - b"WM_NORMAL_HINTS", - b"WM_SIZE_HINTS", - updated, - ) - self.library.XSync(self.display, 0) - confirmed = self._long_property(window, b"WM_NORMAL_HINTS", 18) + if updated == hints: + confirmed = hints + else: + self._replace_long_property( + window, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + updated, + ) + self.library.XSync(self.display, 0) + confirmed = self._long_property(window, b"WM_NORMAL_HINTS", 18) return bool( confirmed and len(confirmed) >= 17 @@ -369,18 +373,25 @@ class _X11: def prefer_composited_presentation(self, window: int) -> bool: """Keep the host compositor active for this window at every size.""" + preferred = (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,) + if self._long_property( + window, + b"_NET_WM_BYPASS_COMPOSITOR", + 1, + ) == preferred: + return True self._replace_long_property( window, b"_NET_WM_BYPASS_COMPOSITOR", b"CARDINAL", - (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,), + preferred, ) self.library.XSync(self.display, 0) return self._long_property( window, b"_NET_WM_BYPASS_COMPOSITOR", 1, - ) == (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,) + ) == preferred def _root_windows(self) -> list[int]: root = self.library.XDefaultRootWindow(self.display) @@ -720,9 +731,9 @@ class _X11: Wine marks this game with identical current-size ``PMinSize`` and ``PMaxSize`` constraints and excludes resize/maximize from its Motif hints. Remove only that exact fixed-size pair and preserve unrelated aspect, - increment, and geometry hints. Initial setup is retained only when the - window manager confirms resize plus both maximize actions. Event-driven - maintenance can skip the confirmation because support was already proven. + increment, and geometry hints. Callers can wait for the window manager to + confirm resize plus both maximize actions, or retain the safe client hints + while polling the manager-owned action property independently. """ current_size = self.window_size(window) diff --git a/src/ptlinux/presentation/gamescope.py b/src/ptlinux/presentation/gamescope.py index 4372201..2bd39ec 100644 --- a/src/ptlinux/presentation/gamescope.py +++ b/src/ptlinux/presentation/gamescope.py @@ -31,7 +31,7 @@ WINDOW_DISCOVERY_POLL_SECONDS = 0.05 RESIZE_STEPS_PER_GAME_CANVAS = 20 MIN_RESIZE_INCREMENT = 8 MAX_RESIZE_INCREMENT = 96 -CHILD_STATUS_POLL_SECONDS = 0.05 +CHILD_STATUS_POLL_SECONDS = 0.2 HOST_FOCUS_HEARTBEAT_SECONDS = 0.2 HOST_REICONIFY_INTERVAL_SECONDS = 0.2 GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0 @@ -429,6 +429,20 @@ def _bundled_gamescope_is_usable() -> bool: return os.access(path, os.X_OK) +def _desktop_session_tokens(environment: Mapping[str, str]) -> set[str]: + tokens: set[str] = set() + for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"): + tokens.update( + token + for token in re.split( + r"[^a-z0-9]+", + environment.get(key, "").strip().casefold(), + ) + if token + ) + return tokens + + def _is_kde_session(environment: Mapping[str, str]) -> bool: """Recognize Plasma sessions without relying on an Ubuntu flavor name.""" @@ -440,14 +454,17 @@ def _is_kde_session(environment: Mapping[str, str]) -> bool: return True if environment.get("KDE_SESSION_VERSION", "").strip(): return True - for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"): - tokens = re.split( - r"[^a-z0-9]+", - environment.get(key, "").strip().casefold(), - ) - if "kde" in tokens or any(token.startswith("plasma") for token in tokens): - return True - return False + tokens = _desktop_session_tokens(environment) + return "kde" in tokens or any(token.startswith("plasma") for token in tokens) + + +def _is_cinnamon_session(environment: Mapping[str, str]) -> bool: + """Recognize Cinnamon/Muffin sessions, including the X-Cinnamon spelling.""" + + return any( + token.startswith("cinnamon") + for token in _desktop_session_tokens(environment) + ) def _select_wayland_gamescope( @@ -490,14 +507,17 @@ def _wayland_socket_available(environment: Mapping[str, str]) -> bool: def _select_host_backend(environment: Mapping[str, str]) -> str: - """Choose the host backend with a Plasma-safe XWayland fallback.""" + """Choose the host backend with desktop-specific XWayland fallbacks.""" if _wayland_socket_available(environment): - # Gamescope's native Wayland backend still has KWin-specific presentation, - # fullscreen, and fractional-scaling failures. Plasma normally exposes an - # XWayland display, so keep the resizable host window on the mature SDL/X11 - # path while leaving non-Plasma Wayland sessions on direct presentation. - if _is_kde_session(environment) and environment.get("DISPLAY", "").strip(): + # KWin still has presentation/fullscreen failures here. Cinnamon's native + # Wayland decoration path can omit Gamescope's maximize control. Both + # desktops normally expose XWayland, where Gamescope creates an explicitly + # resizable SDL window and the host window manager supplies its controls. + desktop_needs_xwayland = ( + _is_kde_session(environment) or _is_cinnamon_session(environment) + ) + if desktop_needs_xwayland and environment.get("DISPLAY", "").strip(): return "sdl" return "wayland" return "sdl" @@ -601,11 +621,17 @@ def _tune_process_window( """Stabilize resizing once the SDL host window becomes available.""" deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS + increments = resize_increments(width, height) reported_window: int | None = None - last_configuration: tuple[bool, bool, bool] | None = None + host_window: int | None = None + last_configuration: tuple[bool, bool, bool, bool] | None = None while process.poll() is None and time.monotonic() < deadline: - window = mapper.find_window_by_pid(process.pid) + window = host_window + if window is None: + window = mapper.find_window_by_pid(process.pid) + host_window = window if window is not None: + window = int(window) if diagnostics is not None and window != reported_window: reported_window = window diagnostics.record( @@ -619,20 +645,28 @@ def _tune_process_window( ) if host_focus_publisher is not None: host_focus_publisher.update(mapper, window) - increments = resize_increments(width, height) increments_applied = mapper.set_resize_increments( window, (width, height), increments, ) - # Apply every client-owned hint first, then wait for the window - # manager to publish resize and maximize actions from those hints. - scaling_enabled = mapper.enable_window_scaling(window) + # Gamescope creates this SDL window as resizable. Keep the matching + # client hints even while Muffin is still recalculating its frame; + # rolling them back on a slow acknowledgement permanently removes + # Cinnamon's maximize control. Poll the WM-owned actions separately. + scaling_hints_applied = mapper.enable_window_scaling( + window, + confirm=False, + ) + scaling_enabled = ( + scaling_hints_applied and mapper.window_allows_scaling(window) + ) if host_focus_publisher is not None: host_focus_publisher.update(mapper, window) compositing_preferred = mapper.prefer_composited_presentation(window) configuration = ( increments_applied, + scaling_hints_applied, scaling_enabled, compositing_preferred, ) @@ -642,6 +676,7 @@ def _tune_process_window( "host_scaling_configured", window_id=window, resize_increments_applied=increments_applied, + scaling_hints_applied=scaling_hints_applied, scaling_enabled=scaling_enabled, compositing_preferred=compositing_preferred, width_increment=increments[0], @@ -668,12 +703,19 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: environment = os.environ.copy() backend = _select_host_backend(environment) kde_session = _is_kde_session(environment) + cinnamon_session = _is_cinnamon_session(environment) kde_wayland_xwayland_fallback = ( backend == "sdl" and kde_session and bool(environment.get("WAYLAND_DISPLAY", "").strip()) and bool(environment.get("DISPLAY", "").strip()) ) + cinnamon_wayland_xwayland_fallback = ( + backend == "sdl" + and cinnamon_session + and bool(environment.get("WAYLAND_DISPLAY", "").strip()) + and bool(environment.get("DISPLAY", "").strip()) + ) selected_command = list(map(str, command)) force_composition = backend == "sdl" using_bundled_gamescope = False @@ -696,14 +738,18 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")), host_x11_monitor_available=mapper is not None, kde_session=kde_session, + cinnamon_session=cinnamon_session, kde_wayland_compatibility_mode=( kde_wayland_xwayland_fallback or (backend == "wayland" and kde_session and force_composition) ), kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback, + cinnamon_wayland_xwayland_fallback=cinnamon_wayland_xwayland_fallback, backend_reason=( "kde_wayland_xwayland_compatibility" if kde_wayland_xwayland_fallback + else "cinnamon_wayland_xwayland_compatibility" + if cinnamon_wayland_xwayland_fallback else "wayland_socket" if backend == "wayland" else "x11_or_unavailable_wayland" @@ -812,6 +858,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: file=sys.stderr, flush=True, ) + elif cinnamon_wayland_xwayland_fallback: + print( + "Cinnamon Wayland compatibility mode active: Gamescope is using " + "its resizable SDL host through XWayland so Muffin provides the " + "native minimize, maximize, and close controls.", + file=sys.stderr, + flush=True, + ) if backend == "wayland" and not force_composition: message = ( "Gamescope resize optimization active: native Wayland direct "