Fixed a gamebox issue where resolution would act up on launch.

This commit is contained in:
2026-09-01 22:05:33 +02:00
parent 25f490c8b8
commit 1a17c1a197
2 changed files with 398 additions and 34 deletions

View File

@@ -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 from __future__ import annotations

View File

@@ -19,8 +19,20 @@ from .desktop_identity import (
WINDOW_TITLE = APPLICATION_NAME WINDOW_TITLE = APPLICATION_NAME
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5 WINDOW_READY_DELAY_SECONDS = 0.5
WINDOW_RESTORE_DELAY_SECONDS = 0.15 WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
WINDOW_REMAP_DELAY_SECONDS = 0.05 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) _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: class _X11:
def __init__(self) -> None: def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6" library_path = ctypes.util.find_library("X11") or "libX11.so.6"
@@ -96,6 +137,7 @@ class _X11:
if not self.display: if not self.display:
self._restore_error_handler() self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display") raise GameboxRuntimeError("could not open the host X11 display")
self._atoms: dict[bytes, int] = {}
def close(self) -> None: def close(self) -> None:
try: try:
@@ -165,8 +207,34 @@ class _X11:
queue.extend(self._child_windows(candidate)) queue.extend(self._child_windows(candidate))
return window 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: 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: if wm_state == 0:
return False return False
actual_type = ctypes.c_ulong() actual_type = ctypes.c_ulong()
@@ -194,42 +262,198 @@ class _X11:
if value: if value:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p)) 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: if borderless:
self._remove_decorations(window) self._remove_decorations(window)
self.library.XMapRaised(self.display, window) self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display) self.library.XFlush(self.display)
return target_size
def refresh_render_surface(self, window: int) -> bool: def refresh_render_surface(
"""Recreate the minimize/restore event sequence after borderless styling. 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 Some Wine/DXVK combinations retain the host desktop-sized render surface
after the top-level game window becomes borderless. A normal user-driven after the top-level game window becomes borderless. A one-pixel resize followed
minimize and restore makes Wine reconcile the surface with the real window. by an exact restore makes Wine reconcile the surface with the real window.
Request that same standard X11 lifecycle once without changing geometry.
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 target_size is None:
if self.library.XIconifyWindow(self.display, window, screen) == 0: target_size = self.window_size(window)
if target_size is None:
return False 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) self.library.XSync(self.display, 0)
try: try:
time.sleep(WINDOW_RESTORE_DELAY_SECONDS) time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
finally: finally:
# Mutter keeps an iconified client's own X window mapped beneath an # Always restore the exact starting dimensions if the short wait is
# unmapped frame, so XMapRaised alone cannot deiconify it. An explicit # interrupted. The game remains mapped for the entire operation.
# client unmap/remap generates a fresh MapRequest for the window manager. self.library.XResizeWindow(
# Always finish the remap if either short wait is interrupted, so the self.display,
# game cannot be stranded as a hidden window. window,
self.library.XUnmapWindow(self.display, window) original_width,
self.library.XSync(self.display, 0) original_height,
try: )
time.sleep(WINDOW_REMAP_DELAY_SECONDS)
finally:
self.library.XMapRaised(self.display, window)
self.library.XSync(self.display, 0) self.library.XSync(self.display, 0)
return True 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( def set_window_identity(
self, self,
window: int, window: int,
@@ -259,7 +483,7 @@ class _X11:
def suppress_wm_ping(self, window: int) -> bool: def suppress_wm_ping(self, window: int) -> bool:
"""Stop the WM hang prompt for one window, preserving its other protocols.""" """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: if ping_atom == 0:
return False return False
protocols = self._wm_protocols(window) protocols = self._wm_protocols(window)
@@ -310,7 +534,7 @@ class _X11:
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p)) self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
def _remove_decorations(self, window: int) -> None: 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: if atom == 0:
return return
hints = _MotifWmHints( hints = _MotifWmHints(
@@ -353,8 +577,6 @@ class _X11:
self.library.XSetErrorHandler.restype = ctypes.c_void_p self.library.XSetErrorHandler.restype = ctypes.c_void_p
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p] self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.library.XOpenDisplay.restype = ctypes.c_void_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.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.XQueryTree.argtypes = [ self.library.XQueryTree.argtypes = [
@@ -426,14 +648,40 @@ class _X11:
self.library.XSetWMProtocols.restype = ctypes.c_int self.library.XSetWMProtocols.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int self.library.XMapRaised.restype = ctypes.c_int
self.library.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] self.library.XGetGeometry.argtypes = [
self.library.XUnmapWindow.restype = ctypes.c_int ctypes.c_void_p,
self.library.XIconifyWindow.argtypes = [ 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_void_p,
ctypes.c_ulong, ctypes.c_ulong,
ctypes.c_int, 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.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.library.XSync.restype = ctypes.c_int self.library.XSync.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p] self.library.XFlush.argtypes = [ctypes.c_void_p]
@@ -475,11 +723,19 @@ def _prepare_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
mapper.map_window(window, borderless=borderless) target_size = mapper.map_window(window, borderless=borderless)
if not borderless: if not borderless:
return return
if target_size is not None:
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
try: try:
if target_size is None:
refreshed = mapper.refresh_render_surface(window) refreshed = mapper.refresh_render_surface(window)
else:
refreshed = mapper.refresh_render_surface(
window,
target_size=target_size,
)
except Exception as exc: except Exception as exc:
print( print(
f"Warning: could not synchronize the borderless game surface ({exc}); " 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( def run_game_window(
command: Sequence[str], command: Sequence[str],
window_title: str = WINDOW_TITLE, window_title: str = WINDOW_TITLE,
@@ -583,6 +940,13 @@ def run_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
_focus_game_window(mapper, game_window)
_guard_game_window_startup(
mapper,
process,
game_window,
borderless=borderless,
)
return int(process.wait()) return int(process.wait())
time.sleep(0.1) time.sleep(0.1)
if process.poll() is None: if process.poll() is None: