From 598d761af4c870d5c9b474f2ed2fdc5d34b08b05 Mon Sep 17 00:00:00 2001 From: m4ko Date: Thu, 3 Sep 2026 23:12:48 +0200 Subject: [PATCH] Added maximize window and free scaling support. --- ptlinux/gamebox_runtime.py | 415 +++++++++++++++++++++++++- ptlinux/settings.py | 7 +- ptlinux/ui/dialogs/settings_dialog.py | 4 +- 3 files changed, 414 insertions(+), 12 deletions(-) diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index ad239b3..82b145d 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import ctypes import ctypes.util +import select import subprocess import sys import time @@ -24,21 +25,36 @@ WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15 WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS = 0.15 WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS = 0.75 WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS = 0.5 +WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS = 0.75 +WINDOW_SCALING_EVENT_TIMEOUT_SECONDS = 0.5 +WINDOW_SCALING_REPAIR_INTERVAL_SECONDS = 0.05 WINDOW_CONFIRM_POLL_SECONDS = 0.025 WINDOW_FOCUS_DELAY_SECONDS = 0.2 WINDOW_STARTUP_GUARD_SECONDS = 12.0 WINDOW_STARTUP_POLL_SECONDS = 0.1 WINDOW_ACTIVATION_DELAY_SECONDS = 0.2 WINDOW_STARTUP_RECOVERY_LIMIT = 3 +WINDOW_SCALING_REPAIR_LIMIT = 3 +WINDOW_SCALING_EVENT_BATCH_LIMIT = 64 _X_REVERT_TO_PARENT = 2 _X_CURRENT_TIME = 0 _X_CLIENT_MESSAGE = 33 +_X_PROPERTY_NOTIFY = 28 +_X_PROPERTY_CHANGE_MASK = 1 << 22 _X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19 _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20 _X_WM_STATE_ICONIC = 3 _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 +_P_MIN_SIZE = 1 << 4 +_P_MAX_SIZE = 1 << 5 +_MOTIF_HINTS_FUNCTIONS = 1 << 0 _MOTIF_HINTS_DECORATIONS = 1 << 1 +_MOTIF_ALL = 1 << 0 +_MOTIF_FUNC_RESIZE = 1 << 1 +_MOTIF_FUNC_MAXIMIZE = 1 << 4 +_MOTIF_DECOR_RESIZE = 1 << 2 +_MOTIF_DECOR_MAXIMIZE = 1 << 6 _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) @@ -77,6 +93,60 @@ def _protocols_without( return tuple(protocol for protocol in protocols if protocol != blocked_protocol) +def _motif_value_with_enabled_action(value: int, action: int) -> int: + """Enable one Motif action while preserving its inclusive/exclusive mode.""" + + if value & _MOTIF_ALL: + return value & ~action + return value | action + + +def _motif_hints_with_scaling(hints: Sequence[int]) -> tuple[int, ...]: + """Add native resize/maximize actions while retaining every other hint.""" + + updated = list(hints) + if len(updated) < 3: + return tuple(updated) + flags = updated[0] + if flags & _MOTIF_HINTS_FUNCTIONS: + for action in (_MOTIF_FUNC_RESIZE, _MOTIF_FUNC_MAXIMIZE): + updated[1] = _motif_value_with_enabled_action(updated[1], action) + if flags & _MOTIF_HINTS_DECORATIONS: + for action in (_MOTIF_DECOR_RESIZE, _MOTIF_DECOR_MAXIMIZE): + updated[2] = _motif_value_with_enabled_action(updated[2], action) + return tuple(updated) + + +def _normal_hints_without_fixed_size( + hints: Sequence[int], + current_size: tuple[int, int], +) -> tuple[int, ...]: + """Relax Wine's exact fixed-size pair while retaining unrelated constraints.""" + + updated = list(hints) + if len(updated) < 9: + return tuple(updated) + current_width, current_height = current_size + has_minimum = updated[0] & _P_MIN_SIZE != 0 + has_maximum = updated[0] & _P_MAX_SIZE != 0 + has_fixed_pair = ( + has_minimum + and has_maximum + and updated[5] == updated[7] + and updated[6] == updated[8] + ) + maximum_is_current = ( + has_maximum + and updated[7] == current_width + and updated[8] == current_height + ) + if has_fixed_pair: + updated[0] &= ~(_P_MIN_SIZE | _P_MAX_SIZE) + elif maximum_is_current: + updated[0] &= ~_P_MAX_SIZE + return tuple(updated) + + class GameboxRuntimeError(RuntimeError): """Raised when the game command or X11 mapper cannot be started.""" @@ -119,10 +189,24 @@ class _XClientMessageEvent(ctypes.Structure): ] +class _XPropertyEvent(ctypes.Structure): + _fields_ = [ + ("type", ctypes.c_int), + ("serial", ctypes.c_ulong), + ("send_event", ctypes.c_int), + ("display", ctypes.c_void_p), + ("window", ctypes.c_ulong), + ("atom", ctypes.c_ulong), + ("time", ctypes.c_ulong), + ("state", ctypes.c_int), + ] + + class _XEvent(ctypes.Union): _fields_ = [ ("type", ctypes.c_int), ("client_message", _XClientMessageEvent), + ("property", _XPropertyEvent), ("padding", ctypes.c_long * 24), ] @@ -352,6 +436,183 @@ class _X11: ) return True + def enable_window_scaling(self, window: int, *, confirm: bool = True) -> bool: + """Expose native resize/maximize actions without changing the game binary. + + 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. + """ + + current_size = self.window_size(window) + normal_hints = self._long_property(window, b"WM_NORMAL_HINTS", 18) + motif_hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5) + updated_normal_hints = ( + _normal_hints_without_fixed_size(normal_hints, current_size) + if normal_hints is not None and current_size is not None + else None + ) + updated_motif_hints = ( + _motif_hints_with_scaling(motif_hints) + if motif_hints is not None + else None + ) + normal_changed = ( + normal_hints is not None + and updated_normal_hints is not None + and updated_normal_hints != normal_hints + ) + motif_changed = motif_hints is not None and updated_motif_hints != motif_hints + if not normal_changed and not motif_changed: + return not confirm or self.window_allows_scaling(window) + + confirmed = False + try: + if normal_changed and updated_normal_hints is not None: + self._replace_long_property( + window, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + updated_normal_hints, + ) + if motif_changed and updated_motif_hints is not None: + self._replace_long_property( + window, + b"_MOTIF_WM_HINTS", + b"_MOTIF_WM_HINTS", + updated_motif_hints, + ) + if not confirm: + self.library.XFlush(self.display) + confirmed = True + return True + self.library.XSync(self.display, 0) + confirmed = self._wait_for_scaling_actions(window) + return confirmed + finally: + if not confirmed: + restore_errors: list[Exception] = [] + if motif_changed and motif_hints is not None: + try: + self._replace_long_property( + window, + b"_MOTIF_WM_HINTS", + b"_MOTIF_WM_HINTS", + motif_hints, + ) + except Exception as exc: + restore_errors.append(exc) + if normal_changed and normal_hints is not None: + try: + self._replace_long_property( + window, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + normal_hints, + ) + except Exception as exc: + restore_errors.append(exc) + try: + self.library.XSync(self.display, 0) + except Exception as exc: + restore_errors.append(exc) + if restore_errors: + raise GameboxRuntimeError( + "could not fully restore the original game-window hints" + ) from restore_errors[0] + + def window_allows_scaling(self, window: int) -> bool: + actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64) + if not actions: + return False + resize = self._intern_atom( + b"_NET_WM_ACTION_RESIZE", + only_if_exists=True, + ) + horizontal = self._intern_atom( + b"_NET_WM_ACTION_MAXIMIZE_HORZ", + only_if_exists=True, + ) + vertical = self._intern_atom( + b"_NET_WM_ACTION_MAXIMIZE_VERT", + only_if_exists=True, + ) + return resize != 0 and horizontal != 0 and vertical != 0 and { + resize, + horizontal, + vertical, + }.issubset(actions) + + def _wait_for_scaling_actions(self, window: int) -> bool: + deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS + while not self.window_allows_scaling(window): + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining)) + return True + + def select_scaling_hint_events(self, window: int) -> None: + """Subscribe this X11 connection to client-property changes.""" + + if self.library.XSelectInput( + self.display, + window, + _X_PROPERTY_CHANGE_MASK, + ) == 0: + raise GameboxRuntimeError( + "could not subscribe to game-window property changes" + ) + self.library.XSync(self.display, 0) + + def wait_for_scaling_hint_change( + self, + window: int, + *, + timeout_seconds: float = WINDOW_SCALING_EVENT_TIMEOUT_SECONDS, + ) -> bool: + """Wait efficiently for Wine to replace either maintained hint property.""" + + if self.library.XPending(self.display) == 0: + connection = self.library.XConnectionNumber(self.display) + if connection < 0: + raise GameboxRuntimeError("the X11 display connection is unavailable") + try: + readable, _, _ = select.select( + (connection,), + (), + (), + timeout_seconds, + ) + except InterruptedError: + return False + if not readable: + return False + + watched_atoms = { + self._intern_atom(b"WM_NORMAL_HINTS", only_if_exists=False), + self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False), + } + changed = False + events_read = 0 + while ( + events_read < WINDOW_SCALING_EVENT_BATCH_LIMIT + and self.library.XPending(self.display) > 0 + ): + event = _XEvent() + self.library.XNextEvent(self.display, ctypes.byref(event)) + events_read += 1 + if ( + event.type == _X_PROPERTY_NOTIFY + and int(event.property.window) == window + and int(event.property.atom) in watched_atoms + ): + changed = True + return changed + def _screen_filling_state_atoms(self) -> tuple[int, int, int]: return ( self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True), @@ -413,6 +674,31 @@ class _X11: if value: self.library.XFree(ctypes.cast(value, ctypes.c_void_p)) + def _replace_long_property( + self, + window: int, + name: bytes, + property_type: bytes, + values: Sequence[int], + ) -> None: + atom = self._intern_atom(name, only_if_exists=False) + type_atom = self._intern_atom(property_type, only_if_exists=False) + if atom == 0 or type_atom == 0: + raise GameboxRuntimeError("could not create an X11 window-property atom") + property_values = (ctypes.c_ulong * len(values))( + *(value & 0xFFFFFFFF for value in values) + ) + self.library.XChangeProperty( + self.display, + window, + atom, + type_atom, + 32, + 0, + ctypes.cast(property_values, ctypes.POINTER(ctypes.c_ubyte)), + len(values), + ) + def window_size(self, window: int) -> tuple[int, int] | None: """Return the current X11 client dimensions.""" @@ -784,6 +1070,21 @@ class _X11: self.library.XSetWMProtocols.restype = ctypes.c_int self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] self.library.XMapRaised.restype = ctypes.c_int + self.library.XSelectInput.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_long, + ] + self.library.XSelectInput.restype = ctypes.c_int + self.library.XConnectionNumber.argtypes = [ctypes.c_void_p] + self.library.XConnectionNumber.restype = ctypes.c_int + self.library.XPending.argtypes = [ctypes.c_void_p] + self.library.XPending.restype = ctypes.c_int + self.library.XNextEvent.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(_XEvent), + ] + self.library.XNextEvent.restype = ctypes.c_int self.library.XGetGeometry.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, @@ -949,13 +1250,104 @@ def _focus_game_window(mapper: _X11, window: int) -> None: ) +def _enable_game_window_scaling(mapper: _X11, window: int) -> bool: + """Enable confirmed resize/maximize actions without risking game startup.""" + + try: + enabled = mapper.enable_window_scaling(window) + except Exception as exc: + print( + f"Warning: could not enable game-window scaling ({exc}); " + "the fixed-size window will continue normally", + file=sys.stderr, + flush=True, + ) + return False + if enabled: + print( + "Game window is resizable with persistent maximize support.", + file=sys.stderr, + flush=True, + ) + else: + print( + "Warning: the window manager did not confirm safe scaling actions; " + "the game remains fixed-size", + file=sys.stderr, + flush=True, + ) + return enabled + + +def _maintain_game_window_scaling( + mapper: _X11, + process: subprocess.Popen[bytes], + window: int, +) -> int: + """Keep host scaling hints across Wine maximize/restore transitions.""" + + try: + mapper.select_scaling_hint_events(window) + except Exception as exc: + print( + f"Warning: could not monitor game-window scaling hints ({exc}); " + "leaving the game window fixed-size for consistency", + file=sys.stderr, + flush=True, + ) + return int(process.wait()) + + if not _enable_game_window_scaling(mapper, window): + return int(process.wait()) + + repair_failures = 0 + next_repair_at = 0.0 + while process.poll() is None: + try: + hints_changed = mapper.wait_for_scaling_hint_change(window) + except Exception as exc: + print( + f"Warning: game-window scaling monitoring stopped ({exc}); " + "the game will continue normally", + file=sys.stderr, + flush=True, + ) + break + if not hints_changed: + continue + now = time.monotonic() + if now < next_repair_at: + time.sleep(next_repair_at - now) + try: + mapper.enable_window_scaling(window, confirm=False) + except Exception as exc: + repair_failures += 1 + print( + f"Warning: could not restore game-window scaling hints ({exc})", + file=sys.stderr, + flush=True, + ) + if repair_failures >= WINDOW_SCALING_REPAIR_LIMIT: + print( + "Warning: scaling maintenance was disabled after repeated failures; " + "the game will continue normally", + file=sys.stderr, + flush=True, + ) + break + else: + repair_failures = 0 + next_repair_at = time.monotonic() + WINDOW_SCALING_REPAIR_INTERVAL_SECONDS + return int(process.wait()) + + def _guard_game_window_startup( mapper: _X11, process: subprocess.Popen[bytes], window: int, *, borderless: bool, -) -> None: +) -> bool: """Recover Wine's login-screen window transition without hiding the client.""" deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS @@ -973,9 +1365,11 @@ def _guard_game_window_startup( file=sys.stderr, flush=True, ) - return + return False recovery_count = 0 - while process.poll() is None and time.monotonic() < deadline: + while process.poll() is None: + if time.monotonic() >= deadline: + return True try: state = mapper.window_state(window) screen_filling = mapper.window_is_fullscreen_or_maximized(window) @@ -989,7 +1383,7 @@ def _guard_game_window_startup( file=sys.stderr, flush=True, ) - return + return False became_iconic = state == _X_WM_STATE_ICONIC entered_screen_filling_mode = screen_filling is True login_style_reset = borderless_was_applied and borderless_now is False @@ -1035,7 +1429,7 @@ def _guard_game_window_startup( file=sys.stderr, flush=True, ) - return + return False if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT: print( "Warning: the game repeatedly changed its startup window; " @@ -1043,8 +1437,9 @@ def _guard_game_window_startup( file=sys.stderr, flush=True, ) - return + return False time.sleep(WINDOW_STARTUP_POLL_SECONDS) + return False def run_game_window( @@ -1116,12 +1511,18 @@ def run_game_window( flush=True, ) _focus_game_window(mapper, game_window) - _guard_game_window_startup( + startup_stable = _guard_game_window_startup( mapper, process, game_window, borderless=borderless, ) + if startup_stable and not borderless: + return _maintain_game_window_scaling( + mapper, + process, + game_window, + ) return int(process.wait()) time.sleep(0.1) if process.poll() is None: diff --git a/ptlinux/settings.py b/ptlinux/settings.py index 383a3bc..c670b2e 100644 --- a/ptlinux/settings.py +++ b/ptlinux/settings.py @@ -18,7 +18,7 @@ from .paths import AppPaths SETTINGS_SCHEMA = 1 _TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated" UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"}) -_RETIRED_DISPLAY_SETTINGS = frozenset( +_RETIRED_SETTINGS = frozenset( { "width", "height", @@ -28,6 +28,7 @@ _RETIRED_DISPLAY_SETTINGS = frozenset( "fullscreen", "scaler", "filter", + "maximize_button", } ) @@ -205,7 +206,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool: value.get("schema") != SETTINGS_SCHEMA or not _SETTINGS_FIELD_NAMES.issubset(value) or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True - or bool(_RETIRED_DISPLAY_SETTINGS.intersection(value)) + or bool(_RETIRED_SETTINGS.intersection(value)) ) @@ -215,7 +216,7 @@ def _write_settings( existing: dict[str, object] | None, ) -> None: payload = dict(existing or {}) - for name in _RETIRED_DISPLAY_SETTINGS: + for name in _RETIRED_SETTINGS: payload.pop(name, None) payload.update(asdict(settings)) payload["schema"] = SETTINGS_SCHEMA diff --git a/ptlinux/ui/dialogs/settings_dialog.py b/ptlinux/ui/dialogs/settings_dialog.py index 1b2aa60..e12fd8c 100644 --- a/ptlinux/ui/dialogs/settings_dialog.py +++ b/ptlinux/ui/dialogs/settings_dialog.py @@ -301,8 +301,8 @@ def open_settings_dialog( ttk.Label( display_card, text=( - "The launcher keeps the game window's title bar by default. Enable " - "Borderless only when you want to remove its host decorations." + "Decorated game windows become freely resizable with persistent maximize " + "support after startup. Borderless mode removes all host decorations." ), style="SettingsHint.TLabel", wraplength=360,