From 3a01c6ba2a612dbc014a10f4b0d814b71db42e82 Mon Sep 17 00:00:00 2001 From: m4ko Date: Sun, 6 Sep 2026 10:57:42 +0200 Subject: [PATCH] Reworked the gamescope to work better with scaling and maximize window. --- install.sh | 18 + ptlinux/doctor.py | 2 +- ptlinux/gamebox.py | 142 +++++++- ptlinux/gamebox_runtime.py | 159 ++++++++- ptlinux/gamescope_child_runtime.py | 69 ++++ ptlinux/gamescope_runtime.py | 457 ++++++++++++++++++++++++++ ptlinux/runtime.py | 52 ++- ptlinux/self_update.py | 7 +- ptlinux/settings.py | 6 + ptlinux/ui/dialogs/settings_dialog.py | 20 +- 10 files changed, 920 insertions(+), 12 deletions(-) create mode 100644 ptlinux/gamescope_child_runtime.py create mode 100644 ptlinux/gamescope_runtime.py diff --git a/install.sh b/install.sh index 252d82c..0faf9ad 100644 --- a/install.sh +++ b/install.sh @@ -155,6 +155,13 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ || ! -f "${SOURCE_ROOT}/ptlinux/ui/dashboard.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ + || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ + || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ + || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ + || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ + || ! -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}/packaging/eu.pristontale.PTLinux.desktop" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ @@ -237,6 +244,17 @@ install -m 0644 \ install -m 0644 \ "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ "${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe" +install -m 0755 \ + "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ + "${STAGING_RELEASE}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" +install -m 0644 \ + "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ + "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ + "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ + "${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" \ + "${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" install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py" diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py index 2c43ce7..fc14061 100644 --- a/ptlinux/doctor.py +++ b/ptlinux/doctor.py @@ -84,7 +84,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: "gamescope", shutil.which("gamescope"), False, - "optional manual game-container diagnostics", + "optional fixed-surface scaling for a stable resizable game window", ), ToolStatus( "cabextract", diff --git a/ptlinux/gamebox.py b/ptlinux/gamebox.py index 92a87e2..59e7054 100644 --- a/ptlinux/gamebox.py +++ b/ptlinux/gamebox.py @@ -1,19 +1,114 @@ -"""Native X11 command construction for the managed game window.""" +"""Command construction for the managed game window.""" from __future__ import annotations +import os +from pathlib import Path +import shutil import sys -from typing import Sequence +from typing import Mapping, Sequence from .settings import Settings +MIN_GAME_CANVAS_DIMENSION = 320 +MAX_GAME_CANVAS_DIMENSION = 16384 +MAX_GAME_CONFIG_BYTES = 1024 * 1024 + + +class GameboxConfigurationError(ValueError): + """Raised when stable outer scaling cannot use the game configuration.""" + + +def find_gamescope( + environment: Mapping[str, str] | None = None, +) -> str | None: + """Find Gamescope using the environment inherited by the game launch.""" + + env = os.environ if environment is None else environment + return shutil.which("gamescope", path=env.get("PATH")) + + +def read_game_canvas_size(game_config: Path) -> tuple[int, int]: + """Read the exact fixed render size advertised in ``game.ini``.""" + + try: + content = game_config.read_bytes() + except OSError as exc: + raise GameboxConfigurationError( + f"could not read game window configuration {game_config}: {exc}" + ) from exc + if len(content) > MAX_GAME_CONFIG_BYTES: + raise GameboxConfigurationError( + f"game window configuration is unexpectedly large: {game_config}" + ) + if b"\x00" in content: + raise GameboxConfigurationError( + f"game window configuration uses an unsupported encoding: {game_config}" + ) + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as utf8_error: + try: + text = content.decode("cp1252") + except UnicodeDecodeError: + raise GameboxConfigurationError( + f"game window configuration uses an unsupported encoding: {game_config}" + ) from utf8_error + + values: dict[str, int] = {} + section: str | None = None + for line_number, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + section = stripped[1:-1].strip().casefold() + continue + if section != "screen" or not stripped or stripped.startswith((";", "#")): + continue + left, separator, right = line.partition("=") + if not separator: + continue + key = left.strip().casefold() + if key not in {"width", "height"}: + continue + if key in values: + raise GameboxConfigurationError( + f"duplicate [Screen] {key.title()} in {game_config}" + ) + try: + value = int(right.strip(), 10) + except ValueError as exc: + raise GameboxConfigurationError( + f"invalid [Screen] {key.title()} on line {line_number} of {game_config}" + ) from exc + if value < MIN_GAME_CANVAS_DIMENSION or value > MAX_GAME_CANVAS_DIMENSION: + raise GameboxConfigurationError( + f"[Screen] {key.title()} must be between " + f"{MIN_GAME_CANVAS_DIMENSION} and {MAX_GAME_CANVAS_DIMENSION}" + ) + values[key] = value + if set(values) != {"width", "height"}: + raise GameboxConfigurationError( + f"game window configuration has no complete [Screen] Width/Height pair: " + f"{game_config}" + ) + return values["width"], values["height"] + + def build_gamebox_command( game_command: Sequence[str], settings: Settings, + *, + game_size: tuple[int, int] | None = None, + gamescope_path: str | None = None, ) -> list[str]: if not game_command: raise ValueError("game command cannot be empty") + use_gamescope = ( + settings.allow_window_resizing + and gamescope_path is not None + and game_size is not None + ) command = [ sys.executable, "-m", @@ -23,7 +118,48 @@ def build_gamebox_command( ] if settings.borderless: command.append("--borderless") + if use_gamescope: + command.append("--gamescope-scaling") if settings.suppress_unresponsive_warnings: command.append("--suppress-wm-ping") command.extend(("--", *[str(value) for value in game_command])) - return command + if not use_gamescope: + return command + + assert gamescope_path is not None + assert game_size is not None + width, height = game_size + if not ( + MIN_GAME_CANVAS_DIMENSION <= width <= MAX_GAME_CANVAS_DIMENSION + and MIN_GAME_CANVAS_DIMENSION <= height <= MAX_GAME_CANVAS_DIMENSION + ): + raise ValueError("game size is outside the supported Gamescope range") + gamescope_command = [ + sys.executable, + "-m", + "ptlinux.gamescope_runtime", + "--width", + str(width), + "--height", + str(height), + "--", + gamescope_path, + "-w", + str(width), + "-h", + str(height), + "-W", + str(width), + "-H", + str(height), + # Gamescope recalculates ``fit`` from each committed game buffer, so an + # in-game resolution change keeps its own aspect ratio without cropping. + "-S", + "fit", + "-F", + "linear", + ] + if settings.borderless: + gamescope_command.append("--borderless") + gamescope_command.extend(("--", *command)) + return gamescope_command diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 7ee18ac..28764f1 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -46,9 +46,12 @@ _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20 _X_WM_STATE_ICONIC = 3 _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 +_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2 WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0 _P_MIN_SIZE = 1 << 4 _P_MAX_SIZE = 1 << 5 +_P_RESIZE_INC = 1 << 6 +_P_BASE_SIZE = 1 << 8 _MOTIF_HINTS_FUNCTIONS = 1 << 0 _MOTIF_HINTS_DECORATIONS = 1 << 1 _MOTIF_ALL = 1 << 0 @@ -148,6 +151,26 @@ def _normal_hints_without_fixed_size( return tuple(updated) +def _normal_hints_with_resize_increments( + hints: Sequence[int], + current_size: tuple[int, int], + increments: tuple[int, int], +) -> tuple[int, ...]: + """Add resize increments while preserving every unrelated size hint.""" + + updated = list(hints) + if len(updated) < 18: + updated.extend(0 for _ in range(18 - len(updated))) + width, height = current_size + width_increment, height_increment = increments + updated[0] |= _P_RESIZE_INC | _P_BASE_SIZE + updated[9] = width_increment + updated[10] = height_increment + updated[15] = width % width_increment + updated[16] = height % height_increment + return tuple(updated) + + class GameboxRuntimeError(RuntimeError): """Raised when the game command or X11 mapper cannot be started.""" @@ -258,6 +281,69 @@ class _X11: window, title = selected return self._client_window(window), title + def find_window_by_pid(self, expected_pid: int) -> int | None: + """Find a top-level host window created by one native process.""" + + queue = self._root_windows() + visited: set[int] = set() + while queue and len(visited) < 4096: + window = queue.pop(0) + if window in visited: + continue + visited.add(window) + pid_values = self._long_property(window, b"_NET_WM_PID", 1) + if pid_values and pid_values[0] == expected_pid: + return window + queue.extend(self._child_windows(window)) + return None + + def set_resize_increments( + self, + window: int, + current_size: tuple[int, int], + increments: tuple[int, int], + ) -> bool: + """Quantize host resize requests without replacing other WM hints.""" + + if any(value <= 0 for value in (*current_size, *increments)): + raise ValueError("window sizes and resize increments must be positive") + hints = self._long_property(window, b"WM_NORMAL_HINTS", 18) or () + updated = _normal_hints_with_resize_increments( + hints, + current_size, + increments, + ) + self._replace_long_property( + window, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + updated, + ) + self.library.XSync(self.display, 0) + confirmed = self._long_property(window, b"WM_NORMAL_HINTS", 18) + return bool( + confirmed + and len(confirmed) >= 17 + and confirmed[0] & _P_RESIZE_INC + and confirmed[9:11] == increments + ) + + def prefer_composited_presentation(self, window: int) -> bool: + """Keep the host compositor active for this window at every size.""" + + self._replace_long_property( + window, + b"_NET_WM_BYPASS_COMPOSITOR", + b"CARDINAL", + (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,), + ) + self.library.XSync(self.display, 0) + return self._long_property( + window, + b"_NET_WM_BYPASS_COMPOSITOR", + 1, + ) == (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,) + def _root_windows(self) -> list[int]: root = self.library.XDefaultRootWindow(self.display) return self._child_windows(root) @@ -1446,6 +1532,30 @@ def _maintain_game_window_scaling( return int(process.wait()) +def _maintain_game_window_focus( + mapper: _X11, + process: subprocess.Popen[bytes], + window: int, +) -> int: + """Keep the keyboard-focus repair while retaining Wine's fixed-size hints.""" + + print( + "Game window resizing is disabled to prevent repeated Direct3D " + "device resets.", + file=sys.stderr, + flush=True, + ) + next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS + while process.poll() is None: + time.sleep(WINDOW_SCALING_EVENT_TIMEOUT_SECONDS) + now = time.monotonic() + if now < next_focus_repair_at: + continue + _repair_game_window_focus(mapper, window) + next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS + return int(process.wait()) + + def _guard_game_window_startup( mapper: _X11, process: subprocess.Popen[bytes], @@ -1552,6 +1662,8 @@ def run_game_window( window_title: str = WINDOW_TITLE, *, borderless: bool = False, + allow_window_resizing: bool = False, + gamescope_scaling: bool = False, suppress_wm_ping: bool = False, ) -> int: if not command: @@ -1604,6 +1716,23 @@ def run_game_window( and game_window_created_at is not None and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS ): + if gamescope_scaling: + # Gamescope owns the nested child's fullscreen/maximized state and + # surface geometry. Trying to restore or nudge that window makes the + # two window managers fight and can produce visible frame flicker. + print( + f"Game window ready: {game_title or window_title}", + file=sys.stderr, + flush=True, + ) + _focus_game_window(mapper, game_window) + print( + "Game presentation is isolated; Gamescope handles " + "aspect-preserving outer-window resizing.", + file=sys.stderr, + flush=True, + ) + return int(process.wait()) _prepare_game_window( mapper, game_window, @@ -1622,12 +1751,30 @@ def run_game_window( game_window, borderless=borderless, ) - if not borderless: + if ( + allow_window_resizing + and not gamescope_scaling + and startup_stable + and not borderless + ): return _maintain_game_window_scaling( mapper, process, game_window, ) + if not borderless: + if allow_window_resizing and not startup_stable: + print( + "Warning: live window resizing was not enabled because the " + "startup window did not stabilize.", + file=sys.stderr, + flush=True, + ) + return _maintain_game_window_focus( + mapper, + process, + game_window, + ) return int(process.wait()) time.sleep(0.1) if process.poll() is None: @@ -1636,7 +1783,7 @@ def run_game_window( file=sys.stderr, flush=True, ) - if game_window is not None: + if game_window is not None and not gamescope_scaling: _prepare_game_window( mapper, game_window, @@ -1652,6 +1799,12 @@ def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--window-title", default=WINDOW_TITLE) parser.add_argument("--borderless", action="store_true") + parser.add_argument( + "--allow-window-resizing", + action="store_true", + help="enable unsafe native client resizing for diagnostics", + ) + parser.add_argument("--gamescope-scaling", action="store_true") parser.add_argument("--suppress-wm-ping", action="store_true") parser.add_argument("command", nargs=argparse.REMAINDER) arguments = parser.parse_args(argv) @@ -1663,6 +1816,8 @@ def main(argv: Sequence[str] | None = None) -> int: command, arguments.window_title, borderless=arguments.borderless, + allow_window_resizing=arguments.allow_window_resizing, + gamescope_scaling=arguments.gamescope_scaling, suppress_wm_ping=arguments.suppress_wm_ping, ) except (GameboxRuntimeError, OSError) as exc: diff --git a/ptlinux/gamescope_child_runtime.py b/ptlinux/gamescope_child_runtime.py new file mode 100644 index 0000000..3ec648f --- /dev/null +++ b/ptlinux/gamescope_child_runtime.py @@ -0,0 +1,69 @@ +"""Report the nested client status to the outer Gamescope supervisor.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import sys +from collections.abc import Sequence + + +def _process_exit_status(returncode: int) -> int: + if returncode < 0: + return 128 + min(-returncode, 127) + return min(returncode, 255) + + +def run_and_record(command: Sequence[str], status_file: Path) -> int: + if not command: + raise ValueError("nested game command cannot be empty") + try: + process = subprocess.Popen([str(value) for value in command]) + except OSError as exc: + print(f"Could not start the nested game command: {exc}", file=sys.stderr) + returncode = 127 + else: + try: + returncode = int(process.wait()) + except BaseException: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + + temporary = status_file.with_name(f".{status_file.name}.{os.getpid()}.tmp") + try: + temporary.write_text(f"{returncode}\n", encoding="ascii") + os.replace(temporary, status_file) + except OSError as exc: + print(f"Could not report the nested game status: {exc}", file=sys.stderr) + try: + temporary.unlink() + except FileNotFoundError: + pass + return _process_exit_status(returncode) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--status-file", required=True, type=Path) + parser.add_argument("command", nargs=argparse.REMAINDER) + arguments = parser.parse_args(argv) + command = arguments.command + if command and command[0] == "--": + command = command[1:] + try: + return run_and_record(command, arguments.status_file) + except ValueError as exc: + print(f"Nested game launch failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ptlinux/gamescope_runtime.py b/ptlinux/gamescope_runtime.py new file mode 100644 index 0000000..6029f55 --- /dev/null +++ b/ptlinux/gamescope_runtime.py @@ -0,0 +1,457 @@ +"""Launch Gamescope with low-overhead host-window resize behavior.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import tempfile +import time +from collections.abc import Mapping, Sequence + +from .gamebox_runtime import GameboxRuntimeError, _X11 + +WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0 +WINDOW_DISCOVERY_POLL_SECONDS = 0.05 +RESIZE_STEPS_PER_GAME_CANVAS = 20 +MIN_RESIZE_INCREMENT = 8 +MAX_RESIZE_INCREMENT = 96 +CHILD_STATUS_POLL_SECONDS = 0.05 +GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0 +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" +) +BUNDLED_GAMESCOPE_OS_ID = "ubuntu" +BUNDLED_GAMESCOPE_OS_VERSION = "26.04" +HASH_READ_SIZE = 256 * 1024 +_GAMESCOPE_VERSION_PATTERN = re.compile( + r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)", + re.IGNORECASE, +) + + +class GamescopeRuntimeError(RuntimeError): + """Raised when the Gamescope process cannot be started.""" + + +def resize_increments(width: int, height: int) -> tuple[int, int]: + """Return coarse-enough size steps to avoid a swapchain rebuild storm.""" + + if width <= 0 or height <= 0: + raise ValueError("game canvas dimensions must be positive") + return ( + min( + MAX_RESIZE_INCREMENT, + max(MIN_RESIZE_INCREMENT, width // RESIZE_STEPS_PER_GAME_CANVAS), + ), + min( + MAX_RESIZE_INCREMENT, + max(MIN_RESIZE_INCREMENT, height // RESIZE_STEPS_PER_GAME_CANVAS), + ), + ) + + +def _supervised_command( + command: Sequence[str], + status_file: Path, + backend: str, + *, + force_composition: bool = False, +) -> list[str]: + """Select a presentation backend and wrap the primary child.""" + + if not command: + raise ValueError("Gamescope command cannot be empty") + if backend not in {"sdl", "wayland"}: + raise ValueError(f"unsupported Gamescope backend: {backend}") + result = [ + str(command[0]), + "--backend", + backend, + "--keep-alive", + ] + if backend == "sdl" or force_composition: + result.append("--force-composition") + result.extend(map(str, command[1:])) + try: + separator = result.index("--") + except ValueError as exc: + raise ValueError("Gamescope command has no nested command separator") from exc + if separator == len(result) - 1: + raise ValueError("Gamescope command has no nested command") + result[separator + 1:separator + 1] = [ + sys.executable, + "-m", + "ptlinux.gamescope_child_runtime", + "--status-file", + str(status_file), + "--", + ] + return result + + +def _read_gamescope_version(executable: str) -> tuple[int, int, int] | None: + """Read Gamescope's release tuple without delaying a normal launch.""" + + try: + completed = subprocess.run( + [executable, "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=GAMESCOPE_VERSION_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if completed.returncode != 0: + return None + match = _GAMESCOPE_VERSION_PATTERN.search(completed.stdout) + if match is None: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def _bundled_gamescope_path() -> Path: + return Path(__file__).resolve().parent / "native" / BUNDLED_GAMESCOPE_FILENAME + + +def _bundled_gamescope_host_supported() -> bool: + """Limit the distribution-specific executable to its build target.""" + + if sys.platform != "linux" or platform.machine().casefold() not in { + "amd64", + "x86_64", + }: + return False + try: + release = platform.freedesktop_os_release() + except OSError: + return False + return ( + release.get("ID", "").casefold() == BUNDLED_GAMESCOPE_OS_ID + and release.get("VERSION_ID") == BUNDLED_GAMESCOPE_OS_VERSION + ) + + +def _bundled_gamescope_is_usable() -> bool: + """Verify the launcher-local executable before selecting it.""" + + if not _bundled_gamescope_host_supported(): + return False + path = _bundled_gamescope_path() + try: + if not path.is_file() or not os.access(path, os.X_OK): + return False + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""): + digest.update(chunk) + except OSError: + return False + return digest.hexdigest() == BUNDLED_GAMESCOPE_SHA256 + + +def _select_wayland_gamescope(executable: str) -> tuple[str, bool, bool]: + """Choose a direct-safe Gamescope or request the stable composed path.""" + + version = _read_gamescope_version(executable) + if version is not None and version >= WAYLAND_BACKING_FIX_VERSION: + return executable, False, False + if version == BUNDLED_GAMESCOPE_VERSION and _bundled_gamescope_is_usable(): + return str(_bundled_gamescope_path()), False, True + return executable, True, False + + +def _wayland_socket_available(environment: Mapping[str, str]) -> bool: + """Return whether the launch environment names an accessible Wayland socket.""" + + display = environment.get("WAYLAND_DISPLAY", "").strip() + if not display: + return False + display_path = Path(display) + if not display_path.is_absolute(): + runtime_directory = environment.get("XDG_RUNTIME_DIR", "").strip() + if not runtime_directory: + return False + display_path = Path(runtime_directory) / display_path + try: + return display_path.is_socket() + except OSError: + return False + + +def _select_host_backend(environment: Mapping[str, str]) -> str: + """Prefer Wayland's retained-buffer resize path when it is usable.""" + + if _wayland_socket_available(environment): + return "wayland" + return "sdl" + + +def _create_child_status_file() -> Path: + """Create a private rendezvous file inherited by the nested child command.""" + + runtime_directory = os.environ.get("XDG_RUNTIME_DIR") + try: + file_descriptor, path = tempfile.mkstemp( + prefix="ptlinux-gamescope-status-", + dir=runtime_directory, + ) + except OSError: + file_descriptor, path = tempfile.mkstemp(prefix="ptlinux-gamescope-status-") + os.close(file_descriptor) + return Path(path) + + +def _read_child_status(status_file: Path) -> int | None: + try: + value = status_file.read_text(encoding="ascii").strip() + except (FileNotFoundError, OSError, UnicodeError): + return None + if not value: + return None + try: + returncode = int(value, 10) + except ValueError: + return None + if returncode < -255 or returncode > 255: + return None + return returncode + + +def _process_exit_status(returncode: int) -> int: + if returncode < 0: + return 128 + min(-returncode, 127) + return min(returncode, 255) + + +def _wait_for_optimized_gamescope( + process: subprocess.Popen[bytes], + status_file: Path, +) -> int: + """Return the real child status and bypass old Gamescope shutdown bugs.""" + + while True: + child_status = _read_child_status(status_file) + if child_status is not None: + if process.poll() is None: + process.kill() + process.wait() + return _process_exit_status(child_status) + gamescope_status = process.poll() + if gamescope_status is not None: + child_status = _read_child_status(status_file) + return _process_exit_status( + gamescope_status if child_status is None else child_status + ) + time.sleep(CHILD_STATUS_POLL_SECONDS) + + +def _open_host_x11() -> _X11 | None: + """Open the host X11 connection used by Gamescope's SDL window.""" + + if not os.environ.get("DISPLAY"): + return None + try: + return _X11() + except GameboxRuntimeError: + return None + + +def _tune_process_window( + mapper: _X11, + process: subprocess.Popen[bytes], + width: int, + height: int, +) -> tuple[int, int] | None: + """Stabilize resizing once the SDL host window becomes available.""" + + deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS + while process.poll() is None and time.monotonic() < deadline: + window = mapper.find_window_by_pid(process.pid) + if window is not None: + increments = resize_increments(width, height) + increments_applied = mapper.set_resize_increments( + window, + (width, height), + increments, + ) + compositing_preferred = mapper.prefer_composited_presentation(window) + if increments_applied and compositing_preferred: + return increments + time.sleep(WINDOW_DISCOVERY_POLL_SECONDS) + return None + + +def run_gamescope(command: Sequence[str], width: int, height: int) -> int: + """Run Gamescope with stable host presentation for the current session.""" + + if not command: + raise GamescopeRuntimeError("Gamescope command cannot be empty") + resize_increments(width, height) + environment = os.environ.copy() + backend = _select_host_backend(environment) + selected_command = list(map(str, command)) + force_composition = backend == "sdl" + using_bundled_gamescope = False + if backend == "wayland": + ( + selected_command[0], + force_composition, + using_bundled_gamescope, + ) = _select_wayland_gamescope(selected_command[0]) + mapper = _open_host_x11() if backend == "sdl" else None + status_file: Path | None = None + try: + candidate_status_file = _create_child_status_file() + try: + launch_command = _supervised_command( + selected_command, + candidate_status_file, + backend, + force_composition=force_composition, + ) + except ValueError: + candidate_status_file.unlink(missing_ok=True) + if mapper is not None: + mapper.close() + raise + status_file = candidate_status_file + except OSError as exc: + launch_command = [ + selected_command[0], + "--backend", + backend, + ] + if force_composition: + launch_command.append("--force-composition") + launch_command.extend(selected_command[1:]) + print( + f"Warning: Gamescope child-status supervision is unavailable: {exc}; " + "continuing with stable presentation.", + file=sys.stderr, + flush=True, + ) + if backend == "sdl" and environment.get("DISPLAY"): + environment["SDL_VIDEODRIVER"] = "x11" + # Keep Mutter on one composited presentation path at every window size. + # 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" + try: + process = subprocess.Popen(launch_command, env=environment) + except OSError as exc: + if mapper is not None: + mapper.close() + if status_file is not None: + status_file.unlink(missing_ok=True) + raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc + + if backend == "wayland" and not force_composition: + message = ( + "Gamescope resize optimization active: native Wayland direct " + "presentation keeps live host-window changes off the game surface." + ) + if using_bundled_gamescope: + message += ( + " Launcher-local lazy-output, live-resize, letterbox backing, " + "and decoration-input fixes are active." + ) + print(message, file=sys.stderr, flush=True) + elif backend == "wayland": + print( + "Warning: this Gamescope build lacks safe direct Wayland letterbox " + "backing. Forced composition prevents flicker, but live resizing may " + "pause; install Gamescope 3.16.28 or newer for direct presentation.", + file=sys.stderr, + flush=True, + ) + elif mapper is not None: + try: + try: + increments = _tune_process_window( + mapper, + process, + width, + height, + ) + except Exception as exc: + increments = None + print( + f"Warning: Gamescope resize tuning failed: {exc}; " + "continuing normally.", + file=sys.stderr, + flush=True, + ) + finally: + mapper.close() + if increments is not None: + print( + "Gamescope resize optimization active: the host window remains " + "composited and size changes use " + f"{increments[0]}x{increments[1]} pixel steps.", + file=sys.stderr, + flush=True, + ) + elif process.poll() is None: + print( + "Warning: Gamescope's host window could not be resize-tuned; " + "continuing normally.", + file=sys.stderr, + flush=True, + ) + else: + print( + "Gamescope is using its SDL presentation fallback; host X11 resize " + "tuning is unavailable.", + file=sys.stderr, + flush=True, + ) + try: + if status_file is not None: + return _wait_for_optimized_gamescope(process, status_file) + return _process_exit_status(int(process.wait())) + except BaseException: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + finally: + if status_file is not None: + status_file.unlink(missing_ok=True) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--width", required=True, type=int) + parser.add_argument("--height", required=True, type=int) + parser.add_argument("command", nargs=argparse.REMAINDER) + arguments = parser.parse_args(argv) + command = arguments.command + if command and command[0] == "--": + command = command[1:] + try: + return run_gamescope(command, arguments.width, arguments.height) + except (GamescopeRuntimeError, ValueError) as exc: + print(f"Gamescope launch failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 75dafb5..91b0390 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -18,7 +18,12 @@ from typing import Mapping, Sequence from .command import CommandOutcome, CommandRunner, OutputLineFilter from .container_host import container_host_error, inspect_container_host from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption -from .gamebox import build_gamebox_command +from .gamebox import ( + GameboxConfigurationError, + build_gamebox_command, + find_gamescope, + read_game_canvas_size, +) from .paths import AppPaths from .settings import Settings from .umu import find_umu_launcher @@ -376,6 +381,18 @@ def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: return False +def _read_game_canvas_size_or_warn(game_config: Path) -> tuple[int, int] | None: + try: + return read_game_canvas_size(game_config) + except GameboxConfigurationError as exc: + print( + f"Warning: {exc}; using a fixed game window.", + file=sys.stderr, + flush=True, + ) + return None + + def run_game( paths: AppPaths, settings: Settings, @@ -441,10 +458,39 @@ def run_game( environment["PROTON_VERB"] = ( "runinprefix" if concurrent_prefix_launch else "waitforexitandrun" ) - if use_gamebox: - game_command = build_gamebox_command(game_command, settings) if extra_environment: environment.update(extra_environment) + if use_gamebox: + gamescope_path = None + game_size = None + if settings.allow_window_resizing: + gamescope_environment = os.environ.copy() + gamescope_environment.update(environment) + gamescope_path = find_gamescope(gamescope_environment) + if gamescope_path is None: + print( + "Warning: a resizable Gamescope window was requested, but " + "Gamescope was not found; using a fixed game window.", + file=sys.stderr, + flush=True, + ) + else: + game_size = _read_game_canvas_size_or_warn(paths.game_config) + if game_size is not None: + width, height = game_size + print( + "Optimized stable resize enabled through Gamescope: the game " + f"starts at {width}x{height} and keeps its aspect ratio while " + "the outer window scales.", + file=sys.stderr, + flush=True, + ) + game_command = build_gamebox_command( + game_command, + settings, + game_size=game_size, + gamescope_path=gamescope_path, + ) environment["PYTHONPATH"] = _runtime_python_path( os.environ.get("PYTHONPATH"), ) diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index 5c9e555..19cc872 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -837,6 +837,11 @@ def _extract_release_archive(archive: Path, destination: Path, version: str) -> raise LauncherUpdateError( f"launcher archive member is group/world writable: {member.name!r}" ) + permissions = member.mode & 0o777 + if member.isfile() and permissions not in {0o644, 0o755}: + raise LauncherUpdateError( + f"launcher archive member has unsupported permissions: {member.name!r}" + ) target = destination.joinpath(*relative.parts) if member.isdir(): target.mkdir(parents=True, exist_ok=True, mode=0o755) @@ -866,7 +871,7 @@ def _extract_release_archive(archive: Path, destination: Path, version: str) -> raise LauncherUpdateError( f"launcher payload was incomplete: {member.name!r}" ) - target.chmod(0o644) + target.chmod(permissions) def _validate_release_directory(release: Path) -> str: diff --git a/ptlinux/settings.py b/ptlinux/settings.py index c670b2e..8d0b958 100644 --- a/ptlinux/settings.py +++ b/ptlinux/settings.py @@ -45,6 +45,7 @@ class Settings: game_source: str | None = None download_speed_limit_mbps: float | None = None low_bandwidth_mode: bool = False + allow_window_resizing: bool = False @property def source_override(self) -> Path | None: @@ -154,6 +155,11 @@ def _settings_from_object(value: dict[str, object]) -> Settings: realm=_string(value, "realm", "Babel").strip(), ui_scale=_string(value, "ui_scale", "auto"), borderless=_migrated_borderless(value), + allow_window_resizing=_boolean( + value, + "allow_window_resizing", + False, + ), suppress_unresponsive_warnings=_boolean( value, "suppress_unresponsive_warnings", diff --git a/ptlinux/ui/dialogs/settings_dialog.py b/ptlinux/ui/dialogs/settings_dialog.py index e12fd8c..4e08cb9 100644 --- a/ptlinux/ui/dialogs/settings_dialog.py +++ b/ptlinux/ui/dialogs/settings_dialog.py @@ -131,6 +131,9 @@ def open_settings_dialog( content.columnconfigure(1, weight=1, uniform="settings-card") borderless_value = tk.BooleanVar(value=current_settings.borderless) + allow_window_resizing_value = tk.BooleanVar( + value=current_settings.allow_window_resizing + ) ui_scale_value = tk.StringVar( value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) ) @@ -265,6 +268,7 @@ def open_settings_dialog( updated_settings = update_settings( paths, borderless=borderless_value.get(), + allow_window_resizing=allow_window_resizing_value.get(), ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), suppress_unresponsive_warnings=suppress_unresponsive_value.get(), runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), @@ -301,8 +305,12 @@ def open_settings_dialog( ttk.Label( display_card, text=( - "Decorated game windows become freely resizable with persistent maximize " - "support after startup. Borderless mode removes all host decorations." + "Gamescope can resize the desktop window while keeping the game's 32-bit " + "Direct3D surface fixed. The game keeps its aspect ratio as the window " + "resizes, with black bars when the window has a different shape. Maximize " + "the window or press Super+F to toggle fullscreen. " + "If Gamescope is unavailable, the launcher keeps the native game window " + "at its configured size." ), style="SettingsHint.TLabel", wraplength=360, @@ -317,6 +325,14 @@ def open_settings_dialog( cursor="hand2", takefocus=True, ).pack(fill="x") + ttk.Checkbutton( + presentation, + text="Use a resizable scaled window (Gamescope)", + variable=allow_window_resizing_value, + style="Settings.TCheckbutton", + cursor="hand2", + takefocus=True, + ).pack(fill="x", pady=(7, 0)) ttk.Checkbutton( presentation, text="Suppress false 'not responding' warnings",