Added maximize window and free scaling support.
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user