diff --git a/ptlinux/gamebox.py b/ptlinux/gamebox.py index af8157e..92a87e2 100644 --- a/ptlinux/gamebox.py +++ b/ptlinux/gamebox.py @@ -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 diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 8fb3b13..c0b3a16 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -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: