Fixed issues with gamescope not properly launching.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user