Reworked the gamescope to work better with scaling and maximize window.

This commit is contained in:
2026-09-06 10:57:42 +02:00
parent f0d631be66
commit 3a01c6ba2a
10 changed files with 920 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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