Fixed issues with gamescope not properly launching.

This commit is contained in:
2026-09-06 22:57:54 +02:00
parent f53f4d0b08
commit bea9d6fda7
9 changed files with 252 additions and 18 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,

View File

@@ -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":

View File

@@ -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.
@@ -104,6 +129,18 @@ def launcher_play_lock(
preparation_allowed=False,
record_owned=False,
)
else:
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(
@@ -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"

View File

@@ -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,
)

View File

@@ -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"
}

Binary file not shown.