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 subprocess
import sys import sys
import time import time
from collections import deque
from collections.abc import Collection, Iterable, Mapping, Sequence from collections.abc import Collection, Iterable, Mapping, Sequence
from ..desktop_identity import ( from ..desktop_identity import (
@@ -322,10 +323,10 @@ class _X11:
def find_window_by_pid(self, expected_pid: int) -> int | None: def find_window_by_pid(self, expected_pid: int) -> int | None:
"""Find a top-level host window created by one native process.""" """Find a top-level host window created by one native process."""
queue = self._root_windows() queue = deque(self._root_windows())
visited: set[int] = set() visited: set[int] = set()
while queue and len(visited) < 4096: while queue and len(visited) < 4096:
window = queue.pop(0) window = queue.popleft()
if window in visited: if window in visited:
continue continue
visited.add(window) visited.add(window)
@@ -351,6 +352,9 @@ class _X11:
current_size, current_size,
increments, increments,
) )
if updated == hints:
confirmed = hints
else:
self._replace_long_property( self._replace_long_property(
window, window,
b"WM_NORMAL_HINTS", b"WM_NORMAL_HINTS",
@@ -369,18 +373,25 @@ class _X11:
def prefer_composited_presentation(self, window: int) -> bool: def prefer_composited_presentation(self, window: int) -> bool:
"""Keep the host compositor active for this window at every size.""" """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( self._replace_long_property(
window, window,
b"_NET_WM_BYPASS_COMPOSITOR", b"_NET_WM_BYPASS_COMPOSITOR",
b"CARDINAL", b"CARDINAL",
(_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,), preferred,
) )
self.library.XSync(self.display, 0) self.library.XSync(self.display, 0)
return self._long_property( return self._long_property(
window, window,
b"_NET_WM_BYPASS_COMPOSITOR", b"_NET_WM_BYPASS_COMPOSITOR",
1, 1,
) == (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,) ) == preferred
def _root_windows(self) -> list[int]: def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display) root = self.library.XDefaultRootWindow(self.display)
@@ -720,9 +731,9 @@ class _X11:
Wine marks this game with identical current-size ``PMinSize`` and Wine marks this game with identical current-size ``PMinSize`` and
``PMaxSize`` constraints and excludes resize/maximize from its Motif hints. ``PMaxSize`` constraints and excludes resize/maximize from its Motif hints.
Remove only that exact fixed-size pair and preserve unrelated aspect, Remove only that exact fixed-size pair and preserve unrelated aspect,
increment, and geometry hints. Initial setup is retained only when the increment, and geometry hints. Callers can wait for the window manager to
window manager confirms resize plus both maximize actions. Event-driven confirm resize plus both maximize actions, or retain the safe client hints
maintenance can skip the confirmation because support was already proven. while polling the manager-owned action property independently.
""" """
current_size = self.window_size(window) current_size = self.window_size(window)

View File

@@ -31,7 +31,7 @@ WINDOW_DISCOVERY_POLL_SECONDS = 0.05
RESIZE_STEPS_PER_GAME_CANVAS = 20 RESIZE_STEPS_PER_GAME_CANVAS = 20
MIN_RESIZE_INCREMENT = 8 MIN_RESIZE_INCREMENT = 8
MAX_RESIZE_INCREMENT = 96 MAX_RESIZE_INCREMENT = 96
CHILD_STATUS_POLL_SECONDS = 0.05 CHILD_STATUS_POLL_SECONDS = 0.2
HOST_FOCUS_HEARTBEAT_SECONDS = 0.2 HOST_FOCUS_HEARTBEAT_SECONDS = 0.2
HOST_REICONIFY_INTERVAL_SECONDS = 0.2 HOST_REICONIFY_INTERVAL_SECONDS = 0.2
GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0 GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0
@@ -429,6 +429,20 @@ def _bundled_gamescope_is_usable() -> bool:
return os.access(path, os.X_OK) 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: def _is_kde_session(environment: Mapping[str, str]) -> bool:
"""Recognize Plasma sessions without relying on an Ubuntu flavor name.""" """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 return True
if environment.get("KDE_SESSION_VERSION", "").strip(): if environment.get("KDE_SESSION_VERSION", "").strip():
return True return True
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"): tokens = _desktop_session_tokens(environment)
tokens = re.split( return "kde" in tokens or any(token.startswith("plasma") for token in tokens)
r"[^a-z0-9]+",
environment.get(key, "").strip().casefold(),
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)
) )
if "kde" in tokens or any(token.startswith("plasma") for token in tokens):
return True
return False
def _select_wayland_gamescope( 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: 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): if _wayland_socket_available(environment):
# Gamescope's native Wayland backend still has KWin-specific presentation, # KWin still has presentation/fullscreen failures here. Cinnamon's native
# fullscreen, and fractional-scaling failures. Plasma normally exposes an # Wayland decoration path can omit Gamescope's maximize control. Both
# XWayland display, so keep the resizable host window on the mature SDL/X11 # desktops normally expose XWayland, where Gamescope creates an explicitly
# path while leaving non-Plasma Wayland sessions on direct presentation. # resizable SDL window and the host window manager supplies its controls.
if _is_kde_session(environment) and environment.get("DISPLAY", "").strip(): desktop_needs_xwayland = (
_is_kde_session(environment) or _is_cinnamon_session(environment)
)
if desktop_needs_xwayland and environment.get("DISPLAY", "").strip():
return "sdl" return "sdl"
return "wayland" return "wayland"
return "sdl" return "sdl"
@@ -601,11 +621,17 @@ def _tune_process_window(
"""Stabilize resizing once the SDL host window becomes available.""" """Stabilize resizing once the SDL host window becomes available."""
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
increments = resize_increments(width, height)
reported_window: int | None = None 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: while process.poll() is None and time.monotonic() < deadline:
window = host_window
if window is None:
window = mapper.find_window_by_pid(process.pid) window = mapper.find_window_by_pid(process.pid)
host_window = window
if window is not None: if window is not None:
window = int(window)
if diagnostics is not None and window != reported_window: if diagnostics is not None and window != reported_window:
reported_window = window reported_window = window
diagnostics.record( diagnostics.record(
@@ -619,20 +645,28 @@ def _tune_process_window(
) )
if host_focus_publisher is not None: if host_focus_publisher is not None:
host_focus_publisher.update(mapper, window) host_focus_publisher.update(mapper, window)
increments = resize_increments(width, height)
increments_applied = mapper.set_resize_increments( increments_applied = mapper.set_resize_increments(
window, window,
(width, height), (width, height),
increments, increments,
) )
# Apply every client-owned hint first, then wait for the window # Gamescope creates this SDL window as resizable. Keep the matching
# manager to publish resize and maximize actions from those hints. # client hints even while Muffin is still recalculating its frame;
scaling_enabled = mapper.enable_window_scaling(window) # 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: if host_focus_publisher is not None:
host_focus_publisher.update(mapper, window) host_focus_publisher.update(mapper, window)
compositing_preferred = mapper.prefer_composited_presentation(window) compositing_preferred = mapper.prefer_composited_presentation(window)
configuration = ( configuration = (
increments_applied, increments_applied,
scaling_hints_applied,
scaling_enabled, scaling_enabled,
compositing_preferred, compositing_preferred,
) )
@@ -642,6 +676,7 @@ def _tune_process_window(
"host_scaling_configured", "host_scaling_configured",
window_id=window, window_id=window,
resize_increments_applied=increments_applied, resize_increments_applied=increments_applied,
scaling_hints_applied=scaling_hints_applied,
scaling_enabled=scaling_enabled, scaling_enabled=scaling_enabled,
compositing_preferred=compositing_preferred, compositing_preferred=compositing_preferred,
width_increment=increments[0], width_increment=increments[0],
@@ -668,12 +703,19 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
environment = os.environ.copy() environment = os.environ.copy()
backend = _select_host_backend(environment) backend = _select_host_backend(environment)
kde_session = _is_kde_session(environment) kde_session = _is_kde_session(environment)
cinnamon_session = _is_cinnamon_session(environment)
kde_wayland_xwayland_fallback = ( kde_wayland_xwayland_fallback = (
backend == "sdl" backend == "sdl"
and kde_session and kde_session
and bool(environment.get("WAYLAND_DISPLAY", "").strip()) and bool(environment.get("WAYLAND_DISPLAY", "").strip())
and bool(environment.get("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)) selected_command = list(map(str, command))
force_composition = backend == "sdl" force_composition = backend == "sdl"
using_bundled_gamescope = False 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")), wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
host_x11_monitor_available=mapper is not None, host_x11_monitor_available=mapper is not None,
kde_session=kde_session, kde_session=kde_session,
cinnamon_session=cinnamon_session,
kde_wayland_compatibility_mode=( kde_wayland_compatibility_mode=(
kde_wayland_xwayland_fallback kde_wayland_xwayland_fallback
or (backend == "wayland" and kde_session and force_composition) or (backend == "wayland" and kde_session and force_composition)
), ),
kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback, kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback,
cinnamon_wayland_xwayland_fallback=cinnamon_wayland_xwayland_fallback,
backend_reason=( backend_reason=(
"kde_wayland_xwayland_compatibility" "kde_wayland_xwayland_compatibility"
if kde_wayland_xwayland_fallback if kde_wayland_xwayland_fallback
else "cinnamon_wayland_xwayland_compatibility"
if cinnamon_wayland_xwayland_fallback
else "wayland_socket" else "wayland_socket"
if backend == "wayland" if backend == "wayland"
else "x11_or_unavailable_wayland" else "x11_or_unavailable_wayland"
@@ -812,6 +858,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
file=sys.stderr, file=sys.stderr,
flush=True, 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: if backend == "wayland" and not force_composition:
message = ( message = (
"Gamescope resize optimization active: native Wayland direct " "Gamescope resize optimization active: native Wayland direct "