474 lines
16 KiB
Python
474 lines
16 KiB
Python
"""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 = (
|
|
"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+)",
|
|
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 and restore its safe archive mode."""
|
|
|
|
if not _bundled_gamescope_host_supported():
|
|
return False
|
|
path = _bundled_gamescope_path()
|
|
try:
|
|
if path.is_symlink() or not path.is_file():
|
|
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
|
|
if digest.hexdigest() != BUNDLED_GAMESCOPE_SHA256:
|
|
return False
|
|
if not os.access(path, os.X_OK):
|
|
try:
|
|
path.chmod(0o755)
|
|
except OSError:
|
|
return False
|
|
return os.access(path, os.X_OK)
|
|
|
|
|
|
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)
|
|
# Some X11 window managers keep the maximize decoration hidden until
|
|
# both the size constraints and allowed actions advertise resizing.
|
|
scaling_enabled = mapper.enable_window_scaling(window)
|
|
increments_applied = mapper.set_resize_increments(
|
|
window,
|
|
(width, height),
|
|
increments,
|
|
)
|
|
compositing_preferred = mapper.prefer_composited_presentation(window)
|
|
if scaling_enabled and 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"
|
|
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:
|
|
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, "
|
|
"decoration-input, and login-window visibility 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())
|