Added maximize window and free scaling support.

This commit is contained in:
2026-09-03 23:12:48 +02:00
parent 5be4ace6c2
commit 598d761af4
3 changed files with 414 additions and 12 deletions

View File

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

View File

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

View File

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