diff --git a/install.sh b/install.sh index 0faf9ad..a88ff46 100644 --- a/install.sh +++ b/install.sh @@ -162,6 +162,7 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ + || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ @@ -254,6 +255,7 @@ install -m 0644 \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ + "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ "${STAGING_RELEASE}/ptlinux/native/" install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py" install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py" diff --git a/ptlinux/cli.py b/ptlinux/cli.py index a45c17f..b910c78 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -50,7 +50,12 @@ from .launcher import ( launch_official_updater, prepare_launcher_binary, ) -from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock +from .locking import ( + LauncherPlayLease, + launcher_operation_lock, + launcher_play_lock, + managed_game_process_is_active, +) from .login_diagnostics import inspect_login_environment from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON from .overlay import cache_compatibility_binaries, prepare_runtime_view @@ -855,6 +860,7 @@ def _play( realm=realm, reuse_prepared=not play_lease.preparation_allowed, before_game=play_lease.allow_shared_clients, + game_lease_fds=play_lease.inherited_fds, ) @@ -869,6 +875,7 @@ def _play_locked( realm: str | None = None, reuse_prepared: bool = False, before_game: Callable[[], None] | None = None, + game_lease_fds: Sequence[int] = (), ) -> int: if game_dir is None and game_import_requires_verification(paths): raise LauncherError( @@ -980,6 +987,7 @@ def _play_locked( use_gamebox=not no_box, concurrent_prefix_launch=reuse_prepared, check=False, + pass_fds=game_lease_fds, ) except KeyboardInterrupt: current_game_log = _current_game_log( @@ -1505,7 +1513,10 @@ def _play_guard( ) -> AbstractContextManager[LauncherPlayLease]: if dry_run: return nullcontext(LauncherPlayLease.dry_run()) - return launcher_play_lock(paths.launcher_operation_lock) + return launcher_play_lock( + paths.launcher_operation_lock, + active_game_probe=lambda: _managed_game_process_is_active(paths), + ) def _operation_guard( @@ -1515,7 +1526,18 @@ def _operation_guard( ) -> AbstractContextManager[None]: if dry_run: return nullcontext() - return launcher_operation_lock(paths.launcher_operation_lock, operation) + return launcher_operation_lock( + paths.launcher_operation_lock, + operation, + active_game_probe=lambda: _managed_game_process_is_active(paths), + ) + + +def _managed_game_process_is_active(paths: AppPaths) -> bool: + return managed_game_process_is_active( + paths.runtime_view, + (paths.proton_prefix, paths.umu_prefix), + ) def _emit(value: object, as_json: bool) -> None: diff --git a/ptlinux/command.py b/ptlinux/command.py index e7bd633..5b42351 100644 --- a/ptlinux/command.py +++ b/ptlinux/command.py @@ -99,10 +99,14 @@ class CommandRunner: check: bool = True, stream_output: bool = False, output_line_filter: OutputLineFilter | None = None, + pass_fds: Sequence[int] = (), ) -> CommandOutcome: arguments = [str(value) for value in argv] if not arguments: raise ValueError("command argv cannot be empty") + inherited_fds = tuple(dict.fromkeys(pass_fds)) + if any(not isinstance(value, int) or value < 0 for value in inherited_fds): + raise ValueError("inherited file descriptors must be non-negative integers") overrides = dict(env_overrides or {}) started = time.monotonic() if self.dry_run: @@ -128,6 +132,7 @@ class CommandRunner: timeout_seconds=timeout_seconds, started=started, output_line_filter=output_line_filter, + pass_fds=inherited_fds, ) else: try: @@ -142,6 +147,7 @@ class CommandRunner: stderr=subprocess.PIPE, timeout=timeout_seconds, check=False, + pass_fds=inherited_fds, ) outcome = CommandOutcome( argv=arguments, @@ -176,6 +182,7 @@ class CommandRunner: timeout_seconds: float, started: float, output_line_filter: OutputLineFilter | None, + pass_fds: tuple[int, ...], ) -> CommandOutcome: process = subprocess.Popen( arguments, @@ -188,6 +195,7 @@ class CommandRunner: stderr=subprocess.PIPE, bufsize=1, start_new_session=True, + pass_fds=pass_fds, ) stdout = BoundedTextCapture() stderr = BoundedTextCapture() diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 7c73ded..f78502b 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -43,6 +43,8 @@ _X_PROPERTY_NOTIFY = 28 _X_PROPERTY_CHANGE_MASK = 1 << 22 _X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19 _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20 +_X_WM_STATE_WITHDRAWN = 0 +_X_WM_STATE_NORMAL = 1 _X_WM_STATE_ICONIC = 3 _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 @@ -448,6 +450,18 @@ class _X11: return None return values[0] + def restore_normal_window_state(self, window: int) -> bool: + """Undo a nested iconify request without raising the host window.""" + + self._replace_long_property( + window, + b"WM_STATE", + b"WM_STATE", + (_X_WM_STATE_NORMAL, 0), + ) + self.library.XSync(self.display, 0) + return self.window_state(window) == _X_WM_STATE_NORMAL + def window_is_borderless(self, window: int) -> bool | None: """Return whether the client currently requests no Motif decorations.""" @@ -1463,6 +1477,54 @@ def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool: return False +def _resolve_gamescope_game_window( + mapper: _X11, + window: int, + *, + window_title: str, + excluded_window_ids: Collection[int], +) -> int | None: + """Return a valid nested client across Wine's login-window transition.""" + + try: + state = mapper.window_state(window) + except Exception: + state = None + if state == _X_WM_STATE_ICONIC: + try: + if not mapper.restore_normal_window_state(window): + return None + except Exception: + return None + print( + "Game login window requested minimization; kept it visible inside " + "Gamescope.", + file=sys.stderr, + flush=True, + ) + return window + if state is not None and state != _X_WM_STATE_WITHDRAWN: + return window + + try: + found = mapper.find_first_window( + window_title, + excluded_window_ids=set(excluded_window_ids) | {window}, + ) + except Exception: + return None + if found is None: + return None + replacement, title = found + _apply_game_window_identity(mapper, replacement) + print( + f"Game login window changed; following it inside Gamescope: {title}", + file=sys.stderr, + flush=True, + ) + return replacement + + def _enable_game_window_scaling(mapper: _X11, window: int) -> bool: """Enable confirmed resize/maximize actions without risking game startup.""" @@ -1587,9 +1649,14 @@ def _maintain_gamescope_game_window_focus( mapper: _X11, process: subprocess.Popen[bytes], window: int, + *, + window_title: str = WINDOW_TITLE, + existing_window_ids: Collection[int] = (), ) -> int: - """Restore nested keyboard focus after host resize/maximize operations.""" + """Keep a valid, focused nested client through login and host resizing.""" + active_window: int = window + retired_window_ids = set(existing_window_ids) next_focus_repair_at = ( time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS ) @@ -1598,7 +1665,22 @@ def _maintain_gamescope_game_window_focus( now = time.monotonic() if now < next_focus_repair_at: continue - _repair_gamescope_game_window_focus(mapper, window) + resolved_window = _resolve_gamescope_game_window( + mapper, + active_window, + window_title=window_title, + excluded_window_ids=retired_window_ids, + ) + if resolved_window is None: + next_focus_repair_at = ( + time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS + ) + continue + resolved_window_id = resolved_window + if resolved_window_id != active_window: + retired_window_ids.add(active_window) + active_window = resolved_window_id + _repair_gamescope_game_window_focus(mapper, active_window) next_focus_repair_at = ( time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS ) @@ -1785,6 +1867,8 @@ def run_game_window( mapper, process, game_window, + window_title=window_title, + existing_window_ids=existing_window_ids, ) _prepare_game_window( mapper, diff --git a/ptlinux/gamescope_runtime.py b/ptlinux/gamescope_runtime.py index ee8d751..4bbff41 100644 --- a/ptlinux/gamescope_runtime.py +++ b/ptlinux/gamescope_runtime.py @@ -27,10 +27,11 @@ WAYLAND_BACKING_FIX_VERSION = (3, 16, 28) BUNDLED_GAMESCOPE_VERSION = (3, 16, 20) BUNDLED_GAMESCOPE_FILENAME = "gamescope-3.16.20-ubuntu26.04-amd64" BUNDLED_GAMESCOPE_SHA256 = ( - "d4a082d8db0c467f65b429b6fb339b56b9a0d3e17e9c1957ede9ba1cb9a3ec90" + "3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639" ) BUNDLED_GAMESCOPE_OS_ID = "ubuntu" BUNDLED_GAMESCOPE_OS_VERSION = "26.04" +KEEP_NESTED_WINDOWS_VISIBLE_ENV = "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" HASH_READ_SIZE = 256 * 1024 _GAMESCOPE_VERSION_PATTERN = re.compile( r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)", @@ -357,6 +358,11 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: # SDL otherwise requests compositor bypass, which can switch near # screen-filling sizes and visibly flicker while Gamescope is scaling. environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0" + if using_bundled_gamescope: + # The game asks its X11 WM to iconify the first client while entering + # the login screen. Our scoped Gamescope patch keeps that nested surface + # visible without activating or raising the host window. + environment[KEEP_NESTED_WINDOWS_VISIBLE_ENV] = "1" try: process = subprocess.Popen(launch_command, env=environment) except OSError as exc: @@ -374,7 +380,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: if using_bundled_gamescope: message += ( " Launcher-local lazy-output, live-resize, letterbox backing, " - "and decoration-input fixes are active." + "decoration-input, and login-window visibility fixes are active." ) print(message, file=sys.stderr, flush=True) elif backend == "wayland": diff --git a/ptlinux/locking.py b/ptlinux/locking.py index 517c848..5916a60 100644 --- a/ptlinux/locking.py +++ b/ptlinux/locking.py @@ -7,7 +7,12 @@ from datetime import datetime import fcntl import os from pathlib import Path -from typing import Iterator, TextIO +from typing import Callable, Iterator, Sequence, TextIO + + +ActiveGameProbe = Callable[[], bool] +MAX_PROCESS_ENVIRONMENT_BYTES = 1024 * 1024 +MAX_PROCESS_COMMAND_BYTES = 128 * 1024 class OperationBusyError(RuntimeError): @@ -51,9 +56,22 @@ class LauncherPlayLease: def record_owned(self) -> bool: return self._record_owned + @property + def inherited_fds(self) -> tuple[int, ...]: + """Keep the shared lease alive in the launched game supervisor.""" + + if self._handle is None: + return () + return (self._handle.fileno(),) + @contextmanager -def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: +def launcher_operation_lock( + lock_path: Path, + operation: str, + *, + active_game_probe: ActiveGameProbe | None = None, +) -> Iterator[None]: lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+", encoding="utf-8") as handle: try: @@ -64,6 +82,11 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: raise OperationBusyError( f"launcher files are busy ({owner}); wait for it to finish and try again" ) from exc + if active_game_probe is not None and active_game_probe(): + raise OperationBusyError( + "launcher files are busy (active managed game client detected); " + "close every game client and try again" + ) _write_owner(handle, operation) try: yield @@ -78,6 +101,8 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: def launcher_play_lock( lock_path: Path, operation: str = "play", + *, + active_game_probe: ActiveGameProbe | None = None, ) -> Iterator[LauncherPlayLease]: """Serialize preparation, then allow multiple active game clients. @@ -105,12 +130,24 @@ def launcher_play_lock( record_owned=False, ) else: - _write_owner(handle, f"{operation} preparation") - lease = LauncherPlayLease( - handle, - preparation_allowed=True, - record_owned=True, - ) + if active_game_probe is not None and active_game_probe(): + # A game from an older launcher may have outlived its Python + # supervisor and therefore its inherited lock. Rejoin that live + # prefix without mutating its runtime view or waiting for it to exit. + _write_owner(handle, operation) + fcntl.flock(handle.fileno(), fcntl.LOCK_SH) + lease = LauncherPlayLease( + handle, + preparation_allowed=False, + record_owned=True, + ) + else: + _write_owner(handle, f"{operation} preparation") + lease = LauncherPlayLease( + handle, + preparation_allowed=True, + record_owned=True, + ) try: yield lease finally: @@ -123,6 +160,79 @@ def launcher_play_lock( fcntl.flock(handle.fileno(), fcntl.LOCK_UN) +def managed_game_process_is_active( + runtime_view: Path, + wine_prefixes: Sequence[Path], + *, + process_root: Path = Path("/proc"), +) -> bool: + """Detect a managed Wine game that outlived its launcher supervisor.""" + + prefixes = {_normalized_path(prefix) for prefix in wine_prefixes} + prefixes.discard("") + if not prefixes: + return False + runtime_marker = _normalized_path(runtime_view).casefold().replace("\\", "/") + if not runtime_marker: + return False + runtime_marker = runtime_marker.rstrip("/") + "/" + try: + processes = tuple(process_root.iterdir()) + except OSError: + return False + for process in processes: + if not process.name.isdecimal(): + continue + try: + name = (process / "comm").read_text( + encoding="utf-8", + errors="replace", + ).strip().casefold() + except OSError: + continue + if not name.endswith(".exe"): + continue + environment = _read_bounded(process / "environ", MAX_PROCESS_ENVIRONMENT_BYTES) + if environment is None: + continue + wine_prefix = next( + ( + entry.removeprefix(b"WINEPREFIX=") + for entry in environment.split(b"\0") + if entry.startswith(b"WINEPREFIX=") + ), + None, + ) + if ( + wine_prefix is None + or _normalized_path(os.fsdecode(wine_prefix)) not in prefixes + ): + continue + command = _read_bounded(process / "cmdline", MAX_PROCESS_COMMAND_BYTES) + if command is None: + continue + normalized_command = os.fsdecode(command).casefold().replace("\\", "/") + if runtime_marker in normalized_command: + return True + return False + + +def _read_bounded(path: Path, maximum_bytes: int) -> bytes | None: + try: + with path.open("rb") as handle: + value = handle.read(maximum_bytes + 1) + except OSError: + return None + return value if len(value) <= maximum_bytes else None + + +def _normalized_path(path: Path | str) -> str: + value = str(path) + if not value: + return "" + return str(Path(value).expanduser().resolve(strict=False)) + + def _read_owner(handle: TextIO) -> str: handle.seek(0) return handle.read().strip() or "unknown operation" diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 91b0390..b434d03 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -406,6 +406,7 @@ def run_game( check: bool = True, extra_environment: Mapping[str, str] | None = None, output_line_filter: OutputLineFilter | None = None, + pass_fds: Sequence[int] = (), ) -> CommandOutcome: _require_container_host(selection, runner) if not paths.runtime_view.is_dir() and not runner.dry_run: @@ -502,6 +503,7 @@ def run_game( check=check, stream_output=True, output_line_filter=output_line_filter, + pass_fds=pass_fds, ) diff --git a/updates/stable.json b/updates/stable.json index 93b70dd..41b2dc5 100644 --- a/updates/stable.json +++ b/updates/stable.json @@ -1,11 +1,11 @@ { - "archive_sha256": "890443aab4cd0e33bb2e2a44ea86b3fd929488eba045b803c2c0662ce68c08ff", - "archive_size": 1410277, + "archive_sha256": "3e00fc6cd72b5ae79adee56642a10b90d410080e67da099292aefcd5cf406681", + "archive_size": 1413002, "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.5/pristontale-eu-linux-update-v0.9.5.tar.gz", "channel": "stable", "minimum_updater_version": "0.5.0", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.5", - "published_at": "2026-09-06T20:06:31Z", + "published_at": "2026-09-06T20:09:16Z", "schema": 1, "version": "0.9.5" } diff --git a/updates/stable.json.sig b/updates/stable.json.sig index 50d1962..94e8d21 100644 Binary files a/updates/stable.json.sig and b/updates/stable.json.sig differ