Reworked some game window and gamescope methods for Cinnamon.

This commit is contained in:
2026-09-12 11:32:46 +02:00
parent 2cbb9315bf
commit f3059c334e
2 changed files with 101 additions and 36 deletions

View File

@@ -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)

View File

@@ -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 "