"""Map and style the real Wine game window on the host X11 display.""" from __future__ import annotations import argparse import ctypes import ctypes.util import subprocess import sys import time from collections.abc import Collection, Iterable, Sequence WINDOW_TITLE = "PristonTale EU" WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_READY_DELAY_SECONDS = 0.5 _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) def _ignore_x11_error(_display: object, _event: object) -> int: """Keep a stale X11 window race from terminating the mapper process.""" return 0 def _matches_game_window_title(title: str, expected_title: str) -> bool: """Match the game title without accepting dialogs that merely mention it.""" candidate = title.strip().casefold() expected = expected_title.strip().casefold() if not candidate or not expected: return False return candidate == expected or candidate.startswith(f"{expected} ") def _select_game_window( windows: Iterable[tuple[int, str]], expected_title: str, excluded_window_ids: Collection[int], ) -> tuple[int, str] | None: for window, title in windows: if window in excluded_window_ids: continue if _matches_game_window_title(title, expected_title): return window, title return None class GameboxRuntimeError(RuntimeError): """Raised when the game command or X11 mapper cannot be started.""" class _MotifWmHints(ctypes.Structure): _fields_ = [ ("flags", ctypes.c_ulong), ("functions", ctypes.c_ulong), ("decorations", ctypes.c_ulong), ("input_mode", ctypes.c_long), ("status", ctypes.c_ulong), ] class _X11: def __init__(self) -> None: library_path = ctypes.util.find_library("X11") or "libX11.so.6" try: self.library = ctypes.CDLL(library_path) except OSError as exc: raise GameboxRuntimeError("the X11 client library is unavailable") from exc self._configure_signatures() self._error_handler = _X_ERROR_HANDLER(_ignore_x11_error) # type: ignore[arg-type] self._previous_error_handler = self.library.XSetErrorHandler( ctypes.cast(self._error_handler, ctypes.c_void_p), ) self.display = self.library.XOpenDisplay(None) if not self.display: self._restore_error_handler() raise GameboxRuntimeError("could not open the host X11 display") def close(self) -> None: try: if self.display: self.library.XCloseDisplay(self.display) self.display = None finally: self._restore_error_handler() def root_window_ids(self) -> set[int]: return set(self._root_windows()) def find_first_window( self, title_fragment: str, *, excluded_window_ids: Collection[int], ) -> tuple[int, str] | None: windows: list[tuple[int, str]] = [] for window in self._root_windows(): title = self._window_title(window) if title is not None: windows.append((window, title)) return _select_game_window(windows, title_fragment, excluded_window_ids) def _root_windows(self) -> list[int]: root = self.library.XDefaultRootWindow(self.display) root_return = ctypes.c_ulong() parent_return = ctypes.c_ulong() children = ctypes.POINTER(ctypes.c_ulong)() child_count = ctypes.c_uint() status = self.library.XQueryTree( self.display, root, ctypes.byref(root_return), ctypes.byref(parent_return), ctypes.byref(children), ctypes.byref(child_count), ) if status == 0: return [] try: return [int(children[index]) for index in range(child_count.value)] finally: if children: self.library.XFree(ctypes.cast(children, ctypes.c_void_p)) def map_window(self, window: int, *, borderless: bool) -> None: if borderless: self._remove_decorations(window) self.library.XMapRaised(self.display, window) self.library.XFlush(self.display) def _remove_decorations(self, window: int) -> None: atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0) if atom == 0: return hints = _MotifWmHints( flags=1 << 1, functions=0, decorations=0, input_mode=0, status=0, ) self.library.XChangeProperty( self.display, window, atom, atom, 32, 0, ctypes.cast(ctypes.byref(hints), ctypes.POINTER(ctypes.c_ubyte)), 5, ) def _window_title(self, window: int) -> str | None: value = ctypes.c_char_p() if self.library.XFetchName(self.display, window, ctypes.byref(value)) == 0: return None try: if value.value is None: return None return value.value.decode("utf-8", errors="replace") finally: self.library.XFree(ctypes.cast(value, ctypes.c_void_p)) def _restore_error_handler(self) -> None: if self._error_handler is None: return self.library.XSetErrorHandler(self._previous_error_handler) self._error_handler = None def _configure_signatures(self) -> None: self.library.XSetErrorHandler.argtypes = [ctypes.c_void_p] 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.XDefaultRootWindow.argtypes = [ctypes.c_void_p] self.library.XDefaultRootWindow.restype = ctypes.c_ulong self.library.XQueryTree.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)), ctypes.POINTER(ctypes.c_uint), ] self.library.XQueryTree.restype = ctypes.c_int self.library.XFetchName.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_char_p), ] self.library.XFetchName.restype = ctypes.c_int self.library.XInternAtom.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ] self.library.XInternAtom.restype = ctypes.c_ulong self.library.XChangeProperty.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_int, ] self.library.XChangeProperty.restype = ctypes.c_int self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] self.library.XMapRaised.restype = ctypes.c_int self.library.XFlush.argtypes = [ctypes.c_void_p] self.library.XFlush.restype = ctypes.c_int self.library.XFree.argtypes = [ctypes.c_void_p] self.library.XFree.restype = ctypes.c_int self.library.XCloseDisplay.argtypes = [ctypes.c_void_p] self.library.XCloseDisplay.restype = ctypes.c_int def run_game_window( command: Sequence[str], window_title: str = WINDOW_TITLE, *, borderless: bool = False, ) -> int: if not command: raise GameboxRuntimeError("game command cannot be empty") mapper: _X11 | None = None existing_window_ids: set[int] = set() try: created_mapper = _X11() existing_window_ids = created_mapper.root_window_ids() mapper = created_mapper except GameboxRuntimeError as error: print( f"Warning: {error}; continuing without X11 window styling", file=sys.stderr, flush=True, ) process = subprocess.Popen([str(value) for value in command]) if mapper is None: print( f"Game window ready: unmanaged {window_title}", file=sys.stderr, flush=True, ) return int(process.wait()) game_window: int | None = None game_title: str | None = None game_window_created_at: float | None = None deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS try: while process.poll() is None and time.monotonic() < deadline: now = time.monotonic() if game_window is None: found = mapper.find_first_window( window_title, excluded_window_ids=existing_window_ids, ) if found is not None: window, title = found game_window = window game_title = title game_window_created_at = now print( f"Game window created; preparing it: {title}", file=sys.stderr, flush=True, ) if ( game_window is not None and game_window_created_at is not None and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS ): mapper.map_window(game_window, borderless=borderless) print( f"Game window ready: {game_title or window_title}", file=sys.stderr, flush=True, ) return int(process.wait()) time.sleep(0.1) if process.poll() is None: print( f"Warning: no new game window matching {window_title!r} appeared", file=sys.stderr, flush=True, ) if game_window is not None: mapper.map_window(game_window, borderless=borderless) return int(process.wait()) finally: mapper.close() def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--window-title", default=WINDOW_TITLE) parser.add_argument("--borderless", action="store_true") parser.add_argument("command", nargs=argparse.REMAINDER) arguments = parser.parse_args(argv) command = arguments.command if command and command[0] == "--": command = command[1:] try: return run_game_window( command, arguments.window_title, borderless=arguments.borderless, ) except (GameboxRuntimeError, OSError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())