Fixed a gamebox issue where resolution would act up on launch.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Native X11 command construction for the clean borderless game window."""
|
||||
"""Native X11 command construction for the managed game window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -19,8 +19,20 @@ from .desktop_identity import (
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||
WINDOW_RESTORE_DELAY_SECONDS = 0.15
|
||||
WINDOW_REMAP_DELAY_SECONDS = 0.05
|
||||
WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
|
||||
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
|
||||
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
|
||||
_X_REVERT_TO_PARENT = 2
|
||||
_X_CURRENT_TIME = 0
|
||||
_X_CLIENT_MESSAGE = 33
|
||||
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
||||
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
||||
_X_WM_STATE_ICONIC = 3
|
||||
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
|
||||
@@ -80,6 +92,35 @@ class _XClassHint(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class _XClientMessageData(ctypes.Union):
|
||||
_fields_ = [
|
||||
("bytes", ctypes.c_char * 20),
|
||||
("shorts", ctypes.c_short * 10),
|
||||
("longs", ctypes.c_long * 5),
|
||||
]
|
||||
|
||||
|
||||
class _XClientMessageEvent(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),
|
||||
("message_type", ctypes.c_ulong),
|
||||
("format", ctypes.c_int),
|
||||
("data", _XClientMessageData),
|
||||
]
|
||||
|
||||
|
||||
class _XEvent(ctypes.Union):
|
||||
_fields_ = [
|
||||
("type", ctypes.c_int),
|
||||
("client_message", _XClientMessageEvent),
|
||||
("padding", ctypes.c_long * 24),
|
||||
]
|
||||
|
||||
|
||||
class _X11:
|
||||
def __init__(self) -> None:
|
||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
@@ -96,6 +137,7 @@ class _X11:
|
||||
if not self.display:
|
||||
self._restore_error_handler()
|
||||
raise GameboxRuntimeError("could not open the host X11 display")
|
||||
self._atoms: dict[bytes, int] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
@@ -165,8 +207,34 @@ class _X11:
|
||||
queue.extend(self._child_windows(candidate))
|
||||
return window
|
||||
|
||||
def _intern_atom(self, name: bytes, *, only_if_exists: bool) -> int:
|
||||
"""Return one atom ID without repeating X-server lookups."""
|
||||
|
||||
try:
|
||||
atoms = self._atoms
|
||||
except AttributeError:
|
||||
# Keep helpers usable in isolated tests that construct the mapper without
|
||||
# opening a real X11 display.
|
||||
atoms = {}
|
||||
self._atoms = atoms
|
||||
cached_atom = atoms.get(name)
|
||||
if cached_atom is not None:
|
||||
return cached_atom
|
||||
atom = int(
|
||||
self.library.XInternAtom(
|
||||
self.display,
|
||||
name,
|
||||
int(only_if_exists),
|
||||
)
|
||||
)
|
||||
# A missing only-if-exists lookup must remain retryable: a later call may
|
||||
# create the atom or another client may publish it.
|
||||
if atom != 0:
|
||||
atoms[name] = atom
|
||||
return atom
|
||||
|
||||
def _has_wm_state(self, window: int) -> bool:
|
||||
wm_state = self.library.XInternAtom(self.display, b"WM_STATE", 1)
|
||||
wm_state = self._intern_atom(b"WM_STATE", only_if_exists=True)
|
||||
if wm_state == 0:
|
||||
return False
|
||||
actual_type = ctypes.c_ulong()
|
||||
@@ -194,42 +262,198 @@ class _X11:
|
||||
if value:
|
||||
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
|
||||
|
||||
def map_window(self, window: int, *, borderless: bool) -> None:
|
||||
def window_state(self, window: int) -> int | None:
|
||||
"""Return the ICCCM WM_STATE value for a managed client."""
|
||||
|
||||
values = self._long_property(window, b"WM_STATE", 2)
|
||||
if not values:
|
||||
return None
|
||||
return values[0]
|
||||
|
||||
def window_is_borderless(self, window: int) -> bool | None:
|
||||
"""Return whether the client currently requests no Motif decorations."""
|
||||
|
||||
hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5)
|
||||
if hints is None or len(hints) < 3:
|
||||
return None
|
||||
if hints[0] & _MOTIF_HINTS_DECORATIONS == 0:
|
||||
return False
|
||||
return hints[2] == 0
|
||||
|
||||
def _long_property(
|
||||
self,
|
||||
window: int,
|
||||
name: bytes,
|
||||
maximum_items: int,
|
||||
) -> tuple[int, ...] | None:
|
||||
atom = self._intern_atom(name, only_if_exists=True)
|
||||
if atom == 0:
|
||||
return None
|
||||
actual_type = ctypes.c_ulong()
|
||||
actual_format = ctypes.c_int()
|
||||
item_count = ctypes.c_ulong()
|
||||
bytes_after = ctypes.c_ulong()
|
||||
value = ctypes.POINTER(ctypes.c_ubyte)()
|
||||
status = self.library.XGetWindowProperty(
|
||||
self.display,
|
||||
window,
|
||||
atom,
|
||||
0,
|
||||
maximum_items,
|
||||
0,
|
||||
0,
|
||||
ctypes.byref(actual_type),
|
||||
ctypes.byref(actual_format),
|
||||
ctypes.byref(item_count),
|
||||
ctypes.byref(bytes_after),
|
||||
ctypes.byref(value),
|
||||
)
|
||||
try:
|
||||
if (
|
||||
status != 0
|
||||
or actual_type.value == 0
|
||||
or actual_format.value != 32
|
||||
or not value
|
||||
):
|
||||
return None
|
||||
longs = ctypes.cast(value, ctypes.POINTER(ctypes.c_ulong))
|
||||
return tuple(
|
||||
int(longs[index] & 0xFFFFFFFF)
|
||||
for index in range(min(item_count.value, maximum_items))
|
||||
)
|
||||
finally:
|
||||
if value:
|
||||
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
|
||||
|
||||
def window_size(self, window: int) -> tuple[int, int] | None:
|
||||
"""Return the current X11 client dimensions."""
|
||||
|
||||
root = ctypes.c_ulong()
|
||||
x = ctypes.c_int()
|
||||
y = ctypes.c_int()
|
||||
width = ctypes.c_uint()
|
||||
height = ctypes.c_uint()
|
||||
border_width = ctypes.c_uint()
|
||||
depth = ctypes.c_uint()
|
||||
if self.library.XGetGeometry(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.byref(root),
|
||||
ctypes.byref(x),
|
||||
ctypes.byref(y),
|
||||
ctypes.byref(width),
|
||||
ctypes.byref(height),
|
||||
ctypes.byref(border_width),
|
||||
ctypes.byref(depth),
|
||||
) == 0:
|
||||
return None
|
||||
return width.value, height.value
|
||||
|
||||
def map_window(
|
||||
self,
|
||||
window: int,
|
||||
*,
|
||||
borderless: bool,
|
||||
) -> tuple[int, int] | None:
|
||||
target_size = self.window_size(window) if borderless else None
|
||||
if borderless:
|
||||
self._remove_decorations(window)
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XFlush(self.display)
|
||||
return target_size
|
||||
|
||||
def refresh_render_surface(self, window: int) -> bool:
|
||||
"""Recreate the minimize/restore event sequence after borderless styling.
|
||||
def refresh_render_surface(
|
||||
self,
|
||||
window: int,
|
||||
*,
|
||||
target_size: tuple[int, int] | None = None,
|
||||
) -> bool:
|
||||
"""Nudge the mapped client size once after borderless styling.
|
||||
|
||||
Some Wine/DXVK combinations retain the host desktop-sized render surface
|
||||
after the top-level game window becomes borderless. A normal user-driven
|
||||
minimize and restore makes Wine reconcile the surface with the real window.
|
||||
Request that same standard X11 lifecycle once without changing geometry.
|
||||
after the top-level game window becomes borderless. A one-pixel resize followed
|
||||
by an exact restore makes Wine reconcile the surface with the real window.
|
||||
|
||||
Never iconify or unmap a Wine client for this refresh. Wine can process the
|
||||
resulting state change after startup and minimize the game at its login screen.
|
||||
"""
|
||||
|
||||
screen = self.library.XDefaultScreen(self.display)
|
||||
if self.library.XIconifyWindow(self.display, window, screen) == 0:
|
||||
if target_size is None:
|
||||
target_size = self.window_size(window)
|
||||
if target_size is None:
|
||||
return False
|
||||
original_width, original_height = target_size
|
||||
if original_width > 1:
|
||||
nudged_width = original_width - 1
|
||||
nudged_height = original_height
|
||||
elif original_height > 1:
|
||||
nudged_width = original_width
|
||||
nudged_height = original_height - 1
|
||||
else:
|
||||
return False
|
||||
|
||||
self.library.XResizeWindow(
|
||||
self.display,
|
||||
window,
|
||||
nudged_width,
|
||||
nudged_height,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
try:
|
||||
time.sleep(WINDOW_RESTORE_DELAY_SECONDS)
|
||||
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
|
||||
finally:
|
||||
# Mutter keeps an iconified client's own X window mapped beneath an
|
||||
# unmapped frame, so XMapRaised alone cannot deiconify it. An explicit
|
||||
# client unmap/remap generates a fresh MapRequest for the window manager.
|
||||
# Always finish the remap if either short wait is interrupted, so the
|
||||
# game cannot be stranded as a hidden window.
|
||||
self.library.XUnmapWindow(self.display, window)
|
||||
# Always restore the exact starting dimensions if the short wait is
|
||||
# interrupted. The game remains mapped for the entire operation.
|
||||
self.library.XResizeWindow(
|
||||
self.display,
|
||||
window,
|
||||
original_width,
|
||||
original_height,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
try:
|
||||
time.sleep(WINDOW_REMAP_DELAY_SECONDS)
|
||||
finally:
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XSync(self.display, 0)
|
||||
return True
|
||||
|
||||
def focus_window(self, window: int) -> None:
|
||||
"""Raise the mapped game client and give it keyboard focus."""
|
||||
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XSetInputFocus(
|
||||
self.display,
|
||||
window,
|
||||
_X_REVERT_TO_PARENT,
|
||||
_X_CURRENT_TIME,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
|
||||
def activate_window(self, window: int) -> bool:
|
||||
"""Ask the window manager to restore and activate one client."""
|
||||
|
||||
active_window = self._intern_atom(
|
||||
b"_NET_ACTIVE_WINDOW",
|
||||
only_if_exists=False,
|
||||
)
|
||||
if active_window == 0:
|
||||
return False
|
||||
event = _XEvent()
|
||||
event.client_message.type = _X_CLIENT_MESSAGE
|
||||
event.client_message.serial = 0
|
||||
event.client_message.send_event = 1
|
||||
event.client_message.display = self.display
|
||||
event.client_message.window = window
|
||||
event.client_message.message_type = active_window
|
||||
event.client_message.format = 32
|
||||
event.client_message.data.longs[0] = 1
|
||||
event.client_message.data.longs[1] = _X_CURRENT_TIME
|
||||
status = self.library.XSendEvent(
|
||||
self.display,
|
||||
self.library.XDefaultRootWindow(self.display),
|
||||
0,
|
||||
_X_SUBSTRUCTURE_NOTIFY_MASK | _X_SUBSTRUCTURE_REDIRECT_MASK,
|
||||
ctypes.byref(event),
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
return status != 0
|
||||
|
||||
def set_window_identity(
|
||||
self,
|
||||
window: int,
|
||||
@@ -259,7 +483,7 @@ class _X11:
|
||||
|
||||
def suppress_wm_ping(self, window: int) -> bool:
|
||||
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
||||
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
||||
ping_atom = self._intern_atom(b"_NET_WM_PING", only_if_exists=False)
|
||||
if ping_atom == 0:
|
||||
return False
|
||||
protocols = self._wm_protocols(window)
|
||||
@@ -310,7 +534,7 @@ class _X11:
|
||||
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
|
||||
|
||||
def _remove_decorations(self, window: int) -> None:
|
||||
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
|
||||
atom = self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False)
|
||||
if atom == 0:
|
||||
return
|
||||
hints = _MotifWmHints(
|
||||
@@ -353,8 +577,6 @@ class _X11:
|
||||
self.library.XSetErrorHandler.restype = ctypes.c_void_p
|
||||
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
self.library.XOpenDisplay.restype = ctypes.c_void_p
|
||||
self.library.XDefaultScreen.argtypes = [ctypes.c_void_p]
|
||||
self.library.XDefaultScreen.restype = ctypes.c_int
|
||||
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
|
||||
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
|
||||
self.library.XQueryTree.argtypes = [
|
||||
@@ -426,14 +648,40 @@ 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.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
self.library.XUnmapWindow.restype = ctypes.c_int
|
||||
self.library.XIconifyWindow.argtypes = [
|
||||
self.library.XGetGeometry.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
]
|
||||
self.library.XGetGeometry.restype = ctypes.c_int
|
||||
self.library.XResizeWindow.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
self.library.XResizeWindow.restype = ctypes.c_int
|
||||
self.library.XSetInputFocus.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
self.library.XIconifyWindow.restype = ctypes.c_int
|
||||
self.library.XSetInputFocus.restype = ctypes.c_int
|
||||
self.library.XSendEvent.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.c_long,
|
||||
ctypes.POINTER(_XEvent),
|
||||
]
|
||||
self.library.XSendEvent.restype = ctypes.c_int
|
||||
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
self.library.XSync.restype = ctypes.c_int
|
||||
self.library.XFlush.argtypes = [ctypes.c_void_p]
|
||||
@@ -475,11 +723,19 @@ def _prepare_game_window(
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
mapper.map_window(window, borderless=borderless)
|
||||
target_size = mapper.map_window(window, borderless=borderless)
|
||||
if not borderless:
|
||||
return
|
||||
if target_size is not None:
|
||||
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
|
||||
try:
|
||||
refreshed = mapper.refresh_render_surface(window)
|
||||
if target_size is None:
|
||||
refreshed = mapper.refresh_render_surface(window)
|
||||
else:
|
||||
refreshed = mapper.refresh_render_surface(
|
||||
window,
|
||||
target_size=target_size,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not synchronize the borderless game surface ({exc}); "
|
||||
@@ -515,6 +771,107 @@ def _apply_game_window_identity(mapper: _X11, window: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _focus_game_window(mapper: _X11, window: int) -> None:
|
||||
"""Let the dashboard process its ready event before focusing the game."""
|
||||
|
||||
time.sleep(WINDOW_FOCUS_DELAY_SECONDS)
|
||||
try:
|
||||
mapper.focus_window(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not focus the game window ({exc}); continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _guard_game_window_startup(
|
||||
mapper: _X11,
|
||||
process: subprocess.Popen[bytes],
|
||||
window: int,
|
||||
*,
|
||||
borderless: bool,
|
||||
) -> None:
|
||||
"""Recover Wine's login-screen window transition without hiding the client."""
|
||||
|
||||
deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS
|
||||
try:
|
||||
initial_borderless = (
|
||||
mapper.window_is_borderless(window) if borderless else None
|
||||
)
|
||||
# A readable decorated value can already be Wine's fast login reset. The
|
||||
# initial preparation immediately before this guard requested borderless.
|
||||
borderless_was_applied = borderless and initial_borderless is not None
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not monitor the game startup window ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
recovery_count = 0
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
try:
|
||||
state = mapper.window_state(window)
|
||||
borderless_now = (
|
||||
mapper.window_is_borderless(window) if borderless else None
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not inspect the game startup window ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
became_iconic = state == _X_WM_STATE_ICONIC
|
||||
login_style_reset = borderless_was_applied and borderless_now is False
|
||||
if not became_iconic and not login_style_reset:
|
||||
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
recovery_count += 1
|
||||
reason = "was minimized" if became_iconic else "changed its window layout"
|
||||
print(
|
||||
f"Game startup window {reason}; restoring and refocusing it.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
mapper.activate_window(window)
|
||||
time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS)
|
||||
_prepare_game_window(
|
||||
mapper,
|
||||
window,
|
||||
borderless=borderless,
|
||||
suppress_wm_ping=False,
|
||||
)
|
||||
mapper.activate_window(window)
|
||||
mapper.focus_window(window)
|
||||
if borderless:
|
||||
borderless_was_applied = (
|
||||
mapper.window_is_borderless(window) is True
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not recover the game startup window ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT:
|
||||
print(
|
||||
"Warning: the game repeatedly changed its startup window; "
|
||||
"stopped automatic recovery.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
||||
|
||||
|
||||
def run_game_window(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
@@ -583,6 +940,13 @@ def run_game_window(
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
_focus_game_window(mapper, game_window)
|
||||
_guard_game_window_startup(
|
||||
mapper,
|
||||
process,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
)
|
||||
return int(process.wait())
|
||||
time.sleep(0.1)
|
||||
if process.poll() is None:
|
||||
|
||||
Reference in New Issue
Block a user