diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index c0b3a16..ad239b3 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -19,8 +19,12 @@ from .desktop_identity import ( WINDOW_TITLE = APPLICATION_NAME WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_READY_DELAY_SECONDS = 0.5 -WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1 +WINDOW_SURFACE_SETTLE_SECONDS = 0.1 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_CONFIRM_POLL_SECONDS = 0.025 WINDOW_FOCUS_DELAY_SECONDS = 0.2 WINDOW_STARTUP_GUARD_SECONDS = 12.0 WINDOW_STARTUP_POLL_SECONDS = 0.1 @@ -32,6 +36,8 @@ _X_CLIENT_MESSAGE = 33 _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 _MOTIF_HINTS_DECORATIONS = 1 << 1 _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) @@ -280,6 +286,88 @@ class _X11: return False return hints[2] == 0 + def window_is_fullscreen_or_maximized(self, window: int) -> bool: + """Return whether EWMH currently marks the client as screen-filling.""" + + states = self._long_property(window, b"_NET_WM_STATE", 64) + if not states: + return False + return any( + atom != 0 and atom in states + for atom in self._screen_filling_state_atoms() + ) + + def force_windowed_state(self, window: int) -> bool: + """Remove fullscreen/maximized EWMH state and wait for WM acknowledgement. + + The state property must not be edited directly on a mapped client. EWMH + requires a client message to the root window so KWin/XWayland can restore + the window's previous geometry before the render surface is refreshed. + + Return ``True`` only when a screen-filling state was present and its removal + was confirmed. Return ``False`` when the window was already normal. + """ + + states = set(self._long_property(window, b"_NET_WM_STATE", 64) or ()) + fullscreen, maximized_vertical, maximized_horizontal = ( + self._screen_filling_state_atoms() + ) + maximized = tuple( + atom + for atom in (maximized_vertical, maximized_horizontal) + if atom != 0 and atom in states + ) + fullscreen_is_set = fullscreen != 0 and fullscreen in states + if not maximized and not fullscreen_is_set: + return False + + state_atom = self._intern_atom(b"_NET_WM_STATE", only_if_exists=True) + if state_atom == 0: + raise GameboxRuntimeError("the window manager has no EWMH state atom") + requests: list[tuple[int, int]] = [] + if maximized: + requests.append( + (maximized[0], maximized[1] if len(maximized) > 1 else 0) + ) + if fullscreen_is_set: + requests.append((fullscreen, 0)) + for first_state, second_state in requests: + if not self._send_root_client_message( + window, + state_atom, + ( + _NET_WM_STATE_REMOVE, + first_state, + second_state, + _NET_WM_STATE_SOURCE_APPLICATION, + 0, + ), + ): + raise GameboxRuntimeError( + "the window manager rejected the windowed-state request" + ) + if not self._wait_for_windowed_state(window): + raise GameboxRuntimeError( + "the window manager did not confirm normal windowed state" + ) + return True + + def _screen_filling_state_atoms(self) -> tuple[int, int, int]: + return ( + self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True), + self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_VERT", only_if_exists=True), + self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_HORZ", only_if_exists=True), + ) + + def _wait_for_windowed_state(self, window: int) -> bool: + deadline = time.monotonic() + WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS + while self.window_is_fullscreen_or_maximized(window): + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining)) + return True + def _long_property( self, window: int, @@ -349,13 +437,49 @@ class _X11: return None return width.value, height.value + def _wait_for_window_size( + self, + window: int, + expected_size: tuple[int, int], + *, + timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS, + ) -> bool: + """Wait briefly for an asynchronous WM ConfigureRequest decision.""" + + deadline = time.monotonic() + timeout_seconds + while self.window_size(window) != expected_size: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining)) + return True + + def _resize_window_and_wait( + self, + window: int, + target_size: tuple[int, int], + *, + timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS, + ) -> bool: + width, height = target_size + self.library.XResizeWindow(self.display, window, width, height) + self.library.XSync(self.display, 0) + return self._wait_for_window_size( + window, + target_size, + timeout_seconds=timeout_seconds, + ) + def map_window( self, window: int, *, borderless: bool, ) -> tuple[int, int] | None: - target_size = self.window_size(window) if borderless else None + # Capture the real client size for both decorated and borderless windows. + # Wine/DXVK can otherwise retain a desktop-sized render surface inside a + # smaller decorated client on some window managers. + target_size = self.window_size(window) if borderless: self._remove_decorations(window) self.library.XMapRaised(self.display, window) @@ -368,11 +492,11 @@ class _X11: *, target_size: tuple[int, int] | None = None, ) -> bool: - """Nudge the mapped client size once after borderless styling. + """Nudge the mapped client size once after Wine creates the window. Some Wine/DXVK combinations retain the host desktop-sized render surface - 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. + inside a smaller top-level game window. A one-pixel resize followed by an + exact restore makes Wine reconcile the surface with the real client size. 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. @@ -392,26 +516,22 @@ class _X11: else: return False - self.library.XResizeWindow( - self.display, - window, - nudged_width, - nudged_height, - ) - self.library.XSync(self.display, 0) try: - time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS) - finally: - # 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, + nudge_confirmed = self._resize_window_and_wait( window, - original_width, - original_height, + (nudged_width, nudged_height), + timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS, ) - self.library.XSync(self.display, 0) - return True + if nudge_confirmed: + time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS) + finally: + # Always request and verify the exact starting dimensions, including when + # KWin declined the nudge or the short wait was interrupted. + restore_confirmed = self._resize_window_and_wait( + window, + (original_width, original_height), + ) + return nudge_confirmed and restore_confirmed def focus_window(self, window: int) -> None: """Raise the mapped game client and give it keyboard focus.""" @@ -434,16 +554,32 @@ class _X11: ) if active_window == 0: return False + return self._send_root_client_message( + window, + active_window, + (1, _X_CURRENT_TIME, 0, 0, 0), + ) + + def _send_root_client_message( + self, + window: int, + message_type: int, + data: Sequence[int], + ) -> bool: + """Send one 32-bit EWMH client message to the root window.""" + + if len(data) != 5: + raise ValueError("an X11 client message requires exactly five values") 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.message_type = message_type event.client_message.format = 32 - event.client_message.data.longs[0] = 1 - event.client_message.data.longs[1] = _X_CURRENT_TIME + for index, value in enumerate(data): + event.client_message.data.longs[index] = value status = self.library.XSendEvent( self.display, self.library.XDefaultRootWindow(self.display), @@ -724,10 +860,38 @@ def _prepare_game_window( flush=True, ) target_size = mapper.map_window(window, borderless=borderless) - if not borderless: - return - if target_size is not None: - time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS) + forced_windowed = False + try: + forced_windowed = mapper.force_windowed_state(window) + except Exception as exc: + print( + f"Warning: could not force the game into normal windowed mode ({exc}); " + "continuing with surface synchronization", + file=sys.stderr, + flush=True, + ) + else: + if forced_windowed: + print( + "Game window forced out of fullscreen/maximized mode.", + file=sys.stderr, + flush=True, + ) + if target_size is not None or forced_windowed: + time.sleep(WINDOW_SURFACE_SETTLE_SECONDS) + if forced_windowed: + # Let KWin finish restoring the pre-fullscreen client geometry before it + # becomes the authoritative surface size. + try: + target_size = mapper.window_size(window) + except Exception as exc: + print( + f"Warning: could not read the restored game-window size ({exc}); " + "retrying during surface synchronization", + file=sys.stderr, + flush=True, + ) + target_size = None try: if target_size is None: refreshed = mapper.refresh_render_surface(window) @@ -738,7 +902,7 @@ def _prepare_game_window( ) except Exception as exc: print( - f"Warning: could not synchronize the borderless game surface ({exc}); " + f"Warning: could not synchronize the game surface ({exc}); " "continuing normally", file=sys.stderr, flush=True, @@ -746,13 +910,13 @@ def _prepare_game_window( else: if refreshed: print( - "Borderless game surface synchronized with its window.", + "Game render surface synchronized with its window.", file=sys.stderr, flush=True, ) else: print( - "Warning: the window manager declined the borderless surface " + "Warning: the window manager declined the game-surface " "refresh; continuing normally", file=sys.stderr, flush=True, @@ -814,6 +978,7 @@ def _guard_game_window_startup( while process.poll() is None and time.monotonic() < deadline: try: state = mapper.window_state(window) + screen_filling = mapper.window_is_fullscreen_or_maximized(window) borderless_now = ( mapper.window_is_borderless(window) if borderless else None ) @@ -826,13 +991,23 @@ def _guard_game_window_startup( ) return 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 - if not became_iconic and not login_style_reset: + if ( + not became_iconic + and not entered_screen_filling_mode + 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" + if became_iconic: + reason = "was minimized" + elif entered_screen_filling_mode: + reason = "entered fullscreen/maximized mode" + else: + reason = "changed its window layout" print( f"Game startup window {reason}; restoring and refocusing it.", file=sys.stderr, diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index b2eb551..09baecf 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -45,6 +45,10 @@ RETIRED_POST_AUTH_GAME_DLL_SHA256 = ( "93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd" ) RUNTIME_VIEW_CONFIGURATION_VERSION = 2 +WINDOWED_GAME_CONFIG_VALUES = ( + (b"windowed", b"Windowed", b"1"), + (b"startmaximized", b"StartMaximized", b"False"), +) class OverlayError(RuntimeError): @@ -175,7 +179,8 @@ def prepare_runtime_view( _validate_game_tree(source) _validate_runtime_target(paths) game_save = _prepare_persistent_save(paths, source) - reused = _reuse_runtime_view(paths, source, game_save) + game_config = _prepare_persistent_game_config(paths, source) + reused = _reuse_runtime_view(paths, source, game_save, game_config) if reused is not None: return reused input_identities = _runtime_input_identities(paths, source) @@ -197,6 +202,7 @@ def prepare_runtime_view( inspection, action, game_save, + game_config, binary_action, game_exe_source, game_dll_source, @@ -211,7 +217,7 @@ def prepare_runtime_view( links_created = 0 try: build.mkdir(parents=True, exist_ok=False) - top_level_exceptions = {"game", "save"} + top_level_exceptions = {"game", "game.ini", "save"} for name, binary_source in ( ("Game.exe", game_exe_source), ("game.dll", game_dll_source), @@ -225,6 +231,8 @@ def prepare_runtime_view( ) (build / "save").symlink_to(game_save, target_is_directory=True) links_created += 1 + (build / "game.ini").symlink_to(game_config) + links_created += 1 if game_exe_source != source / "Game.exe": (build / "Game.exe").symlink_to(game_exe_source) links_created += 1 @@ -510,6 +518,108 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path: return target +def _prepare_persistent_game_config(paths: AppPaths, source: Path) -> Path: + """Keep game settings writable while forcing a true windowed startup.""" + + target = paths.game_config + if target.is_symlink(): + raise OverlayError(f"persistent game config must not be a symbolic link: {target}") + if target.exists() and not target.is_file(): + raise OverlayError(f"persistent game config is not a file: {target}") + source_config = target if target.is_file() else source / "game.ini" + try: + content = source_config.read_bytes() + mode = source_config.stat().st_mode & 0o777 + except OSError as exc: + raise OverlayError(f"could not read game config {source_config}: {exc}") from exc + windowed_content = _force_windowed_game_config(content) + if source_config == target and windowed_content == content: + return target + + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("xb") as handle: + handle.write(windowed_content) + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(mode) + temporary.replace(target) + except BaseException: + temporary.unlink(missing_ok=True) + raise + return target + + +def _force_windowed_game_config(content: bytes) -> bytes: + """Set only the two Screen keys that can trigger a desktop-sized backbuffer.""" + + if b"\x00" in content: + raise OverlayError("game.ini uses an unsupported encoding") + newline = b"\r\n" if b"\r\n" in content else b"\n" + desired = { + key: (display_name, value) + for key, display_name, value in WINDOWED_GAME_CONFIG_VALUES + } + result: list[bytes] = [] + in_screen = False + found_screen = False + found_settings: set[bytes] = set() + + def append_missing_settings() -> None: + if result and not result[-1].endswith((b"\r", b"\n")): + result[-1] += newline + for key, display_name, value in WINDOWED_GAME_CONFIG_VALUES: + if key in found_settings: + continue + result.append(display_name + b"=" + value + newline) + found_settings.add(key) + + for line in content.splitlines(keepends=True): + if line.endswith(b"\r\n"): + body, line_ending = line[:-2], b"\r\n" + elif line.endswith((b"\r", b"\n")): + body, line_ending = line[:-1], line[-1:] + else: + body, line_ending = line, b"" + stripped = body.strip() + if stripped.startswith(b"[") and b"]" in stripped: + if in_screen: + append_missing_settings() + section_end = stripped.find(b"]") + in_screen = stripped[1:section_end].strip().lower() == b"screen" + found_screen = found_screen or in_screen + if in_screen and b"=" in body and not stripped.startswith((b";", b"#")): + equals = body.find(b"=") + key = body[:equals].strip().lower() + setting = desired.get(key) + if setting is not None: + leading_value_space = body[equals + 1 :] + leading_value_space = leading_value_space[ + : len(leading_value_space) - len(leading_value_space.lstrip(b" \t")) + ] + result.append( + body[: equals + 1] + + leading_value_space + + setting[1] + + line_ending + ) + found_settings.add(key) + continue + result.append(line) + + if in_screen: + append_missing_settings() + elif not found_screen: + if result and not result[-1].endswith((b"\r", b"\n")): + result[-1] += newline + if result and result[-1].strip(): + result.append(newline) + result.append(b"[Screen]" + newline) + append_missing_settings() + return b"".join(result) + + def _reject_save_symlinks(root: Path) -> None: for directory, directory_names, file_names in os.walk(root, followlinks=False): parent = Path(directory) @@ -617,6 +727,7 @@ def _reuse_legacy_runtime_view( inspection: SceneInspection, scene_action: str, game_save: Path, + game_config: Path, binary_action: str, game_exe_source: Path, game_dll_source: Path, @@ -640,10 +751,16 @@ def _reuse_legacy_runtime_view( return None if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"): return None + _migrate_runtime_game_config_link( + paths.runtime_view / "game.ini", + source / "game.ini", + game_config, + ) if not _runtime_layout_ready( paths, source, game_save, + game_config, game_exe_source, game_dll_source, legacy.links_created, @@ -691,16 +808,23 @@ def _reuse_runtime_view( paths: AppPaths, source: Path, game_save: Path, + game_config: Path, ) -> OverlayResult | None: result = _matching_cached_runtime_result(paths, source) if result is None: return None game_exe_source = source / "Game.exe" game_dll_source = source / "game.dll" + _migrate_runtime_game_config_link( + paths.runtime_view / "game.ini", + source / "game.ini", + game_config, + ) if not _runtime_layout_ready( paths, source, game_save, + game_config, game_exe_source, game_dll_source, result.links_created, @@ -724,6 +848,7 @@ def _runtime_layout_ready( paths: AppPaths, source: Path, game_save: Path, + game_config: Path, game_exe_source: Path, game_dll_source: Path, expected_links: int, @@ -731,8 +856,8 @@ def _runtime_layout_ready( runtime = paths.runtime_view if runtime.is_symlink() or not runtime.is_dir(): return False - top_level_exceptions = {"game", "save"} - top_level_special = {"game", "save"} + top_level_exceptions = {"game", "game.ini", "save"} + top_level_special = {"game", "game.ini", "save"} for name, binary_source in ( ("Game.exe", game_exe_source), ("game.dll", game_dll_source), @@ -750,6 +875,8 @@ def _runtime_layout_ready( return False if not _symlink_points_to(runtime / "save", game_save): return False + if not _symlink_points_to(runtime / "game.ini", game_config): + return False if game_exe_source != source / "Game.exe" and not _symlink_points_to( runtime / "Game.exe", game_exe_source, @@ -789,7 +916,7 @@ def _runtime_layout_ready( runtime_scene = maps_target / "WhisperingVale.rovug" if runtime_scene.is_symlink() or not runtime_scene.is_file(): return False - actual_links = top_level_links + game_links + maps_links + 1 + actual_links = top_level_links + game_links + maps_links + 2 if game_exe_source != source / "Game.exe": actual_links += 1 if game_dll_source != source / "game.dll": @@ -830,6 +957,27 @@ def _symlink_points_to(link: Path, expected_target: Path) -> bool: return False +def _migrate_runtime_game_config_link( + link: Path, + source_config: Path, + persistent_config: Path, +) -> bool: + """Atomically redirect the previous source link without rebuilding the view.""" + + if _symlink_points_to(link, persistent_config): + return True + if not _symlink_points_to(link, source_config): + return False + temporary = link.with_name(f".{link.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.symlink_to(persistent_config) + temporary.replace(link) + except OSError: + temporary.unlink(missing_ok=True) + return False + return _symlink_points_to(link, persistent_config) + + def _read_overlay_result(path: Path) -> OverlayResult | None: try: value = json.loads(path.read_text(encoding="utf-8")) diff --git a/ptlinux/paths.py b/ptlinux/paths.py index 7842c1e..c1604d8 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -208,6 +208,12 @@ class AppPaths: def game_save(self) -> Path: return self.game_user_data / "save" + @property + def game_config(self) -> Path: + """Writable game settings kept outside the launcher-managed source tree.""" + + return self.game_user_data / "game.ini" + @property def compatibility_binaries(self) -> Path: return self.data / "compatibility-binaries"