Reworked the gamescope to work better with scaling and maximize window.
This commit is contained in:
18
install.sh
18
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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
69
ptlinux/gamescope_child_runtime.py
Normal file
69
ptlinux/gamescope_child_runtime.py
Normal 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())
|
||||
457
ptlinux/gamescope_runtime.py
Normal file
457
ptlinux/gamescope_runtime.py
Normal 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())
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user