Release 1.1.3 with game window and Gamescope lifecycle fixes

This commit is contained in:
2026-10-25 20:47:19 +01:00
parent b6de80e758
commit 1f2aad0ee2
42 changed files with 9291 additions and 153 deletions

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import queue
import subprocess
import unittest
from unittest.mock import MagicMock
from ptlinux.ui.controllers.operation import start_operation_process
from ptlinux.ui.viewmodels.downloader import ProgressCoalescer
class _InlineThread:
def __init__(self, *, target, name: str, daemon: bool) -> None:
self._target = target
self.name = name
self.daemon = daemon
def start(self) -> None:
self._target()
class _Process:
def __init__(self, lines: list[str], returncode: int = 0, pid: int = 4321) -> None:
self.stdout = iter(lines)
self.returncode = returncode
self.pid = pid
def wait(self) -> int:
return self.returncode
def _drain(events: queue.Queue[tuple[str, object]]) -> list[tuple[str, object]]:
values: list[tuple[str, object]] = []
while not events.empty():
values.append(events.get_nowait())
return values
class DashboardOperationControllerTests(unittest.TestCase):
def test_routes_status_progress_output_and_completion(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
progress = ProgressCoalescer()
registry = MagicMock()
process = _Process(
[
'STATUS {"step": 1, "total": 3, "message": "Starting"}\n',
'PROGRESS {"phase": "downloading", "bytes_completed": 8}\n',
"plain output\n",
"STATUS invalid\n",
],
returncode=7,
)
seen_process_args: list[tuple[list[str], dict[str, object]]] = []
def process_factory(command: list[str], **kwargs: object) -> _Process:
seen_process_args.append((command, kwargs))
return process
start_operation_process(
["launcher", "update"],
events=events,
progress_updates=progress,
game_launch_supervisors=registry,
child_environment=lambda: {"ROLE": "operation"},
interrupt_supervisor=MagicMock(),
process_factory=process_factory,
thread_factory=_InlineThread,
)
self.assertEqual(
_drain(events),
[
(
"operation_status",
{"step": 1, "total": 3, "message": "Starting"},
),
("output", "plain output\n"),
("output", "STATUS invalid\n"),
("done", 7),
],
)
self.assertEqual(
progress.take(),
{"phase": "downloading", "bytes_completed": 8},
)
self.assertEqual(seen_process_args[0][0], ["launcher", "update"])
self.assertEqual(seen_process_args[0][1]["env"], {"ROLE": "operation"})
self.assertEqual(seen_process_args[0][1]["stdout"], subprocess.PIPE)
self.assertEqual(seen_process_args[0][1]["stderr"], subprocess.STDOUT)
def test_routes_game_readiness_and_cancellation_race(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
registry.attach.return_value = True
process = _Process(
[
"Game window created; preparing it\n",
"Game window ready: PristonTale EU\n",
],
)
interrupt = MagicMock(return_value=True)
start_operation_process(
["launcher", "play"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=interrupt,
game_client_id=3,
process_factory=lambda *_args, **_kwargs: process,
thread_factory=_InlineThread,
)
kinds = [kind for kind, _value in _drain(events)]
self.assertEqual(
kinds,
[
"game_supervisor_started",
"output",
"output",
"game_operation_status",
"game_started",
"output",
"game_window_ready",
"game_done",
],
)
registry.attach.assert_called_once_with(3, process)
registry.detach.assert_called_once_with(3, process)
interrupt.assert_called_once_with(process)
def test_unmonitored_process_releases_controls_without_a_window_ready_event(self) -> None:
for line in (
"Game window monitoring unavailable; process started: PristonTale EU\n",
"Game window ready: unmanaged PristonTale EU\n",
):
with self.subTest(line=line):
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
registry.attach.return_value = False
process = _Process([line], returncode=1)
start_operation_process(
["launcher", "play"], events=events,
progress_updates=ProgressCoalescer(), game_launch_supervisors=registry,
child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=3,
process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread,
)
result = _drain(events)
self.assertIn(("game_window_unmonitored", 3), result)
self.assertNotIn(("game_window_ready", 3), result)
self.assertEqual(result[-1], ("game_done", (3, 1)))
def test_cancellation_before_process_start_skips_the_child(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = True
process_factory = MagicMock()
start_operation_process(
["launcher", "play"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=MagicMock(),
game_client_id=4,
process_factory=process_factory,
thread_factory=_InlineThread,
)
self.assertEqual(
_drain(events),
[
(
"output",
"[Client 4] Launch cancelled before the game supervisor started.\n",
),
("game_done", (4, 130)),
],
)
process_factory.assert_not_called()
def test_process_start_failure_uses_the_matching_completion_event(self) -> None:
for client_id, done_event in ((None, ("done", 1)), (5, ("game_done", (5, 1)))):
with self.subTest(client_id=client_id):
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
def fail_to_start(*_args: object, **_kwargs: object) -> _Process:
raise OSError("missing executable")
start_operation_process(
["missing"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=MagicMock(),
game_client_id=client_id,
process_factory=fail_to_start,
thread_factory=_InlineThread,
)
emitted = _drain(events)
self.assertIn(done_event, emitted)
self.assertIn("Could not start operation", str(emitted[0][1]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,594 @@
from dataclasses import asdict, replace
import ctypes
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.application.commands.play import (
ensure_portable_umu_for_play,
play_locked,
runtime_issue_summary,
)
from ptlinux.cli import _load_or_default_settings
from ptlinux.depot import DepotError, DepotLaunchCatalog, DepotStartOption
from ptlinux.game.arguments import split_windows_arguments
from ptlinux.game.depot.catalogs import select_depot_start_option
from ptlinux.game.depot.manifest import parse_start_options
from ptlinux.game.runtime import (
RuntimeErrorPT,
RuntimeSelection,
game_runtime_ready,
prepare_game_runtime,
run_game,
run_runtime_prefix_command,
runtime_settings_for_realm,
select_runtime,
)
from ptlinux.game.runtime_view import (
KNOWN_SOURCE_SHA256,
OverlayError,
_scene_action,
prepare_runtime_view,
select_scene_action,
)
from ptlinux.paths import AppPaths
from ptlinux.presentation import game_window
from ptlinux.settings import (
Settings,
ensure_settings,
load_settings,
reset_settings,
save_settings,
update_settings,
)
from ptlinux.telemetry.runtime import _selected_prefix, _wine_command
class WindowsArgumentTests(unittest.TestCase):
def test_preserves_windows_paths_quotes_empty_arguments_and_shell_literals(self) -> None:
arguments = (
"PTEU_REBORN", r"C:\game\data", "Babel's Realm", "", "a b",
'a"b', "trailing\\", r"$(touch /tmp/should-not-exist)", "a;b", "%TEMP%",
)
self.assertEqual(split_windows_arguments(subprocess.list2cmdline(arguments)), arguments)
def test_windows_quoting_examples(self) -> None:
for command, expected in (
('"a b c" d e', ("a b c", "d", "e")),
(r'a\\\b d"e f"g h', (r"a\\\b", "de fg", "h")),
(r'a\\\"b c d', ('a\\"b', "c", "d")),
(r'a\\\\"b c" d e', (r"a\\b c", "d", "e")),
('a"b"" c d', ('ab" c d',)),
("Babel's Realm", ("Babel's", "Realm")),
(" \t ", ()),
):
with self.subTest(command=command):
self.assertEqual(split_windows_arguments(command), expected)
def test_manifest_allows_no_arguments_and_keeps_publisher_default(self) -> None:
options, default = parse_start_options(
[
{"Name": "Babel", "Path": "Game.exe", "Arguments": ""},
{"Name": "New Realm", "Path": "New.exe"},
],
"New Realm",
)
self.assertEqual(options[0].arguments, "")
self.assertEqual(options[1].arguments, "")
catalog = DepotLaunchCatalog(10, options, default)
self.assertEqual(select_depot_start_option(catalog, "Removed Realm"), options[1])
class RuntimeDefaultMigrationTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.config.mkdir(parents=True)
def write_settings(self, value: dict[str, object]) -> None:
self.paths.settings_file.write_text(json.dumps(value), encoding="utf-8")
def test_new_and_reset_installations_use_ge_proton(self) -> None:
self.assertEqual(Settings().runtime, "ge-proton")
self.assertEqual(load_settings(self.paths).runtime, "ge-proton")
self.assertFalse(self.paths.settings_file.exists())
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
save_settings(self.paths, Settings(runtime="proton7"))
self.assertEqual(reset_settings(self.paths).runtime, "ge-proton")
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
def test_all_old_profiles_migrate_without_changing_other_preferences(self) -> None:
for runtime in ("auto", "proton7", "proton7-container", "umu", "ge-proton"):
with self.subTest(runtime=runtime):
old_settings = Settings(
runtime=runtime,
proton_path="/Steam Library/Proton 7.0/proton",
renderer="wined3d",
proton_sync="esync",
telemetry_enabled=False,
game_crash_dump_upload_enabled=False,
game_source="/Games/Publisher",
ui_scale="150",
borderless=True,
allow_window_resizing=True,
suppress_unresponsive_warnings=False,
download_speed_limit_mbps=4.0,
low_bandwidth_mode=True,
update_channel="prerelease",
)
self.write_settings({
**asdict(old_settings), "schema": 1,
"title_frame_default_migrated": True,
"future_setting": {"keep": True},
})
before = self.paths.settings_file.read_bytes()
expected = replace(old_settings, runtime="ge-proton")
self.assertEqual(load_settings(self.paths), expected)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(ensure_settings(self.paths), expected)
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
self.assertEqual(payload["future_setting"], {"keep": True})
self.assertEqual(payload["schema"], 1)
self.assertEqual(self.paths.settings_file.stat().st_mode & 0o777, 0o600)
def test_migration_is_persisted_once_and_later_manual_choices_survive(self) -> None:
self.write_settings({"runtime": "proton7", "ge_proton_default_migrated": False})
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
before = self.paths.settings_file.stat()
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
after = self.paths.settings_file.stat()
self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns))
for runtime in ("auto", "proton7", "proton7-container", "umu"):
with self.subTest(runtime=runtime):
update_settings(self.paths, runtime=runtime)
update_settings(self.paths, proton_sync="esync")
self.assertEqual(ensure_settings(self.paths).runtime, runtime)
self.assertEqual(load_settings(self.paths).runtime, runtime)
def test_unrelated_update_migrates_the_runtime(self) -> None:
self.write_settings({"runtime": "umu"})
settings = update_settings(self.paths, low_bandwidth_mode=True)
self.assertEqual(settings.runtime, "ge-proton")
self.assertTrue(settings.low_bandwidth_mode)
self.assertEqual(ensure_settings(self.paths), settings)
def test_explicit_runtime_choice_during_first_save_is_preserved(self) -> None:
for full_save in (False, True):
with self.subTest(full_save=full_save):
self.write_settings({"runtime": "auto"})
if full_save:
save_settings(self.paths, Settings(runtime="umu"))
else:
update_settings(self.paths, runtime="umu")
self.assertEqual(ensure_settings(self.paths).runtime, "umu")
def test_migration_keeps_the_ptr_override_and_ignores_its_path_for_babel(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
for realm in ("PTR Babel", "Test Babel"):
with self.subTest(realm=realm):
self.write_settings({"runtime": "proton7", "realm": realm, "proton_path": str(proton)})
settings = ensure_settings(self.paths)
self.assertEqual(settings.runtime, "ge-proton")
self.assertEqual(settings.realm, realm)
self.assertEqual(runtime_settings_for_realm(settings).runtime, "proton7")
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
ptr = select_runtime(settings, {"HOME": str(self.root)})
babel = select_runtime(replace(settings, realm="Babel"), {})
self.assertEqual(ptr.kind, "proton7")
self.assertEqual(ptr.proton_path, str(proton))
self.assertEqual(babel.kind, "ge-proton")
self.assertIsNone(babel.proton_path)
def test_invalid_runtime_and_marker_are_rejected_without_rewriting(self) -> None:
for value in (
{"runtime": "unsupported"}, {"runtime": None}, {"runtime": True},
{"ge_proton_default_migrated": "true"}, {"ge_proton_default_migrated": 1},
):
with self.subTest(value=value):
self.write_settings(value)
before = self.paths.settings_file.read_bytes()
with self.assertRaises(ValueError):
ensure_settings(self.paths)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
def test_default_cannot_silently_fall_back_to_legacy_proton(self) -> None:
with (
patch("ptlinux.game.runtime.find_umu_launcher", return_value=None),
patch("ptlinux.game.runtime.find_steam_proton7") as find_legacy,
):
with self.assertRaisesRegex(RuntimeErrorPT, "umu-run was not found"):
select_runtime(Settings(), {})
find_legacy.assert_not_called()
def test_default_bootstrap_error_does_not_fall_back_to_proton7(self) -> None:
api = MagicMock()
api.provision_umu.side_effect = RuntimeError("network unavailable")
with self.assertRaisesRegex(RuntimeError, "portable UMU.*network unavailable"):
ensure_portable_umu_for_play(api, self.paths, Settings(), dry_run=False)
api.select_runtime.assert_not_called()
def test_cli_migrates_existing_settings_and_dry_runs_remain_read_only(self) -> None:
self.write_settings({"runtime": "proton7"})
before = self.paths.settings_file.read_bytes()
self.assertEqual(_load_or_default_settings(self.paths, persist=False).runtime, "ge-proton")
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(_load_or_default_settings(self.paths, persist=True).runtime, "ge-proton")
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
class LaunchCompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.runtime_view.mkdir(parents=True)
(self.paths.runtime_view / "Game.exe").touch()
self.runner = MagicMock(dry_run=False)
self.selection = RuntimeSelection("ge-proton", "/usr/bin/umu-run", None, None)
self.host_check = patch("ptlinux.game.runtime._require_container_host")
self.host_check.start()
self.addCleanup(self.host_check.stop)
def test_recovers_legacy_renderer_option_and_persists_migration(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({"proton_path": "PROTON_USE_WINED3D=1"}))
settings = ensure_settings(self.paths)
self.assertIsNone(settings.proton_path)
self.assertEqual(settings.renderer, "wined3d")
self.assertEqual(load_settings(self.paths), settings)
self.assertIsNone(json.loads(self.paths.settings_file.read_text())["proton_path"])
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
selection = select_runtime(settings, {})
run_game(self.paths, settings, selection, self.runner, use_gamebox=False)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], "1")
self.assertIn("d3d9=b", environment["WINEDLLOVERRIDES"])
def test_does_not_execute_or_migrate_arbitrary_path_commands(self) -> None:
for value in ("PROTON_LOG=1", "env PROTON_USE_WINED3D=1 %command%"):
with self.subTest(value=value):
settings = Settings(runtime="auto", proton_path=value)
with self.assertRaisesRegex(ValueError, "filesystem path"):
save_settings(self.paths, settings)
with self.assertRaisesRegex(RuntimeErrorPT, "filesystem path"):
select_runtime(settings, {})
def test_existing_explicit_renderer_wins_over_misplaced_legacy_option(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({
"proton_path": "PROTON_USE_WINED3D=1", "renderer": "dxvk",
}))
self.assertEqual(ensure_settings(self.paths).renderer, "dxvk")
def test_proton_script_field_accepts_a_steam_proton_directory_with_spaces(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
selection = select_runtime(Settings(runtime="proton7", proton_path=str(proton.parent)), {})
self.assertEqual(selection.proton_path, str(proton))
def test_auto_selects_ge_across_distributions_and_keeps_explicit_umu(self) -> None:
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
for runtime in ("auto", "ge-proton"):
for distribution in ("ubuntu", "cachyos", "arch", "fedora"):
with self.subTest(runtime=runtime, distribution=distribution):
self.assertEqual(select_runtime(Settings(runtime=runtime), {}, distribution_id=distribution).kind, "ge-proton")
self.assertEqual(select_runtime(Settings(runtime="umu"), {}).kind, "umu")
def test_future_publisher_scene_is_copied_unchanged_without_old_texture_repair(self) -> None:
source = self.paths.source_game
for relative, content in (
("Game.exe", b"new executable"),
("game.dll", b"new dll"),
("game.ini", b"[Screen]\nWindowed=1\n"),
("game/maps/WhisperingVale.rovug", b"future publisher scene format"),
):
path = source / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
with patch("ptlinux.game.runtime_view.normalize_wrapped_assbin_textures") as repair:
result = prepare_runtime_view(self.paths, source)
self.assertEqual(result.scene_action, "upstream-unmodified-copy")
self.assertEqual(result.binary_action, "upstream-pair")
self.assertEqual(result.source_scene_sha256, result.runtime_scene_sha256)
self.assertEqual((source / "game/maps/WhisperingVale.rovug").read_bytes(), b"future publisher scene format")
repair.assert_not_called()
def test_unknown_scene_texture_count_does_not_trigger_legacy_patch(self) -> None:
self.assertEqual(_scene_action(MagicMock(file_sha256="new", embedded_textures=())), "upstream-unmodified-copy")
self.assertEqual(select_scene_action("new", [16, 16], [2, 2]), "upstream-unmodified-copy")
self.assertEqual(select_scene_action(KNOWN_SOURCE_SHA256, [16, 16], [2, 2]), "normalize-known-profile")
with self.assertRaises(OverlayError):
select_scene_action(KNOWN_SOURCE_SHA256, [16, 8], [2, 2])
def test_ge_setup_game_and_helpers_use_one_isolated_wow64_prefix(self) -> None:
self.runner.dry_run = True
prepare_game_runtime(self.paths, self.selection, self.runner)
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
run_runtime_prefix_command(self.paths, self.selection, self.runner, ["reg.exe", "query"])
self.assertEqual(self.runner.run.call_count, 3)
for call in self.runner.run.call_args_list:
environment = call.kwargs["env_overrides"]
self.assertEqual(environment["PROTONPATH"], "GE-Proton")
self.assertEqual(environment["PROTON_USE_WOW64"], "1")
self.assertEqual(environment["WINEPREFIX"], str(self.paths.ge_proton_prefix))
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.umu_prefix)
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.proton_prefix)
def test_failed_directx_postcondition_does_not_mark_runtime_ready(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "DirectX setup completed without"):
prepare_game_runtime(self.paths, self.selection, self.runner)
self.assertFalse(self.paths.game_runtime_marker.exists())
self.assertFalse(game_runtime_ready(self.paths, self.selection))
def test_shared_clients_cannot_reuse_a_prefix_for_another_runtime(self) -> None:
self.paths.state.mkdir(parents=True)
for prefix in (self.paths.umu_prefix, self.paths.ge_proton_prefix):
directory = prefix / "drive_c/windows/syswow64"
directory.mkdir(parents=True)
for name in ("d3dx9_43", "d3dcompiler_43"):
(directory / f"{name}.dll").touch()
(prefix / "user.reg").write_text('"*d3dx9_43"="native"\n"*d3dcompiler_43"="native"\n')
self.paths.game_runtime_marker.write_text(json.dumps({
"configuration_version": 1, "runtime": asdict(self.selection),
}))
self.assertTrue(game_runtime_ready(self.paths, self.selection))
self.assertFalse(game_runtime_ready(self.paths, RuntimeSelection("umu", "/usr/bin/umu-run", None, None)))
def test_shared_ge_clients_keep_same_environment_except_for_verb(self) -> None:
for concurrent in (False, True):
run_game(
self.paths, Settings(), self.selection, self.runner,
use_gamebox=False, concurrent_prefix_launch=concurrent,
)
first, second = [dict(call.kwargs["env_overrides"]) for call in self.runner.run.call_args_list]
self.assertEqual(first.pop("PROTON_VERB"), "waitforexitandrun")
self.assertEqual(second.pop("PROTON_VERB"), "runinprefix")
self.assertEqual(first, second)
def test_renderer_is_environment_and_native_helpers_preserve_unrelated_overrides(self) -> None:
for kind in ("umu", "ge-proton", "proton7", "proton7-container"):
for renderer, value, override in (("dxvk", "0", "d3d9=n,b"), ("wined3d", "1", "d3d9=b")):
with self.subTest(kind=kind, renderer=renderer):
selection = RuntimeSelection(kind, "/usr/bin/umu-run", "/Steam/proton", "/Steam")
run_game(
self.paths, Settings(renderer=renderer), selection, self.runner,
use_gamebox=False,
extra_environment={"WINEDLLOVERRIDES": "*d3dx9_43,foo=b;d3dcompiler_43=;d3d9=b"},
)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], value)
self.assertEqual(environment["WINEDLLOVERRIDES"],
f"foo=b;d3dx9_43=n,b;d3dcompiler_43=n,b;{override}")
self.assertNotIn("PROTON_USE_WINED3D", " ".join(self.runner.run.call_args.args[0]))
def test_windows_arguments_are_passed_in_order_without_shell_expansion(self) -> None:
folder = self.paths.runtime_view / "New Client"
folder.mkdir()
(folder / "CLIENT.exe").touch()
arguments = ("NEW_TOKEN", r"C:\data\assets", "", "a b", "$(echo literal)")
option = DepotStartOption("New Realm", "New Client/client.exe", subprocess.list2cmdline(arguments))
run_game(self.paths, Settings(), self.selection, self.runner, start_option=option, use_gamebox=False)
call = self.runner.run.call_args
self.assertEqual(call.args[0], ["/usr/bin/umu-run", str(folder / "CLIENT.exe"), *arguments])
self.assertEqual(call.kwargs["cwd"], self.paths.runtime_view)
def test_missing_advertised_executable_fails_before_runtime_start(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "missing New.exe"):
run_game(self.paths, Settings(), self.selection, self.runner,
start_option=DepotStartOption("New", "New.exe", ""), use_gamebox=False)
self.runner.run.assert_not_called()
def test_build_mismatch_blocks_play_before_game_view_or_runtime_changes(self) -> None:
self.paths.state.mkdir(parents=True)
self.paths.native_depot_state.write_text(json.dumps({"build": 100, "dry_run": False}))
api = MagicMock()
api.current_telemetry.return_value = None
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings()
api.is_running_inside_steam_runtime.return_value = False
for build in (99, 101, None):
api.read_cached_depot_launch_catalog.return_value = (
DepotLaunchCatalog(build, (DepotStartOption("Babel", "Game.exe", "NEW"),), "Babel")
if build is not None else None
)
with self.subTest(build=build), self.assertRaisesRegex(DepotError, "update.*before playing"):
play_locked(api, self.paths, None, False, False, False, False)
api.prepare_runtime_view.assert_not_called()
api.prepare_game_runtime.assert_not_called()
api.run_game.assert_not_called()
def test_proton_telemetry_does_not_report_host_wine_as_bundled_wine(self) -> None:
with patch("ptlinux.telemetry.runtime.shutil.which", return_value="/usr/bin/wine") as which:
for kind in ("umu", "ge-proton"):
self.assertIsNone(_wine_command({"kind": kind, "executable": "/usr/bin/umu-run"}, {}))
which.assert_not_called()
(self.paths.ge_proton_prefix / "drive_c/windows").mkdir(parents=True)
self.assertEqual(_selected_prefix(self.paths, {"runtime": asdict(self.selection)}), self.paths.ge_proton_prefix)
def test_gstreamer_negotiation_errors_are_visible_without_claiming_crash_cause(self) -> None:
report = runtime_issue_summary(
"GStreamer-Video-CRITICAL: gst_video_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
"GStreamer-Audio-CRITICAL: gst_audio_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
)
self.assertEqual(report["gstreamer_caps_error_count"], 2)
self.assertFalse(report["gstreamer_stream_error"])
class GameKeyboardFocusTests(unittest.TestCase):
def test_startup_hands_keyboard_focus_to_the_desktop_game_window(self) -> None:
# Model a WM that rejects raw X11 focus changes to an inactive client.
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
active, keyboard = [7], [7]
pending: list[int] = []
def activate(window: int) -> bool:
pending.append(window)
return True
def settle(_seconds: float) -> None:
if pending:
active[0] = pending.pop()
def focus(window: int) -> None:
if active[0] == window:
keyboard[0] = window
mapper.activate_window.side_effect = activate
mapper.window_is_active.side_effect = lambda window: active[0] == window
mapper.window_is_focused.side_effect = lambda window: keyboard[0] == window
mapper.restore_input_focus.side_effect = focus
with patch.object(game_window.time, "sleep", side_effect=settle):
game_window._focus_game_window(mapper, 12)
self.assertEqual((active[0], keyboard[0]), (12, 12))
def test_activation_preserves_minimize_before_and_during_handoff(self) -> None:
for states, requests in (([3], 0), ([1, 3], 1)):
with self.subTest(states=states):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.side_effect = states
mapper.window_is_active.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12)
self.assertEqual(mapper.activate_window.call_count, requests)
mapper.focus_window.assert_not_called()
def test_nested_gamescope_focus_does_not_request_desktop_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.window_is_focused.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12, activate=False)
mapper.activate_window.assert_not_called()
mapper.restore_input_focus.assert_called_once_with(12)
mapper.focus_window.assert_not_called()
def test_activation_uses_only_the_games_own_user_time(self) -> None:
for properties, expected in (
({}, 0),
({(12, b"_NET_WM_USER_TIME"): (12345,)}, 12345),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(13, b"_NET_WM_USER_TIME"): (45678,)}, 45678),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(12, b"_NET_WM_USER_TIME"): (56789,)}, 56789),
):
with self.subTest(properties=properties):
mapper: game_window._X11 = object.__new__(game_window._X11)
with (
patch.object(mapper, "_intern_atom", return_value=77),
patch.object(
mapper, "_long_property",
side_effect=lambda window, name, _count: properties.get((window, name)),
),
patch.object(mapper, "_send_root_client_message", return_value=True) as send,
):
self.assertTrue(mapper.activate_window(12))
send.assert_called_once_with(12, 77, (1, expected, 0, 0, 0))
def test_focus_diagnostics_distinguish_a_request_from_actual_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.activate_window.return_value = True
mapper.window_is_active.return_value = False
mapper.window_diagnostic_snapshot.return_value = {"active": False, "focused": False}
diagnostics = MagicMock()
with (
patch.object(game_window.time, "sleep"),
patch.object(game_window, "WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS", 0.0),
):
game_window._focus_game_window(mapper, 12, diagnostics=diagnostics)
diagnostics.record.assert_called_once_with(
"focus_handoff", window_id=12, activation_requested=True,
activation_submitted=True, outcome="not_activated", active=False, focused=False,
)
def test_focus_repair_respects_background_and_minimized_windows(self) -> None:
for state, active in ((1, False), (3, True)):
with self.subTest(state=state, active=active):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = state
mapper.window_is_active.return_value = active
mapper.window_is_focused.return_value = False
self.assertFalse(game_window._repair_game_window_focus(mapper, 12))
mapper.restore_input_focus.assert_not_called()
mapper.activate_window.assert_not_called()
mapper.focus_window.assert_not_called()
def test_borderless_launch_keeps_focus_repair_without_live_resizing(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.root_window_ids.return_value = set()
mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
process = MagicMock()
process.poll.return_value = None
with (
patch.object(game_window, "_X11", return_value=mapper),
patch.object(game_window.subprocess, "Popen", return_value=process),
patch.object(game_window, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(game_window, "_prepare_game_window"),
patch.object(game_window, "_focus_game_window"),
patch.object(game_window, "_guard_game_window_startup", return_value=True),
patch.object(game_window, "_maintain_game_window_scaling") as resize,
patch.object(game_window, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(game_window.run_game_window(["unused"], borderless=True), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=game_window.WINDOW_TITLE,
existing_window_ids=set(), expected_pid=mapper.window_pid.return_value,
)
resize.assert_not_called()
mapper.close.assert_called_once_with()
def test_child_input_focus_is_preserved_and_foreign_focus_is_rejected(self) -> None:
for focused, parents, expected in (
(12, {}, True),
(14, {14: 13, 13: 12}, True),
(24, {24: 23, 23: 1000, 1000: 0}, False),
(24, {}, False), # The focused window vanished during the read.
(24, {24: 25, 25: 24}, False),
(0, {}, False),
(1, {}, False),
):
with self.subTest(focused=focused, parents=parents):
mapper: game_window._X11 = object.__new__(game_window._X11)
mapper.display = ctypes.c_void_p(1)
mapper.library = MagicMock()
allocations: list[object] = []
def read_focus(_display, pointer, _revert):
pointer._obj.value = focused
return 1
def query_tree(_display, window, root, parent, children, count):
if window not in parents:
return 0
root._obj.value = 1000
parent._obj.value = parents[window]
allocation = (ctypes.c_ulong * 1)(99)
allocations.append(allocation)
ctypes.cast(children, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))[0] = allocation
count._obj.value = 1
return 1
mapper.library.XGetInputFocus.side_effect = read_focus
mapper.library.XQueryTree.side_effect = query_tree
with (
patch.object(mapper, "window_state", return_value=1),
patch.object(mapper, "window_is_active", return_value=True),
patch.object(mapper, "restore_input_focus") as restore,
):
self.assertEqual(game_window._repair_game_window_focus(mapper, 12), expected)
self.assertEqual(restore.call_count, 0 if expected else 1)
self.assertEqual(mapper.library.XFree.call_count, len(allocations))

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
from contextlib import redirect_stderr, redirect_stdout
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock
from ptlinux.application.commands.play import (
game_exit_metadata,
game_failure_reason,
game_readiness_signal,
play_locked,
print_game_outcome,
runtime_issue_summary,
)
from ptlinux.command import CommandOutcome
from ptlinux.depot import DepotStartOption
from ptlinux.paths import AppPaths
from ptlinux.runtime import RuntimeSelection
from ptlinux.settings import Settings
def missing_status_message(status: int) -> str:
return (
f"Gamescope exited without a recorded game status (compositor status {status}); "
"the game's exit result is unknown.\n"
)
class GamescopeSessionOutcomeTests(unittest.TestCase):
def test_missing_status_is_not_attributed_to_the_game_or_umu(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = None
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
api._runtime_issue_summary.side_effect = runtime_issue_summary
api._game_exit_metadata.side_effect = game_exit_metadata
for ready in (True, False):
for compositor_status, wrapper_status in ((0, 1), (-11, 139), (247, 247), (0, 0)):
with self.subTest(ready=ready, compositor=compositor_status, wrapper=wrapper_status):
api.current_telemetry.return_value.reset_mock()
api._game_readiness_signal.return_value = "window_ready" if ready else None
api.run_game.return_value = CommandOutcome(
[], wrapper_status, "", missing_status_message(compositor_status), 120.0, False, False,
)
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
report = api._emit.call_args.args[0]
self.assertFalse(report["ok"])
self.assertEqual(report["launch_succeeded"], ready)
event_name = "game_exited" if ready else "game_launch_failed"
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == event_name)
metadata = event.kwargs["metadata"]
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
self.assertFalse(metadata["game_exit_status_available"])
self.assertEqual(metadata["exit_status_source"], "gamescope_supervisor")
self.assertEqual(metadata["gamescope_exit_code"], compositor_status)
self.assertEqual(metadata["exit_code"], wrapper_status)
self.assertNotIn("signal_inferred", metadata)
self.assertNotIn("signal", metadata)
self.assertIn("before the game reported its exit status", event.kwargs["message"])
self.assertNotIn("Game client exited with status", event.kwargs["message"])
def test_truncated_output_retains_observed_shutdown_status(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = None
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
api._runtime_issue_summary.side_effect = runtime_issue_summary
api._game_readiness_signal.side_effect = game_readiness_signal
api._gamescope_presentation_failure.return_value = None
api.parse_window_diagnostic.return_value = None
api._game_exit_metadata.side_effect = game_exit_metadata
def run(*args, **kwargs):
observe = kwargs["output_line_filter"]
observe("Game window ready: PristonTale EU\n")
observe(missing_status_message(247))
return CommandOutcome([], 247, "", "<truncated>final child cleanup output\n", 120.0, False, False)
api.run_game.side_effect = run
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
metadata = event.kwargs["metadata"]
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
self.assertEqual(metadata["gamescope_exit_code"], 247)
self.assertNotIn("signal_inferred", metadata)
self.assertTrue(metadata["game_window_ready"])
def test_unobserved_window_is_not_readiness_evidence(self) -> None:
for message in (
"Game window ready: unmanaged PristonTale EU\n",
"Game window monitoring unavailable; process started: PristonTale EU\n",
):
with self.subTest(message=message):
self.assertIsNone(game_readiness_signal(message))
self.assertFalse(runtime_issue_summary(message)["game_window_ready"])
self.assertEqual(game_readiness_signal(message + "Game window ready: PristonTale EU\n"), "window_ready")
def test_human_output_distinguishes_unknown_status_in_old_and_new_logs(self) -> None:
messages = (
missing_status_message(0),
missing_status_message(-11),
"Gamescope exited without a recorded game status; the game session was interrupted.\n",
)
for message in messages:
with self.subTest(message=message):
outcome = CommandOutcome([], 1, "", message, 120.0, False, False)
issues = runtime_issue_summary(message)
output = io.StringIO()
with redirect_stdout(output), redirect_stderr(output):
print_game_outcome(outcome, None, issues)
self.assertIn("before the game reported its exit status", output.getvalue())
self.assertNotIn("Game client exited", output.getvalue())
self.assertNotIn("crashed", output.getvalue())
def test_missing_status_preserves_stronger_failure_evidence(self) -> None:
cases = (
({"runtime_out_of_memory": True}, None, False, "direct3d_resource_exhaustion"),
(None, {"path": "/tmp/crash_game.dat"}, False, "client_crash_dump"),
(None, None, True, "session_timeout"),
)
for native_log, crash, timed_out, reason in cases:
with self.subTest(reason=reason):
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
self.assertEqual(game_failure_reason(outcome, native_log, crash, {}), reason)
def test_human_output_keeps_crash_and_timeout_evidence(self) -> None:
for timed_out, crash, expected in (
(False, {"path": "/tmp/crash_game.dat"}, "crashed and wrote a dump"),
(True, None, "timed out and was stopped"),
):
with self.subTest(expected=expected):
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
output = io.StringIO()
with redirect_stderr(output):
print_game_outcome(outcome, None, runtime_issue_summary(outcome.stderr), crash)
self.assertIn(expected, output.getvalue())
self.assertNotIn("before the game reported", output.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,110 @@
"""Exercise supervisor failure paths without launching a game or display server."""
from contextlib import redirect_stderr
import io
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import textwrap
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.presentation import gamescope
from scripts.repository_paths import SOURCE_ROOT
class GamescopeShutdownRegressions(unittest.TestCase):
def test_missing_status_storage_cannot_be_reported_as_a_clean_game_exit(self) -> None:
process = MagicMock()
process.poll.return_value = 0
output = io.StringIO()
with (
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
patch.object(gamescope, "_open_host_x11", return_value=None),
patch.object(gamescope, "_create_child_status_file", side_effect=OSError("no space left")),
patch.object(gamescope.subprocess, "Popen", return_value=process) as popen,
redirect_stderr(output),
):
self.assertEqual(gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960), 1)
self.assertNotIn("--keep-alive", popen.call_args.args[0])
self.assertIn("supervision is unavailable", output.getvalue())
self.assertIn("without a recorded game status (compositor status 0)", output.getvalue())
def test_interrupt_during_startup_tuning_closes_the_compositor_and_state_files(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
mapper = MagicMock()
process = MagicMock()
process.poll.return_value = None
process.wait.return_value = -15
with (
patch.dict(os.environ, {"DISPLAY": ":0", "XDG_RUNTIME_DIR": temporary}, clear=True),
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
patch.object(gamescope, "_open_host_x11", return_value=mapper),
patch.object(gamescope, "_tune_process_window", side_effect=KeyboardInterrupt),
patch.object(gamescope.subprocess, "Popen", return_value=process),
self.assertRaises(KeyboardInterrupt),
):
gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960)
process.terminate.assert_called_once_with()
process.wait.assert_called_once_with(timeout=gamescope.GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS)
mapper.close.assert_called_once_with()
self.assertEqual(list(Path(temporary).iterdir()), [])
def test_child_report_failure_does_not_leave_an_empty_compositor_alive(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake_gamescope = root / "gamescope"
fake_gamescope.write_text(
f"#!{sys.executable}\n" + textwrap.dedent("""\
import subprocess, sys, time
separator = sys.argv.index('--')
subprocess.run(sys.argv[separator + 1:], check=False)
# Gamescope's default exits when the primary child finishes;
# --keep-alive disables that behavior, even if the child failed.
if '--keep-alive' in sys.argv[1:separator]:
time.sleep(15)
"""), encoding="utf-8",
)
fake_gamescope.chmod(0o700)
runner = root / "supervisor.py"
runner.write_text(textwrap.dedent("""\
from pathlib import Path
import sys
from unittest.mock import patch
from ptlinux.presentation import gamescope
status = Path(sys.argv[2])
with (
patch.object(gamescope, '_select_host_backend', return_value='wayland'),
patch.object(gamescope, '_select_wayland_gamescope', return_value=(sys.argv[1], True, False)),
patch.object(gamescope, '_create_child_status_file', return_value=status),
):
raise SystemExit(gamescope.run_gamescope(
[sys.argv[1], '--', sys.executable, '-c', 'raise SystemExit(7)'], 1280, 960,
))
"""), encoding="utf-8")
for status, expected in ((root / "status", 7), (root / "removed-directory" / "status", 1)):
with self.subTest(status=status):
process = subprocess.Popen(
[sys.executable, str(runner), str(fake_gamescope), str(status)],
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT)},
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
)
try:
_stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.communicate(timeout=5)
raise
self.assertEqual(process.returncode, expected, stderr)
if expected == 1:
self.assertIn("Could not report the nested game status", stderr)
self.assertIn("without a recorded game status", stderr)
else:
self.assertNotIn("without a recorded game status", stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,690 @@
from __future__ import annotations
from contextlib import ExitStack, redirect_stderr, redirect_stdout
from dataclasses import replace
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import io
import json
import os
from pathlib import Path
import resource
import runpy
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from scripts.repository_paths import SOURCE_ROOT
from ptlinux.command import CommandOutcome, CommandRunner
from ptlinux.application.commands.play import (
game_exit_metadata,
game_failure_reason,
play_locked,
print_game_outcome,
recommended_runtime_comparison,
runtime_issue_summary,
)
from ptlinux.depot import DepotClient, DepotError, DepotFile, DepotStartOption
from ptlinux.diagnostics.native_logs import parse_native_log_lines
from ptlinux.doctor import inspect_host
from ptlinux.game.ptr_compat import PtrCompatibilityError, ptr_bcrypt_dll_overrides, stage_ptr_bcrypt_compatibility
from ptlinux.game.runtime import RuntimeErrorPT, RuntimeSelection, prepare_game_runtime, run_game, run_runtime_prefix_command
from ptlinux.game.runtime_view import prepare_runtime_view
from ptlinux.gamebox import build_gamebox_command
from ptlinux.gui_bootstrap import recover_gui_python, tkinter_path
from ptlinux.locking import managed_game_process_is_active
from ptlinux.paths import AppPaths
from ptlinux.presentation.gamescope import _read_gamescope_version, _supervised_command, _wait_for_optimized_gamescope
from ptlinux.settings import Settings
from ptlinux.telemetry.dependencies import _dependency_health
from ptlinux.telemetry.runtime import _selected_prefix
from ptlinux.telemetry import _resource_usage
class NativeCrashLogTests(unittest.TestCase):
def test_current_client_collision_and_minimap_allocation_failures(self) -> None:
log = r"""
[18:16:32.2832206] [CRIT] - Stage 0x3d1d0020 (not in the map list) has no scene/collision: stage without new stage at X: -10 Z: -110. 0 more lookups in the last minute.
[18:16:38.7429594] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:38.7429983] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:38.7517353] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:38.7517827] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:39.2249996] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:39.2250202] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:39.2271872] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:39.2271993] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
"""
analysis = parse_native_log_lines(log.splitlines())
summary = analysis.to_summary()
self.assertTrue(summary["stage_without_new_stage"])
self.assertTrue(summary["runtime_out_of_memory"])
self.assertEqual(summary["out_of_memory_count"], 4)
self.assertEqual(
summary["out_of_memory_effect_paths"],
[r"game\scripts\shaders\UI\Minimap.fx"],
)
self.assertEqual(
len([event for event in analysis.events if event.kind == "stage_without_new_stage"]),
1,
)
def test_collision_warning_alone_does_not_imply_resource_exhaustion(self) -> None:
analysis = parse_native_log_lines([
"[21:14:40.2199865] [CRIT] - Stage 0x43635c38 (not in the map list) "
"has no scene/collision: stage without new stage at X: -10 Z: -110. "
"0 more lookups in the last minute."
])
self.assertTrue(analysis.stage_without_new_stage)
self.assertFalse(analysis.runtime_out_of_memory)
self.assertFalse(analysis.import_scene_threw)
self.assertFalse(analysis.asset_pack_health_failed)
class ResourceExhaustionGuidanceTests(unittest.TestCase):
def setUp(self) -> None:
self.native_log = {
"runtime_out_of_memory": True,
"out_of_memory_count": 4,
"out_of_memory_effect_paths": [r"game\scripts\shaders\UI\Minimap.fx"],
"stage_without_new_stage": True,
"managed_asset_integrity_failed": False,
}
self.runtime_issues = runtime_issue_summary(
"Game window ready: PristonTale EU V23.86.96\n"
"plugin.so: wrong ELF class: ELFCLASS64\n"
"libavfilter.so.7: cannot open shared object file: No such file or directory\n"
)
def test_proton7_report_keeps_oom_classification_and_suggests_ge(self) -> None:
outcome = CommandOutcome([], 241, "", "", 138.0, False, False)
self.assertEqual(
game_failure_reason(outcome, self.native_log, None, game_exit_metadata(241, "proton7")),
"direct3d_resource_exhaustion",
)
for kind in ("proton7", "proton7-container", "umu"):
with self.subTest(runtime=kind):
self.assertEqual(
recommended_runtime_comparison(
kind, self.runtime_issues, current_game_log=self.native_log, realm="Babel",
),
"ge-proton",
)
def test_oom_evidence_takes_priority_over_secondary_runtime_warnings(self) -> None:
issues = {
**self.runtime_issues,
"gstreamer_stream_error": True,
"dbghelp_symdeferred_assertion_count": 1,
"host_futex_fatal_count": 1,
}
for kind, expected in (("proton7", "ge-proton"), ("proton7-container", "ge-proton"), ("ge-proton", None)):
with self.subTest(runtime=kind):
self.assertEqual(
recommended_runtime_comparison(kind, issues, current_game_log=self.native_log, realm="Babel"),
expected,
)
def test_ptr_and_publisher_realm_alias_keep_the_required_runtime(self) -> None:
for realm in ("PTR Babel", "ptr babel", "Test Babel", "test babel"):
with self.subTest(realm=realm):
self.assertIsNone(recommended_runtime_comparison(
"proton7", self.runtime_issues, current_game_log=self.native_log, realm=realm,
))
def test_plugin_and_collision_warnings_alone_do_not_suggest_a_runtime_switch(self) -> None:
for log in (None, {**self.native_log, "runtime_out_of_memory": False}):
with self.subTest(log=log):
self.assertIsNone(recommended_runtime_comparison(
"proton7", self.runtime_issues, current_game_log=log, realm="Babel",
))
def test_completed_play_reports_the_current_log_recommendation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="proton7")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = self.native_log
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("proton7", "proton", "proton", None)
api.run_game.return_value = CommandOutcome([], 241, "", "", 138.0, False, False)
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
api._game_readiness_signal.return_value = "window_ready"
api._game_exit_metadata.return_value = game_exit_metadata(241, "proton7")
for realm, expected in (("Babel", "ge-proton"), ("PTR Babel", None)):
with self.subTest(realm=realm):
api.current_telemetry.return_value.reset_mock()
api.select_depot_start_option.return_value = DepotStartOption(realm, "Game.exe", "PTEU_REBORN")
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
report = api._emit.call_args.args[0]
self.assertFalse(report["ok"])
self.assertTrue(report["degraded"])
self.assertEqual(report["runtime_issues"].get("recommended_one_variable_comparison"), expected)
exit_event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
self.assertEqual(exit_event.kwargs["metadata"]["failure_reason"], "direct3d_resource_exhaustion")
self.assertEqual(exit_event.kwargs["metadata"]["runtime_issues"].get("recommended_one_variable_comparison"), expected)
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
def test_runtime_advice_is_visible_in_the_launcher_output(self) -> None:
output = io.StringIO()
with redirect_stderr(output):
print_game_outcome(
CommandOutcome([], 241, "", "", 138.0, False, False), None,
{**self.runtime_issues, "recommended_one_variable_comparison": "ge-proton"},
)
self.assertIn("runtime comparison: ge-proton", output.getvalue())
class ResourceUsageScopeTests(unittest.TestCase):
def test_peak_memory_is_explicitly_launcher_rss_and_cpu_is_launcher_time(self) -> None:
with (
patch("ptlinux.telemetry.sys.platform", "linux"),
patch("ptlinux.telemetry.resource.getrusage", return_value=SimpleNamespace(
ru_maxrss=305398, ru_utime=40.0, ru_stime=0.891,
)) as read_usage,
):
self.assertEqual(_resource_usage(), {
"resource_usage_scope": "launcher_process",
"peak_memory_mb": 298.24,
"cpu_time_seconds": 40.891,
})
read_usage.assert_called_once_with(resource.RUSAGE_SELF)
def test_unavailable_process_usage_does_not_emit_a_scope_without_measurements(self) -> None:
with patch("ptlinux.telemetry.resource.getrusage", side_effect=OSError("unavailable")):
self.assertEqual(_resource_usage(), {})
class GuiRecoveryTests(unittest.TestCase):
def test_missing_python_package_is_not_hidden_by_a_present_extension(self) -> None:
def imports(name: str) -> object:
if name == "tkinter":
raise ModuleNotFoundError("No module named 'tkinter'")
return MagicMock()
with patch("ptlinux.gui_bootstrap.import_module", side_effect=imports):
self.assertIsNone(tkinter_path())
def test_broken_tcl_data_is_reported_as_missing_gui_support(self) -> None:
class TclError(Exception):
pass
tk = MagicMock()
tk.Tcl.side_effect = TclError("Can't find a usable init.tcl")
with patch("ptlinux.gui_bootstrap.import_module", return_value=tk):
self.assertIsNone(tkinter_path())
def test_fallback_restarts_the_same_release_and_arguments_with_system_python(self) -> None:
with (
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
patch("ptlinux.gui_bootstrap.Path.is_file", return_value=True),
patch.object(sys, "executable", "/custom/python3"),
patch.dict(os.environ, {"PYTHONHOME": "/custom", "PYTHONPATH": "/old", "DISPLAY": ":0"}, clear=True),
patch("ptlinux.gui_bootstrap.subprocess.run", return_value=SimpleNamespace(returncode=0)) as probe,
patch("ptlinux.gui_bootstrap.os.execve", side_effect=SystemExit(0)) as restart,
self.assertRaises(SystemExit),
):
recover_gui_python(["gui"])
self.assertEqual(probe.call_args.args[0][0], "/usr/bin/python3")
self.assertEqual(restart.call_args.args[1], ["/usr/bin/python3", "-B", "-P", "-m", "ptlinux", "gui"])
env = restart.call_args.args[2]
self.assertNotIn("PYTHONHOME", env)
self.assertEqual(env["DISPLAY"], ":0")
self.assertTrue((Path(env["PYTHONPATH"]) / "ptlinux/__main__.py").is_file())
def test_explicit_interpreter_and_retry_guard_do_not_loop(self) -> None:
for env in ({"PTLINUX_PYTHON": "/custom/python"}, {"PTLINUX_GUI_PYTHON_RETRIED": "1"}):
with (
patch.dict(os.environ, env, clear=True),
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
patch("ptlinux.gui_bootstrap.subprocess.run") as probe,
):
self.assertFalse(recover_gui_python([]))
probe.assert_not_called()
def test_missing_tk_reports_dependency_and_shows_desktop_repair_before_bootstrap(self) -> None:
telemetry = MagicMock()
with (
patch.object(sys, "argv", ["pristontale-eu", "gui"]),
patch("ptlinux.gui_bootstrap.recover_gui_python", return_value=False),
patch("ptlinux.gui_bootstrap.show_gui_dependency_error") as show,
patch("ptlinux.telemetry.initialize_shared_telemetry", return_value=(telemetry, True)),
patch("ptlinux.telemetry.shutdown_shared_telemetry") as shutdown,
patch("ptlinux.umu.provision_umu_for_startup") as bootstrap,
patch("sys.stderr", new_callable=io.StringIO),
self.assertRaises(SystemExit) as stopped,
):
runpy.run_module("ptlinux.__main__", run_name="__main__")
self.assertEqual(stopped.exception.code, 1)
self.assertEqual(telemetry.send_event.call_args.args[0], "dependency_check_failed")
self.assertEqual(telemetry.send_event.call_args.kwargs["metadata"]["dependencies"], ["python-tk"])
telemetry.report_exception.assert_not_called()
bootstrap.assert_not_called()
show.assert_called_once()
shutdown.assert_called_once_with(telemetry)
class RuntimeDependencyTests(unittest.TestCase):
def setUp(self) -> None:
self.stack = ExitStack()
self.addCleanup(self.stack.close)
for target, value in (
("_read_os_release", {"ID": "steamos", "VERSION_ID": "3.8"}),
("_python_tk_path", "tkinter"),
("find_wine_tools", None),
("_umu_path", "/usr/bin/umu-run"),
("inspect_container_host", SimpleNamespace(ready=True, bwrap_path="/usr/bin/bwrap", broken_umu_profile_link=False)),
):
self.stack.enter_context(patch(f"ptlinux.doctor.{target}", return_value=value))
self.stack.enter_context(patch("ptlinux.doctor.shutil.which", side_effect=lambda name, **kw: "/usr/bin/openssl" if name == "openssl" else None))
self.support = {"vulkan_available": True, "vulkan_64bit": True, "vulkan_32bit": True}
def test_steamos_umu_does_not_require_host_wine_or_installation_tools(self) -> None:
for kind in ("umu", "ge-proton"):
with self.subTest(kind=kind):
health = _dependency_health({}, None, {**self.support, "kind": kind})
self.assertTrue(health["healthy"])
self.assertEqual(health["missing_required"], [])
self.assertFalse(health["tools"]["wine"])
self.assertFalse(health["archive_tools_present"])
def test_legacy_prefix_still_requires_tools_until_directx_is_prepared(self) -> None:
for ready in (False, True):
with patch("ptlinux.doctor.native_d3d_ready", return_value=ready):
report = inspect_host({}, vulkan_support=self.support,
game_runtime={"kind": "proton7"}, runtime_prefix=Path("/test/prefix"))
self.assertEqual(report.ok, ready)
missing = {tool.name for tool in report.tools if tool.required and tool.path is None}
self.assertEqual(missing, set() if ready else {"winetricks", "cabextract"})
def test_modern_wow64_uses_64bit_driver_while_umu_keeps_multilib_check(self) -> None:
for kind, expected in (("ge-proton", True), ("umu", False)):
health = _dependency_health({}, None, {**self.support, "kind": kind, "vulkan_32bit": False})
self.assertEqual(health["healthy"], expected)
class LaunchIsolationTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.runtime_view.mkdir(parents=True)
(self.paths.runtime_view / "Game.exe").touch()
self.selection = RuntimeSelection("ge-proton", "/fake/umu-run", None, None)
self.runner = MagicMock(dry_run=False)
self.host = patch("ptlinux.game.runtime._require_container_host")
self.host.start()
self.addCleanup(self.host.stop)
def _bundled_proton_launcher(self, kind: str) -> RuntimeSelection:
proton_directory = self.root / "bundled Proton"
proton_directory.mkdir(exist_ok=True)
(proton_directory / "ptlinux_proton_test_dependency.py").write_text("VALUE = 'bundled'\n")
proton = proton_directory / "proton"
proton.write_text(
"import json, sys\n"
"from ptlinux_proton_test_dependency import VALUE\n"
"print(json.dumps({'dependency': VALUE, 'safe_path': sys.flags.safe_path, "
"'argv': sys.argv[1:]}))\n"
)
umu = self.root / "umu-run"
umu.write_text(
f"#!{sys.executable}\n"
"import os, sys\n"
f"os.execv(sys.executable, [sys.executable, {str(proton)!r}, *sys.argv[1:]])\n"
)
umu.chmod(0o755)
return RuntimeSelection(kind, str(umu), str(proton), str(self.root / "Steam"))
def test_bundled_proton_imports_survive_launcher_safe_path_during_setup(self) -> None:
for kind in ("umu", "ge-proton"):
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
selection = self._bundled_proton_launcher(kind)
runner = CommandRunner(self.root / "commands.jsonl")
with patch("ptlinux.game.runtime.native_d3d_ready", side_effect=[False, True]):
outcomes = prepare_game_runtime(self.paths, selection, runner)
child = json.loads(outcomes[0].stdout)
self.assertEqual(child["dependency"], "bundled")
self.assertFalse(child["safe_path"])
self.assertEqual(child["argv"], ["winetricks", "-q", "d3dx9_43", "d3dcompiler_43"])
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
def test_bundled_proton_imports_survive_safe_path_for_games_and_helpers(self) -> None:
for kind in ("umu", "ge-proton", "proton7-container"):
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
selection = self._bundled_proton_launcher(kind)
runner = CommandRunner(self.root / "commands.jsonl")
helper = run_runtime_prefix_command(self.paths, selection, runner,
["reg.exe", "query"], timeout_seconds=5)
with redirect_stdout(io.StringIO()):
game = run_game(self.paths, Settings(), selection, runner,
use_gamebox=False, timeout_seconds=5)
for outcome in (helper, game):
child = json.loads(outcome.stdout)
self.assertEqual(child["dependency"], "bundled")
self.assertFalse(child["safe_path"])
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
def test_launcher_wrappers_ignore_python_modules_in_the_game_directory(self) -> None:
shadow = self.paths.runtime_view / "ptlinux"
shadow.mkdir()
(shadow / "__init__.py").write_text("raise RuntimeError('game directory shadowed launcher')\n")
command = build_gamebox_command(["umu-run", "Game.exe"],
Settings(allow_window_resizing=True), game_size=(1280, 960), gamescope_path="gamescope")
separator = command.index("--")
command[separator + 1:] = _supervised_command(
command[separator + 1:], self.root / "status", "wayland")
environment = {
**os.environ,
"PYTHONSAFEPATH": "",
"PYTHONPATH": str(SOURCE_ROOT),
}
for module in ("ptlinux.gamescope_runtime", "ptlinux.gamescope_child_runtime", "ptlinux.gamebox_runtime"):
with self.subTest(module=module):
index = command.index(module)
wrapper = command[command.index(sys.executable, index - 3):index + 1]
result = subprocess.run([*wrapper, "--help"], cwd=self.paths.runtime_view,
env=environment, capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("usage:", result.stdout)
def test_gamescope_can_finish_its_shutdown_without_sigkill(self) -> None:
cleanup = self.root / "display-closed"
status = self.root / "status"
status.write_text("0\n")
process = subprocess.Popen(
[sys.executable, "-c",
"import signal\nfrom pathlib import Path\n"
"def stop(*args):\n"
f" Path({str(cleanup)!r}).touch()\n"
" raise SystemExit(0)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"print('ready', flush=True)\n"
"signal.pause()\n"],
stdout=subprocess.PIPE, text=True,
)
try:
assert process.stdout is not None
self.assertEqual(process.stdout.readline().strip(), "ready")
self.assertEqual(_wait_for_optimized_gamescope(process, status), 0)
self.assertEqual(process.returncode, 0)
self.assertTrue(cleanup.exists())
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
if process.stdout is not None:
process.stdout.close()
def test_version_detection_recognizes_the_bundled_gamescope_filename(self) -> None:
banner = (
"[gamescope-3.16.20-ubuntu26.04-amd64] [\x1b[0;34mInfo\x1b[0m] "
"\x1b[0;37mconsole:\x1b[0m gamescope-3.16.20-ubuntu26.04-amd64 "
"version 3.16.20+ds-1 (gcc 15.2.0)\n"
)
with patch("ptlinux.presentation.gamescope.subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout=banner)):
self.assertEqual(_read_gamescope_version("gamescope-custom"), (3, 16, 20))
def test_gamescope_cleanup_timeout_preserves_the_game_failure(self) -> None:
status = self.root / "status"
status.write_text("-11\n")
process = MagicMock()
process.poll.return_value = None
process.wait.side_effect = [subprocess.TimeoutExpired("gamescope", 2), -9]
output = io.StringIO()
with patch("sys.stderr", output):
self.assertEqual(_wait_for_optimized_gamescope(process, status), 139)
process.terminate.assert_called_once_with()
process.kill.assert_called_once_with()
self.assertTrue(runtime_issue_summary(output.getvalue())["gamescope_forced_cleanup"])
def test_gamescope_exit_without_game_status_is_not_reported_as_clean(self) -> None:
status = self.root / "status"
for contents in (None, "", "invalid", "999"):
for compositor_status, expected in ((0, 1), (-11, 139), (247, 247)):
with self.subTest(contents=contents, status=compositor_status):
status.unlink(missing_ok=True)
if contents is not None:
status.write_text(contents)
process = MagicMock()
process.poll.return_value = compositor_status
output = io.StringIO()
with patch("sys.stderr", output):
self.assertEqual(_wait_for_optimized_gamescope(process, status), expected)
issues = runtime_issue_summary(output.getvalue())
self.assertTrue(issues["gamescope_child_status_missing"])
self.assertEqual(issues["gamescope_exit_code"], compositor_status)
process.terminate.assert_not_called()
def test_optional_runtime_warnings_are_distinct_from_presentation_failures(self) -> None:
output = (
"sd_bus_call(g_dbus, message, UINT64_MAX, NULL, &reply)failed with ret -30\n" * 2
+ "No CAP_SYS_NICE, falling back to regular-priority compute and threads.\n"
+ "Proton: Error: unable to use parent for game drive, path \n"
+ "Game window ready: PristonTale EU V23.86.96\n"
)
report = runtime_issue_summary(output)
self.assertEqual(report["gamescope_memory_protection_warning_count"], 2)
self.assertTrue(report["gamescope_priority_fallback"])
self.assertTrue(report["proton_game_drive_warning"])
self.assertTrue(report["game_window_ready"])
self.assertFalse(report["gamescope_child_status_missing"])
self.assertFalse(report["gamescope_forced_cleanup"])
self.assertEqual(report["gamescope_vulkan_output_failed_count"], 0)
def test_actual_child_receives_literal_arguments_prefix_and_renderer(self) -> None:
fake_umu = self.root / "fake umu-run"
fake_umu.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\n"
"print(json.dumps({'argv': sys.argv[1:], 'cwd': os.getcwd(), "
"'prefix': os.environ['WINEPREFIX'], 'renderer': os.environ.get('PROTON_USE_WINED3D'), "
"'verb': os.environ['PROTON_VERB']}))\n"
)
fake_umu.chmod(0o755)
selection = replace(self.selection, executable=str(fake_umu))
arguments = ("NEW_TOKEN", "", r"C:\game data\assets", "$(touch should-not-exist)")
option = DepotStartOption("Babel", "Game.exe", subprocess.list2cmdline(arguments))
runner = CommandRunner(self.root / "commands.jsonl")
with redirect_stdout(io.StringIO()):
outcome = run_game(self.paths, Settings(renderer="wined3d"), selection, runner,
start_option=option, use_gamebox=False, timeout_seconds=5)
child = json.loads(outcome.stdout)
self.assertEqual(child["argv"], [str(self.paths.runtime_view / "Game.exe"), *arguments])
self.assertEqual(child["cwd"], str(self.paths.runtime_view))
self.assertEqual(child["prefix"], str(self.paths.ge_proton_prefix))
self.assertEqual(child["renderer"], "1")
self.assertEqual(child["verb"], "waitforexitandrun")
self.assertFalse((self.paths.runtime_view / "should-not-exist").exists())
def test_changed_renderer_or_sync_cannot_join_an_active_prefix(self) -> None:
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
marker = (self.paths.state / "game-launch.json").read_bytes()
for settings in (Settings(renderer="wined3d"), Settings(proton_sync="esync")):
self.runner.run.reset_mock()
shared = MagicMock()
with self.assertRaisesRegex(RuntimeErrorPT, "close every client"):
run_game(self.paths, settings, self.selection, self.runner,
use_gamebox=False, concurrent_prefix_launch=True, before_start=shared)
self.runner.run.assert_not_called()
shared.assert_not_called()
self.assertEqual((self.paths.state / "game-launch.json").read_bytes(), marker)
def test_preparation_finishes_before_shared_lease_and_spawn(self) -> None:
order = []
def share() -> None:
self.assertTrue((self.paths.state / "game-launch.json").is_file())
order.append("shared")
self.runner.run.side_effect = lambda *a, **kw: order.append("spawn")
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False, before_start=share)
self.assertEqual(order, ["shared", "spawn"])
def test_helpers_join_umu_prefix_without_waiting_for_game_exit(self) -> None:
for kind in ("umu", "ge-proton", "proton7-container"):
selection = RuntimeSelection(kind, "/fake/umu-run", "/Steam/proton", "/Steam")
run_runtime_prefix_command(self.paths, selection, self.runner, ["reg.exe", "query"])
self.assertEqual(self.runner.run.call_args.kwargs["env_overrides"]["PROTON_VERB"], "runinprefix")
def test_missing_selected_prefix_does_not_report_another_prefixs_components(self) -> None:
(self.paths.proton_prefix / "drive_c/windows").mkdir(parents=True)
self.assertIsNone(_selected_prefix(self.paths, {"runtime": {"kind": "ge-proton"}}))
def test_windows_paths_and_long_game_names_are_detected_but_services_are_not(self) -> None:
process = self.root / "proc/1000"
process.mkdir(parents=True)
(process / "environ").write_bytes(f"WINEPREFIX={self.paths.proton_prefix}\0".encode())
(process / "comm").write_text("FutureGameClien\n")
for command, expected in (
(r"C:\Program Files (x86)\Pristontale EU\FutureGameClient.exe", True),
(str(self.paths.runtime_view / "FutureGameClient.exe"), True),
(r"C:\windows\system32\services.exe", False),
(str(self.paths.runtime_view) + "-other/Game.exe", False),
):
(process / "cmdline").write_bytes(command.encode() + b"\0")
self.assertEqual(managed_game_process_is_active(self.paths.runtime_view,
(self.paths.proton_prefix,), process_root=self.root / "proc"), expected)
def test_ptr_dlls_are_removed_before_preparing_a_new_stable_session(self) -> None:
source = self.paths.source_game
for name in ("Game.exe", "game.dll", "game.ini", "game/maps/WhisperingVale.rovug"):
path = source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"future publisher data")
prepare_runtime_view(self.paths, source)
for name in ("bcrypt.dll", "bcrypt_orig.dll"):
(self.paths.runtime_view / name).write_bytes(b"PTR specific DLL")
prepare_runtime_view(self.paths, source)
self.assertFalse((self.paths.runtime_view / "bcrypt.dll").exists())
self.assertFalse((self.paths.runtime_view / "bcrypt_orig.dll").exists())
self.assertEqual((source / "game.dll").read_bytes(), b"future publisher data")
def test_shared_ptr_preparation_checks_bytes_without_replacing_them(self) -> None:
proton = self.root / "proton/proton"
original = proton.parent / "dist/lib/wine/i386-windows/bcrypt.dll"
original.parent.mkdir(parents=True)
proton.touch()
pe = bytearray(128)
pe[:2] = b"MZ"
pe[60:64] = (64).to_bytes(4, "little")
pe[64:68] = b"PE\0\0"
pe[68:70] = (0x14c).to_bytes(2, "little")
pe[88:90] = (0x10b).to_bytes(2, "little")
original.write_bytes(pe)
proxy, target = stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton)
before = (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns)
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
self.assertEqual(before, (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns))
target.write_bytes(b"wrong version")
with self.assertRaises(PtrCompatibilityError):
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
self.assertEqual(target.read_bytes(), b"wrong version")
self.assertEqual(ptr_bcrypt_dll_overrides("*bcrypt,foo=b"), "foo=b;bcrypt=n")
class Response:
def __init__(self, body: bytes) -> None:
self.body = io.BytesIO(body)
self.headers = {"Content-Length": str(len(body))}
self.status = 200
def read(self, size: int = -1) -> bytes:
return self.body.read(size)
def close(self) -> None:
self.body.close()
class DepotRecoveryTests(unittest.TestCase):
def test_manifest_read_timeout_retries_and_closes_failed_response(self) -> None:
failed = MagicMock(headers={})
failed.read.side_effect = TimeoutError("The read operation timed out")
opener = MagicMock(side_effect=[failed, Response(b'{"Build": 42}')])
client = DepotClient(opener=opener, retry_delays=(0,), sleeper=lambda _: None)
self.assertEqual(client._get_json("https://cdn.example/manifest.json"), {"Build": 42})
failed.close.assert_called_once()
self.assertEqual(opener.call_count, 2)
def test_permanent_manifest_error_falls_back_without_repeated_requests(self) -> None:
opener = MagicMock(side_effect=HTTPError("https://cdn.example/manifest.json", 404, "missing", {}, None))
client = DepotClient(opener=opener, retry_delays=(0, 0), sleeper=lambda _: None)
with self.assertRaises(DepotError):
client._get_json("https://cdn.example/manifest.json")
self.assertEqual(opener.call_count, 1)
def test_timeout_cooldown_avoids_failed_mirror_on_following_files_then_expires(self) -> None:
body = b"verified data"
file = DepotFile("file.bin", 42, len(body), hashlib.sha1(body).hexdigest())
urls = []
def open_response(request: Request, *, timeout: float) -> Response:
urls.append(request.full_url)
if len(urls) == 1:
raise TimeoutError("The read operation timed out")
return Response(body)
client = DepotClient(opener=open_response, retry_delays=(0,), sleeper=lambda _: None)
client._candidate_servers = ("primary.example", "secondary.example")
with tempfile.TemporaryDirectory() as temporary, patch("ptlinux.depot.time.monotonic", return_value=100) as clock:
for name in ("first", "second"):
client.download_file("primary.example", file, Path(temporary) / name)
clock.return_value = 161
client.download_file("primary.example", file, Path(temporary) / "third")
self.assertEqual([url.split("/")[2] for url in urls],
["primary.example", "secondary.example", "secondary.example", "primary.example"])
def test_real_http_read_timeout_retains_small_chunk_and_resumes_verified_bytes(self) -> None:
body = b"a small initial block followed by the remaining verified data"
cut = 12
requests = []
recovered = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def do_GET(self) -> None:
requests.append(self.headers.get("Range"))
if len(requests) == 1:
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body[:cut])
self.wfile.flush()
recovered.wait(5)
else:
self.send_response(206)
self.send_header("Content-Length", str(len(body) - cut))
self.send_header("Content-Range", f"bytes {cut}-{len(body)-1}/{len(body)}")
self.end_headers()
self.wfile.write(body[cut:])
recovered.set()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
def local_open(request: Request, *, timeout: float):
return urlopen(Request(f"http://127.0.0.1:{server.server_port}/data",
headers=dict(request.header_items())), timeout=timeout)
client = DepotClient(opener=local_open, timeout_seconds=0.2, retry_delays=(0,), sleeper=lambda _: None)
file = DepotFile("wind_a.TGA", 42, len(body), hashlib.sha1(body).hexdigest())
with tempfile.TemporaryDirectory() as temporary:
destination = Path(temporary) / "wind_a.TGA"
destination.write_bytes(b"previous verified version")
client.download_file("cdn.example", file, destination)
self.assertEqual(destination.read_bytes(), body)
self.assertEqual(requests, [None, f"bytes={cut}-"])
finally:
recovered.set()
server.shutdown()
server.server_close()
thread.join(5)
if __name__ == "__main__":
unittest.main()

1425
tests/test_ptlinux_cli.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,830 @@
import contextlib
import hashlib
import io
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
from unittest import mock
# noinspection PyProtectedMember
from ptlinux.gamescope_runtime import (
_HostFocusPublisher,
_bundled_gamescope_is_usable,
_is_cinnamon_session,
_is_kde_session,
_process_exit_status,
_read_gamescope_version,
_select_host_backend,
_select_wayland_gamescope,
_supervised_command,
_tune_process_window,
_wait_for_optimized_gamescope,
main,
resize_increments,
run_gamescope,
)
from ptlinux.gamebox_runtime import GAMESCOPE_HOST_FOCUS_STATE_ENV
from ptlinux.window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticRecorder,
parse_window_diagnostic,
)
class GamescopeRuntimeTests(unittest.TestCase):
def test_uses_five_percent_resize_steps_with_sensible_bounds(self) -> None:
self.assertEqual(resize_increments(1280, 960), (64, 48))
self.assertEqual(resize_increments(100, 100), (8, 8))
self.assertEqual(resize_increments(7680, 4320), (96, 96))
def test_rejects_invalid_canvas_dimensions(self) -> None:
with self.assertRaisesRegex(ValueError, "positive"):
resize_increments(0, 960)
def test_inserts_selected_backend_before_existing_gamescope_options(self) -> None:
self.assertEqual(
_supervised_command(
["/usr/bin/gamescope", "-w", "1280", "--", "game"],
Path("/tmp/status"),
"wayland",
),
[
"/usr/bin/gamescope",
"--backend",
"wayland",
"-w",
"1280",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
"/tmp/status",
"--",
"game",
],
)
def test_can_force_composition_for_an_unsafe_wayland_build(self) -> None:
command = _supervised_command(
["gamescope", "--", "game"],
Path("/tmp/status"),
"wayland",
force_composition=True,
)
self.assertEqual(command[:4], [
"gamescope",
"--backend",
"wayland",
"--force-composition",
])
def test_reads_the_gamescope_release_tuple(self) -> None:
completed = mock.Mock(
returncode=0,
stdout="[gamescope] gamescope version 3.16.20+ds-1 (gcc 15.2.0)\n"
)
with mock.patch(
"ptlinux.gamescope_runtime.subprocess.run",
return_value=completed,
) as run:
self.assertEqual(
_read_gamescope_version("/usr/games/gamescope"),
(3, 16, 20),
)
run.assert_called_once_with(
["/usr/games/gamescope", "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=1.0,
check=False,
)
def test_selects_upstream_or_launcher_local_wayland_backing_fix(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
side_effect=[(3, 16, 28), (3, 16, 20)],
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=Path("/opt/ptlinux/gamescope"),
):
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/usr/games/gamescope", False, False),
)
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/opt/ptlinux/gamescope", False, True),
)
def test_unsafe_wayland_build_uses_composed_fallback(self) -> None:
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 20),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=False,
),
):
self.assertEqual(
_select_wayland_gamescope("gamescope"),
("gamescope", True, False),
)
def test_kubuntu_keeps_every_native_wayland_build_fully_composed(self) -> None:
environment = {
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
}
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 28),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
) as bundled_gamescope,
):
self.assertEqual(
_select_wayland_gamescope(
"/usr/games/gamescope",
environment=environment,
),
("/usr/games/gamescope", True, False),
)
self.assertTrue(_is_kde_session(environment))
bundled_gamescope.assert_not_called()
def test_recognizes_cinnamon_session_names(self) -> None:
self.assertTrue(
_is_cinnamon_session(
{
"XDG_CURRENT_DESKTOP": "X-Cinnamon:GNOME",
"DESKTOP_SESSION": "cinnamon2d",
}
)
)
self.assertFalse(_is_cinnamon_session({"XDG_CURRENT_DESKTOP": "GNOME"}))
def test_launcher_local_gamescope_matches_its_pinned_digest(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
):
self.assertTrue(_bundled_gamescope_is_usable())
def test_repairs_a_non_executable_verified_launcher_local_gamescope(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
payload = b"verified launcher-local gamescope"
executable.write_bytes(payload)
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
hashlib.sha256(payload).hexdigest(),
),
):
self.assertTrue(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o755)
def test_does_not_make_an_unverified_launcher_local_gamescope_executable(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
executable.write_bytes(b"unexpected contents")
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
"0" * 64,
),
):
self.assertFalse(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o644)
def test_selects_wayland_only_for_an_accessible_socket(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
socket_path = Path(temporary) / "wayland-test"
with socket.socket(socket.AF_UNIX) as wayland_socket:
wayland_socket.bind(str(socket_path))
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": "missing-wayland-socket",
}
),
"sdl",
)
def test_maps_signals_to_a_shell_exit_status(self) -> None:
self.assertEqual(_process_exit_status(-6), 134)
self.assertEqual(_process_exit_status(7), 7)
def test_tunes_resize_steps_and_keeps_the_host_window_composited(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.return_value = True
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock()
process.pid = 1234
process.poll.return_value = None
publisher = mock.Mock()
self.assertEqual(
_tune_process_window(
mapper,
process,
1280,
960,
host_focus_publisher=publisher,
),
(64, 48),
)
self.assertEqual(publisher.update.call_args_list, [
mock.call(mapper, 41),
mock.call(mapper, 41),
])
mapper.enable_window_scaling.assert_called_once_with(41, confirm=False)
mapper.window_allows_scaling.assert_called_once_with(41)
mapper.set_resize_increments.assert_called_once_with(
41,
(1280, 960),
(64, 48),
)
mapper.prefer_composited_presentation.assert_called_once_with(41)
def test_tuning_retains_hints_while_muffin_refreshes_its_frame(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.side_effect = [False, True]
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock(pid=1234)
process.poll.return_value = None
with (
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[0.0, 0.0, 0.1],
),
mock.patch("ptlinux.gamescope_runtime.time.sleep") as sleep,
):
self.assertEqual(
_tune_process_window(mapper, process, 1280, 960),
(64, 48),
)
mapper.find_window_by_pid.assert_called_once_with(1234)
self.assertEqual(
mapper.enable_window_scaling.call_args_list,
[
mock.call(41, confirm=False),
mock.call(41, confirm=False),
],
)
sleep.assert_called_once_with(0.05)
def test_host_focus_publisher_tracks_active_and_minimized_state(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.return_value = 1
mapper.window_is_active.return_value = True
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.window_state.return_value = 3
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
finally:
state_path = publisher.path
publisher.close()
self.assertFalse(state_path.exists())
def test_host_focus_publisher_preserves_an_inactive_minimized_remap(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.3, 10.4],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1, 1]
mapper.window_is_active.side_effect = [True, False, True]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
mapper.minimize_window.assert_called_once_with(41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.minimize_window.assert_called_once_with(41)
finally:
publisher.close()
def test_host_focus_diagnostics_capture_the_minimize_remap_cycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
environment = {
"XDG_RUNTIME_DIR": temporary,
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "b" * 32,
}
output = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.4],
),
contextlib.redirect_stderr(output),
):
diagnostics = WindowDiagnosticRecorder(
"gamescope_host",
environment=environment,
monotonic=lambda: 20.0,
)
publisher = _HostFocusPublisher.create(diagnostics=diagnostics)
summary: dict[str, object] = {}
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1]
mapper.window_is_active.side_effect = [True, False]
mapper.minimize_window.return_value = True
mapper.window_diagnostic_snapshot.return_value = {
"wm_state": "normal",
"allows_resize": True,
"allows_maximize_horizontal": False,
"allows_maximize_vertical": False,
}
mapper.window_net_wm_state_flags.side_effect = [
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": True,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
summary = publisher.summary_details()
finally:
publisher.close()
diagnostics.close(**summary)
payloads = [
parse_window_diagnostic(line)
for line in output.getvalue().splitlines()
]
diagnostics_by_name = {
payload["diagnostic"]: payload
for payload in payloads
if payload is not None
}
self.assertIn("host_minimize_latched", diagnostics_by_name)
self.assertIn("host_hidden_state_cleared", diagnostics_by_name)
remap = diagnostics_by_name["host_inactive_remap"]
details = remap["details"]
assert isinstance(details, dict)
self.assertTrue(details["minimize_request_accepted"])
self.assertFalse(details["allows_maximize_horizontal"])
self.assertEqual(summary["inactive_remaps"], 1)
self.assertEqual(summary["hidden_state_clears"], 1)
self.assertEqual(summary["reiconify_accepted"], 1)
def test_optimized_wait_refreshes_host_focus_before_returning(self) -> None:
process = mock.Mock(pid=1234)
process.poll.return_value = 0
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
publisher = mock.Mock()
self.assertEqual(
_wait_for_optimized_gamescope(
process,
None,
host_focus_publisher=publisher,
host_mapper=mapper,
),
1,
)
publisher.update.assert_called_once_with(mapper, 41)
def test_optimized_wait_returns_the_nested_child_status(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
status_file = Path(temporary) / "status"
status_file.write_text("0\n", encoding="ascii")
process = mock.Mock()
process.poll.return_value = None
self.assertEqual(_wait_for_optimized_gamescope(process, status_file), 0)
process.terminate.assert_called_once_with()
process.kill.assert_not_called()
process.wait.assert_called_once_with(timeout=2.0)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_tunes_the_window_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
mapper = open_host_x11.return_value
process = popen.return_value
process.poll.return_value = 7
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=7,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 7)
self.assertEqual(
popen.call_args.args[0],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertEqual(popen.call_args.kwargs["env"]["SDL_VIDEODRIVER"], "x11")
self.assertEqual(
popen.call_args.kwargs["env"][
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"
],
"0",
)
self.assertIn(
GAMESCOPE_HOST_FOCUS_STATE_ENV,
popen.call_args.kwargs["env"],
)
tune_process_window.assert_called_once_with(
mapper,
process,
1280,
960,
host_focus_publisher=mock.ANY,
)
mapper.close.assert_called_once_with()
self.assertIn("64x48", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11", return_value=None)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_disables_nested_focus_repair_without_host_monitor(
self,
_select_host_backend: mock.Mock,
_open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(io.StringIO()),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
self.assertEqual(
run_gamescope(["gamescope", "--", "game"], 1280, 960),
0,
)
self.assertEqual(
popen.call_args.kwargs["env"][GAMESCOPE_HOST_FOCUS_STATE_ENV],
"unavailable",
)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_wayland_gamescope",
return_value=("/opt/ptlinux/gamescope", False, True),
)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="wayland",
)
def test_wayland_path_avoids_sdl_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
select_wayland_gamescope: mock.Mock,
open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0],
[
"/opt/ptlinux/gamescope",
"--backend",
"wayland",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertNotIn("SDL_VIDEODRIVER", popen.call_args.kwargs["env"])
self.assertNotIn(
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR",
popen.call_args.kwargs["env"],
)
self.assertEqual(
popen.call_args.kwargs["env"][
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
],
"1",
)
open_host_x11.assert_not_called()
select_wayland_gamescope.assert_called_once_with(
"gamescope",
environment=mock.ANY,
)
self.assertIn("native Wayland", stderr.getvalue())
self.assertIn("lazy-output", stderr.getvalue())
self.assertIn("login-window visibility", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_kubuntu_wayland_uses_xwayland_presentation_and_reports_policy(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
environment = {
"WAYLAND_DISPLAY": "wayland-0",
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "c" * 32,
}
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
contextlib.redirect_stderr(stderr),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0][:4],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
],
)
self.assertNotIn(
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE",
popen.call_args.kwargs["env"],
)
open_host_x11.assert_called_once_with()
tune_process_window.assert_called_once()
self.assertIn("KDE Wayland compatibility mode active", stderr.getvalue())
payloads = [
parse_window_diagnostic(line)
for line in stderr.getvalue().splitlines()
]
runtime_configured = next(
payload
for payload in payloads
if payload is not None and payload["diagnostic"] == "runtime_configured"
)
details = runtime_configured["details"]
assert isinstance(details, dict)
self.assertTrue(details["kde_session"])
self.assertTrue(details["kde_wayland_compatibility_mode"])
self.assertTrue(details["kde_wayland_xwayland_fallback"])
self.assertEqual(
details["backend_reason"],
"kde_wayland_xwayland_compatibility",
)
self.assertEqual(details["presentation_mode"], "sdl_composed")
def test_main_reports_an_empty_command(self) -> None:
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
status = main(["--width", "1280", "--height", "960"])
self.assertEqual(status, 1)
self.assertIn("cannot be empty", stderr.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,866 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.desktop_entry import escape_icon_path, quote_exec_argument
from ptlinux.launcher_wrapper import quote_shell_word
from scripts.repository_paths import PROJECT_ROOT, SOURCE_ROOT
from tools.sign_release_artifact import sign_release_artifact
class AioPackagingTests(unittest.TestCase):
def test_bootstrap_release_remains_reproducible_with_a_fixed_epoch(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
outputs = (root / "first", root / "second")
for output in outputs:
environment = os.environ.copy()
environment.pop("PTLINUX_VERSION", None)
environment.update(
{
"PTLINUX_OUTPUT_DIRECTORY": str(output),
"PTLINUX_PYTHON": sys.executable,
"SOURCE_DATE_EPOCH": "0",
}
)
built = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(built.returncode, 0, built.stderr)
first = {
path.name: path.read_bytes()
for path in outputs[0].iterdir()
if path.is_file()
}
second = {
path.name: path.read_bytes()
for path in outputs[1].iterdir()
if path.is_file()
}
self.assertEqual(first, second)
def test_packaged_installer_uses_the_compatible_top_level_source_layout(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
output = root / "dist"
build_environment = os.environ.copy()
build_environment.pop("PTLINUX_VERSION", None)
build_environment.update(
{
"PTLINUX_OUTPUT_DIRECTORY": str(output),
"PTLINUX_PYTHON": sys.executable,
"SOURCE_DATE_EPOCH": "0",
}
)
built = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=build_environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(built.returncode, 0, built.stderr)
release_name = (
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
)
archive = output / f"{release_name}.tar.gz"
extracted = root / "extracted"
with tarfile.open(archive, "r:gz") as bundle:
bundle.extractall(extracted, filter="data")
packaged_root = extracted / release_name
self.assertTrue((packaged_root / "ptlinux").is_dir())
self.assertFalse((packaged_root / "src").exists())
home = root / "home"
data_home = root / "desktop-data"
install_directory = root / "managed-install"
install_environment = os.environ.copy()
install_environment.pop("PTLINUX_INSTALL_DIR", None)
install_environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
installed = subprocess.run(
[
"bash",
str(packaged_root / "install.sh"),
"--install-dir",
str(install_directory),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=packaged_root,
env=install_environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(installed.returncode, 0, installed.stderr)
current = install_directory / "app/current"
self.assertTrue((current / "ptlinux/cli.py").is_file())
self.assertTrue((current / "ptdiag/logs.py").is_file())
def test_installer_dry_run_reports_destinations_without_writing(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "desktop-data"
install_directory = root / "managed-install"
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--install-dir",
str(install_directory),
"--dry-run",
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(f"Install data: {install_directory}", result.stdout)
self.assertIn("Skipping host dependency installation.", result.stdout)
self.assertFalse(install_directory.exists())
self.assertFalse(home.exists())
self.assertFalse(data_home.exists())
def test_installer_writes_path_independent_desktop_entry(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home with spaces"
data_home = root / "data with spaces"
home.mkdir()
fake_bin = root / "bin"
fake_bin.mkdir()
fake_umu = fake_bin / "umu-run"
fake_umu.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
fake_umu.chmod(0o755)
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": f"{fake_bin}:/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--skip-deps",
"--skip-doctor",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(fake_umu.resolve()), result.stdout)
self.assertIn(
f"Note: {home / '.local/bin'} is not in PATH for this shell.",
result.stdout,
)
output_lines = result.stdout.splitlines()
start_line = output_lines[output_lines.index("Start PristonTale EU with:") + 1]
self.assertEqual(
shlex.split(start_line),
[str(home / ".local/bin/pristontale-eu"), "gui"],
)
self.assertIn("export PATH=", result.stdout)
default_install = home / "Games/PristontaleEU"
self.assertTrue((default_install / "app/current/ptlinux").is_dir())
self.assertTrue((default_install / "app/current/ptlinux/umu.py").is_file())
self.assertTrue(
(default_install / "app/current/ptlinux/ui/dashboard.py").is_file()
)
self.assertTrue(
(
default_install
/ "app/current/ptlinux/ui/viewmodels/downloader.py"
).is_file()
)
self.assertTrue(
(default_install / "app/current/ptlinux/native/pt-identity-probe.exe").is_file()
)
self.assertTrue(
(default_install / "app/current/ptlinux/native/ptr-bcrypt-proxy.dll").is_file()
)
gamescope = (
default_install
/ "app/current/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertTrue(gamescope.is_file())
self.assertEqual(gamescope.stat().st_mode & 0o777, 0o755)
self.assertFalse(
(default_install / "app/current/ptlinux/desktop_entry.py").exists()
)
self.assertFalse(
(default_install / "app/current/ptlinux/launcher_wrapper.py").exists()
)
self.assertFalse(
(default_install / "app/current/ptlinux/world_fix.py").exists()
)
self.assertTrue((default_install / "app/current").is_symlink())
self.assertEqual(
(default_install / "app/current").resolve().stat().st_mode & 0o777,
0o755,
)
launcher = home / ".local/bin/pristontale-eu"
desktop = data_home / "applications/eu.pristontale.PTLinux.desktop"
icon = data_home / "pixmaps/eu.pristontale.PTLinux.png"
self.assertTrue(launcher.is_file())
self.assertEqual(
icon.read_bytes(),
(PROJECT_ROOT / "packaging/eu.pristontale.PTLinux.png").read_bytes(),
)
entry = desktop.read_text(encoding="utf-8")
exec_line = next(line for line in entry.splitlines() if line.startswith("Exec="))
icon_line = next(line for line in entry.splitlines() if line.startswith("Icon="))
self.assertEqual(
exec_line,
f"Exec={quote_exec_argument(str(launcher))} gui",
)
self.assertEqual(icon_line, f"Icon={escape_icon_path(str(icon))}")
self.assertIn("StartupWMClass=PristonTaleEU", entry)
self.assertNotIn("TryExec=", entry)
self.assertIn(
f"install_directory={quote_shell_word(str(default_install))}",
launcher.read_text(encoding="utf-8"),
)
self.assertIn(
'exec "$python_bin" -B -P -m ptlinux',
launcher.read_text(encoding="utf-8"),
)
arguments = shlex.split(exec_line.removeprefix("Exec="))
self.assertEqual(arguments, [str(launcher), "gui"])
arguments[1] = "--help"
launched = subprocess.run(
arguments,
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(launched.returncode, 0, launched.stderr)
self.assertIn("PristonTale EU all-in-one", launched.stdout)
stale_directory = root / "unpacked-old-installer"
stale_package = stale_directory / "ptlinux"
stale_package.mkdir(parents=True)
(stale_package / "__init__.py").write_text("", encoding="utf-8")
(stale_package / "__main__.py").write_text(
'print("STALE LAUNCHER WAS IMPORTED")\n',
encoding="utf-8",
)
isolated = subprocess.run(
[launcher, "--version"],
cwd=stale_directory,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(isolated.returncode, 0, isolated.stderr)
self.assertEqual(isolated.stdout.strip(), f"pristontale-eu {__version__}")
self.assertNotIn("STALE LAUNCHER", isolated.stdout)
def test_installer_persists_custom_install_directory(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "desktop data"
install_directory = root / "External $(touch INJECTED)'s Game"
home.mkdir()
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--install-dir",
str(install_directory),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((install_directory / "app/current/ptlinux").is_dir())
self.assertTrue((install_directory / "app/current").is_symlink())
launcher = home / ".local/bin/pristontale-eu"
wrapper = launcher.read_text(encoding="utf-8")
self.assertIn(
f"install_directory={quote_shell_word(str(install_directory))}",
wrapper,
)
runtime_environment = environment.copy()
runtime_environment.pop("PTLINUX_INSTALL_DIR", None)
runtime_environment["XDG_DATA_HOME"] = str(root / "different data home")
launched = subprocess.run(
[str(launcher), "--help"],
cwd=root,
env=runtime_environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(launched.returncode, 0, launched.stderr)
self.assertIn("PristonTale EU all-in-one", launched.stdout)
self.assertFalse((root / "INJECTED").exists())
def test_installer_preserves_the_previous_default_on_upgrade(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "data"
legacy_install = data_home / "pristontale-eu"
legacy_game = (
legacy_install
/ "launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe"
)
legacy_game.parent.mkdir(parents=True)
legacy_game.write_bytes(b"existing managed game")
legacy_package = legacy_install / "app/ptlinux"
legacy_package.mkdir(parents=True)
(legacy_package / "__init__.py").write_text(
'__version__ = "0.4.7"\n',
encoding="utf-8",
)
settings_file = home / ".config/pristontale-eu/settings.json"
settings_file.parent.mkdir(parents=True)
settings_file.write_text(
json.dumps(
{
"borderless": False,
"runtime": "proton7",
"proton_sync": "esync",
}
),
encoding="utf-8",
)
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("preserving its previous data location", result.stdout)
launcher = home / ".local/bin/pristontale-eu"
self.assertIn(
f"install_directory={quote_shell_word(str(legacy_install))}",
launcher.read_text(encoding="utf-8"),
)
self.assertFalse((home / "Games/PristontaleEU/app").exists())
self.assertTrue((legacy_install / "app/current").is_symlink())
settings_result = subprocess.run(
[launcher, "--json", "settings", "--init"],
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(settings_result.returncode, 0, settings_result.stderr)
migrated = json.loads(settings_result.stdout)["settings"]
self.assertFalse(migrated["borderless"])
self.assertEqual(migrated["runtime"], "ge-proton")
self.assertEqual(migrated["proton_sync"], "esync")
persisted = json.loads(settings_file.read_text(encoding="utf-8"))
self.assertEqual(persisted["runtime"], "ge-proton")
self.assertIs(persisted["ge_proton_default_migrated"], True)
def test_production_release_requires_a_signing_key(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment.pop("PTLINUX_SIGNING_KEY", None)
environment["PTLINUX_RELEASE_MODE"] = "production"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("PTLINUX_SIGNING_KEY is required", result.stderr)
self.assertFalse(output.exists())
def test_production_release_rejects_a_key_path_inside_the_checkout(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment["PTLINUX_RELEASE_MODE"] = "production"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
environment["PTLINUX_PYTHON"] = sys.executable
environment["PTLINUX_SIGNING_KEY"] = str(
PROJECT_ROOT / "release-keys/fixture-private.pem"
)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("outside the repository", result.stderr)
self.assertFalse(output.exists())
def test_release_artifact_signature_is_generated_and_verified(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
private_key = root / "private.pem"
public_key = root / "public.pem"
artifact = root / "installer.tar.gz"
artifact.write_bytes(b"release bytes")
commands = (
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
)
for command in commands:
generated = subprocess.run(
command,
capture_output=True,
text=True,
timeout=20,
check=False,
)
self.assertEqual(generated.returncode, 0, generated.stderr)
private_key.chmod(0o600)
signature = sign_release_artifact(
artifact,
signing_key=private_key,
public_key=public_key,
)
self.assertTrue(signature.is_file())
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
artifact.write_bytes(b"tampered release bytes")
verification = subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-verify",
str(public_key),
"-signature",
str(signature),
str(artifact),
],
capture_output=True,
text=True,
timeout=20,
check=False,
)
self.assertNotEqual(verification.returncode, 0)
def test_release_builder_packages_current_updater_sources(self) -> None:
self.assertIn(
f'version = "{__version__}"',
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"),
)
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory)
environment = os.environ.copy()
environment.pop("PTLINUX_VERSION", None)
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
environment["SOURCE_DATE_EPOCH"] = "0"
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
release_name = (
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
)
archive = output / f"{release_name}.tar.gz"
checksum = archive.with_suffix(archive.suffix + ".sha256")
self.assertTrue(archive.is_file())
self.assertTrue(checksum.is_file())
self.assertFalse(Path(f"{archive}.sig").exists())
self.assertTrue((output / "launcher-update-public.pem").is_file())
zip_archive = output / f"{release_name}.zip"
self.assertTrue(zip_archive.is_file())
self.assertFalse(Path(f"{zip_archive}.sig").exists())
update_archive = output / f"pristontale-eu-linux-update-v{__version__}.tar.gz"
self.assertTrue(update_archive.is_file())
update_checksum = update_archive.with_suffix(update_archive.suffix + ".sha256")
self.assertTrue(update_checksum.is_file())
manifest = output / "stable.json"
self.assertTrue(manifest.is_file())
for artifact in (
archive,
checksum,
update_archive,
update_checksum,
manifest,
):
self.assertEqual(artifact.stat().st_mode & 0o777, 0o644)
expected_sha256 = checksum.read_text(encoding="utf-8").split()[0]
self.assertEqual(
hashlib.sha256(archive.read_bytes()).hexdigest(),
expected_sha256,
)
release_root = release_name
with tarfile.open(archive, "r:gz") as release:
members = release.getmembers()
names = {member.name for member in members}
self.assertTrue(
all(member.mode & 0o022 == 0 for member in members),
"release entries must not be group- or world-writable",
)
self.assertEqual(
next(member.mode for member in members if member.name == release_root),
0o755,
)
self.assertEqual(
{name for name in names if name.endswith(".md")},
{f"{release_root}/README.md"},
)
self.assertIn(f"{release_root}/ptlinux/desktop_entry.py", names)
self.assertIn(f"{release_root}/ptlinux/install_status.py", names)
self.assertIn(f"{release_root}/ptlinux/ui/dashboard.py", names)
self.assertIn(
f"{release_root}/ptlinux/ui/components/progress_panel.py",
names,
)
self.assertIn(f"{release_root}/ptlinux/launcher_wrapper.py", names)
self.assertIn(f"{release_root}/ptlinux/self_update.py", names)
self.assertIn(f"{release_root}/ptlinux/container_host.py", names)
self.assertIn(f"{release_root}/release/config.json", names)
self.assertIn(f"{release_root}/scripts/__init__.py", names)
self.assertIn(f"{release_root}/scripts/repository_paths.py", names)
self.assertIn(f"{release_root}/scripts/release_lib.py", names)
self.assertFalse(any(name.startswith(f"{release_root}/devtools/") for name in names))
self.assertIn(
f"{release_root}/ptlinux/launcher-update-public.pem",
names,
)
self.assertIn(
f"{release_root}/ptlinux/native/pt-identity-probe.exe",
names,
)
self.assertIn(
f"{release_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
names,
)
gamescope_name = (
f"{release_root}/ptlinux/native/"
"gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertIn(gamescope_name, names)
self.assertEqual(
next(member.mode for member in members if member.name == gamescope_name),
0o755,
)
for name in (
"gamescope-compat.README",
"gamescope-compat.copyright",
"gamescope-lazy-output-images.patch",
"gamescope-wayland-live-resize.patch",
"gamescope-wayland-letterbox-backing.patch",
"gamescope-wayland-libdecor-input-scope.patch",
"gamescope-keep-nested-windows-visible.patch",
):
self.assertIn(f"{release_root}/ptlinux/native/{name}", names)
self.assertNotIn(
f"{release_root}/ptlinux/native/pt-world-fix.exe",
names,
)
self.assertNotIn(f"{release_root}/ptlinux/world_fix.py", names)
archived_readme = release.extractfile(f"{release_root}/README.md")
self.assertIsNotNone(archived_readme)
assert archived_readme is not None
readme = archived_readme.read().decode("utf-8")
self.assertIn(release_name, readme)
self.assertIn("./install.sh", readme)
self.assertIn("pristontale-eu update --repair", readme)
self.assertIn("pristontale-eu --help", readme)
for command in (
"gui",
"doctor",
"container-host",
"fetch",
"setup",
"update",
"sync",
"wine-update",
"import-game",
"cache-compatibility",
"prepare",
"play",
"settings",
"launcher-update",
):
self.assertIn(f"pristontale-eu {command}", readme)
archived_depot = release.extractfile(f"{release_root}/ptlinux/depot.py")
self.assertIsNotNone(archived_depot)
assert archived_depot is not None
self.assertEqual(
archived_depot.read(),
(SOURCE_ROOT / "ptlinux/depot.py").read_bytes(),
)
archived_login_trace = release.extractfile(
f"{release_root}/ptlinux/login_trace.py"
)
self.assertIsNotNone(archived_login_trace)
assert archived_login_trace is not None
login_trace_source = archived_login_trace.read().decode("utf-8")
self.assertIn("LOGIN_TRACE_ENABLED = False", login_trace_source)
self.assertNotIn("+relay", login_trace_source)
self.assertNotIn("RelayInclude", login_trace_source)
with tarfile.open(update_archive, "r:gz") as update:
update_root = f"pristontale-eu-linux-update-v{__version__}"
update_members = update.getmembers()
update_names = {member.name for member in update_members}
self.assertIn(f"{update_root}/release.json", update_names)
self.assertIn(f"{update_root}/ptlinux/self_update.py", update_names)
self.assertIn(f"{update_root}/ptlinux/container_host.py", update_names)
self.assertIn(f"{update_root}/ptlinux/import_state.py", update_names)
self.assertIn(f"{update_root}/ptlinux/ui/dashboard.py", update_names)
self.assertIn(
f"{update_root}/ptlinux/ui/dialogs/settings_dialog.py",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/desktop_entry.py",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/launcher_wrapper.py",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/launcher-update-public.pem",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/native/pt-identity-probe.exe",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
update_names,
)
gamescope_name = (
f"{update_root}/ptlinux/native/"
"gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertIn(gamescope_name, update_names)
self.assertEqual(
next(
member.mode
for member in update_members
if member.name == gamescope_name
),
0o755,
)
for name in (
"gamescope-compat.README",
"gamescope-compat.copyright",
"gamescope-lazy-output-images.patch",
"gamescope-wayland-live-resize.patch",
"gamescope-wayland-letterbox-backing.patch",
"gamescope-wayland-libdecor-input-scope.patch",
"gamescope-keep-nested-windows-visible.patch",
):
self.assertIn(
f"{update_root}/ptlinux/native/{name}",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/native/pt-world-fix.exe",
update_names,
)
self.assertNotIn(f"{update_root}/ptlinux/world_fix.py", update_names)
def test_release_builder_rejects_unsafe_version_text(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment["PTLINUX_VERSION"] = "../unsafe"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("expected X.Y.Z", result.stderr)
self.assertFalse(output.exists())
def test_compact_release_can_import_overlay_in_clean_interpreter(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
release_root = Path(temporary_directory)
shutil.copytree(SOURCE_ROOT / "ptlinux", release_root / "ptlinux")
(release_root / "ptdiag").mkdir()
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "__init__.py",
release_root / "ptdiag" / "__init__.py",
)
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "assbin.py",
release_root / "ptdiag" / "assbin.py",
)
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "logs.py",
release_root / "ptdiag" / "logs.py",
)
environment = os.environ.copy()
environment["PYTHONPATH"] = str(release_root)
result = subprocess.run(
[sys.executable, "-c", "import ptlinux.overlay"],
cwd=release_root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import contextlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.window_diagnostics import (
WINDOW_DIAGNOSTIC_EVENT_LIMIT,
WINDOW_DIAGNOSTIC_PREFIX,
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticLog,
WindowDiagnosticRecorder,
parse_window_diagnostic,
)
class WindowDiagnosticTests(unittest.TestCase):
def test_local_log_keeps_bounded_samples_and_summaries_from_this_session(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "logs" / "windows-session.jsonl"
log = WindowDiagnosticLog(path, "a" * 32)
for component in ("game_window", "gamescope_host"):
payload = {
"component": component,
"session_id": "a" * 32,
"diagnostic": "focus_state_changed",
}
log.record({**payload, "session_id": "b" * 32})
for sequence in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 10):
log.record({**payload, "sequence": sequence + 1})
for _ in range(2):
log.record({**payload, "diagnostic": "session_summary"})
payloads = [json.loads(line) for line in path.read_text().splitlines()]
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual({item["session_id"] for item in payloads}, {"a" * 32})
for component in ("game_window", "gamescope_host"):
records = [item for item in payloads if item["component"] == component]
self.assertEqual(len(records), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
self.assertEqual(records[-1]["diagnostic"], "session_summary")
def test_local_log_io_failure_warns_once_without_interrupting_play(self) -> None:
log = WindowDiagnosticLog(Path("/unused/windows-session.jsonl"), "a" * 32)
payload = {"component": "game_window", "session_id": "a" * 32, "diagnostic": "focus"}
output = io.StringIO()
with (
patch("ptlinux.window_diagnostics.Path.mkdir", side_effect=OSError("disk full")) as mkdir,
contextlib.redirect_stderr(output),
):
log.record(payload)
log.record(payload)
mkdir.assert_called_once()
self.assertEqual(output.getvalue().count("could not be saved locally"), 1)
def test_recorder_bounds_samples_and_always_emits_a_summary(self) -> None:
environment = {
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "a" * 32,
}
output = io.StringIO()
with contextlib.redirect_stderr(output):
recorder = WindowDiagnosticRecorder(
"gamescope_host",
environment=environment,
monotonic=lambda: 10.0,
)
for index in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8):
recorder.record("host_inactive_remap", attempt=index)
recorder.close(minimized_latched=True)
payloads = [
parse_window_diagnostic(line)
for line in output.getvalue().splitlines()
]
self.assertNotIn(None, payloads)
self.assertEqual(len(payloads), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
summary = payloads[-1]
assert summary is not None
self.assertEqual(summary["diagnostic"], "session_summary")
self.assertEqual(summary["session_id"], "a" * 32)
details = summary["details"]
assert isinstance(details, dict)
self.assertEqual(
details["counts"],
{"host_inactive_remap": WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8},
)
self.assertEqual(details["sampled_events"], WINDOW_DIAGNOSTIC_EVENT_LIMIT)
self.assertEqual(details["suppressed_events"], 8)
def test_transition_records_only_state_changes(self) -> None:
output = io.StringIO()
with contextlib.redirect_stderr(output):
recorder = WindowDiagnosticRecorder(
"game_window",
environment={WINDOW_DIAGNOSTICS_ENV: "true"},
monotonic=lambda: 1.0,
)
recorder.transition("focus", (False, False), "focus_state_changed")
recorder.transition("focus", (False, False), "focus_state_changed")
recorder.transition("focus", (True, True), "focus_state_changed")
recorder.close()
payloads = [
json.loads(line.removeprefix(WINDOW_DIAGNOSTIC_PREFIX))
for line in output.getvalue().splitlines()
]
self.assertEqual(
[payload["diagnostic"] for payload in payloads],
["focus_state_changed", "focus_state_changed", "session_summary"],
)
self.assertEqual(payloads[-1]["details"]["counts"], {"focus_state_changed": 2})
def test_parser_rejects_untrusted_or_malformed_records(self) -> None:
self.assertIsNone(parse_window_diagnostic("ordinary game output\n"))
self.assertIsNone(
parse_window_diagnostic(
WINDOW_DIAGNOSTIC_PREFIX
+ '{"schema":1,"component":"other","diagnostic":"state",'
+ '"sequence":1,"elapsed_ms":0,"details":{}}\n'
)
)
self.assertIsNone(
parse_window_diagnostic(
WINDOW_DIAGNOSTIC_PREFIX + "{not-json}\n"
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,514 @@
"""Focus ownership and supervisor failure-path regressions (no live desktop)."""
from contextlib import ExitStack
import ctypes
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.presentation import game_window as gw
class Clock:
def __init__(self) -> None:
self.now = 0.0
def sleep(self, seconds: float) -> None:
self.now += seconds
class WindowFocusRegressions(unittest.TestCase):
def setUp(self) -> None:
self.context = self.enterContext(ExitStack())
self.clock = Clock()
self.context.enter_context(patch.object(gw.time, "sleep", self.clock.sleep))
self.context.enter_context(patch.object(gw.time, "monotonic", lambda: self.clock.now))
self.mapper = MagicMock(spec=gw._X11)
self.mapper.window_state.return_value = 1
self.mapper.window_is_active.return_value = True
self.mapper.window_is_focused.return_value = False
self.mapper.activate_window.return_value = True
def assert_no_input_change(self) -> None:
self.mapper.focus_window.assert_not_called()
self.mapper.restore_input_focus.assert_not_called()
def test_denied_desktop_activation_is_not_overridden_with_raw_focus(self) -> None:
self.mapper.window_is_active.return_value = False
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_called_once_with(12)
self.assertLessEqual(self.clock.now, 2.0)
def test_slow_desktop_activation_is_confirmed_before_input_repair(self) -> None:
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 0.6
self.mapper.window_is_focused.side_effect = [False, True]
gw._focus_game_window(self.mapper, 12)
self.assertGreaterEqual(self.clock.now, 0.6)
self.assertLessEqual(self.clock.now, 2.0)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.focus_window.assert_not_called()
def test_already_focused_child_is_not_replaced_by_startup_handoff(self) -> None:
self.mapper.window_is_focused.return_value = True
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_alt_tab_during_focus_inspection_is_not_undone(self) -> None:
self.mapper.window_is_active.side_effect = [True, False]
gw._repair_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_unmapped_or_unknown_window_is_never_focused(self) -> None:
for state in (None, 0, 2, 3):
for repair in (gw._repair_game_window_focus, gw._repair_gamescope_game_window_focus):
with self.subTest(state=state, repair=repair.__name__):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
with patch.object(gw, "_gamescope_host_focus_is_allowed", return_value=True):
self.assertFalse(repair(self.mapper, 12))
self.assert_no_input_change()
def test_nested_handoff_rechecks_the_host_after_the_dashboard_delay(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._focus_game_window(self.mapper, 12, activate=False)
self.assert_no_input_change()
self.mapper.map_window.assert_not_called()
def test_nested_startup_maps_the_ge_client_before_repairing_input(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
mapped_at = None
def map_window(_window, *, borderless):
nonlocal mapped_at
self.assertFalse(borderless)
mapped_at = self.clock.now
self.mapper.map_window.side_effect = map_window
self.mapper.window_state.side_effect = lambda _window: (
1 if mapped_at is not None and self.clock.now - mapped_at >= 0.1 else None
)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.activate_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_nested_startup_does_not_remap_existing_or_destroyed_windows(self) -> None:
for state, size in ((1, (1920, 1080)), (3, (1920, 1080)), (0, (1920, 1080)), (None, None)):
with self.subTest(state=state, size=size):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
self.mapper.window_size.return_value = size
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.mapper.activate_window.assert_not_called()
def test_nested_startup_does_not_map_after_host_focus_changes(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False}, {"allowed": False},
]):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_startup_mapping_wait_is_bounded_and_does_not_force_focus(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assertLessEqual(self.clock.now, 1.0)
self.assert_no_input_change()
def test_nested_repair_rechecks_host_focus_before_setting_input(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False},
]):
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_nested_mapping_is_deferred_until_host_focus_returns(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
mapped_at = []
def map_window(_window, *, borderless):
mapped_at.append(self.clock.now)
self.mapper.window_state.return_value = 1
self.mapper.map_window.side_effect = map_window
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", side_effect=lambda **_kw: {
"allowed": self.clock.now >= 1.0,
}):
gw._focus_game_window(self.mapper, 12, activate=False)
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.assertEqual(len(mapped_at), 1)
self.assertGreaterEqual(mapped_at[0], 1.0)
self.mapper.activate_window.assert_not_called()
self.mapper.restore_input_focus.assert_called_with(12)
def test_nested_replacement_login_window_gets_its_own_startup_mapping(self) -> None:
mapped = set()
self.mapper.window_state.side_effect = lambda window: (
1 if window in mapped or (window == 12 and self.clock.now < 0.5) else None
)
self.mapper.window_size.side_effect = lambda window: (
(1920, 1080) if window == 21 else None
)
self.mapper.find_first_window.return_value = (21, "PristonTale EU Login")
self.mapper.map_window.side_effect = lambda window, **_kw: mapped.add(window)
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(21, borderless=False)
self.mapper.restore_input_focus.assert_called_with(21)
def test_nested_monitor_does_not_keep_remapping_an_unacknowledged_client(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 3 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_mapping_stays_deferred_while_the_host_is_minimized(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_monitor_does_not_resurrect_a_previously_managed_client(self) -> None:
self.mapper.window_state.side_effect = lambda _window: 1 if self.clock.now < 0.5 else None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.window_is_focused.return_value = True
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_mapping_error_does_not_end_the_game_supervisor(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.map_window.side_effect = OSError("window disappeared")
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
self.assertEqual(gw._maintain_gamescope_game_window_focus(self.mapper, process, 12), 0)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_startup_rechecks_minimize_before_mapping(self) -> None:
self.mapper.window_state.side_effect = [None, 3]
self.mapper.window_size.return_value = (1920, 1080)
prepared_windows = set()
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
# Losing WM_STATE after minimization must not trigger startup mapping.
self.mapper.window_state.side_effect = None
self.mapper.window_state.return_value = None
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_activation_errors_do_not_fall_back_to_forced_focus(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.activate_window.side_effect = RuntimeError("WM disconnected")
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
def test_initial_preparation_respects_minimize_after_discovery(self) -> None:
self.mapper.root_window_ids.return_value = set()
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.return_value = None
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_maintain_game_window_focus", return_value=17),
):
self.assertEqual(gw.run_game_window(["unused"], borderless=True), 17)
self.mapper.map_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_preparation_never_raises_a_client_before_desktop_activation(self) -> None:
gw._prepare_game_window(self.mapper, 12,
borderless=False, suppress_wm_ping=False, raise_window=False)
self.mapper.map_window.assert_called_once_with(12, borderless=False, raise_window=False)
self.assert_no_input_change()
def test_layout_recovery_does_not_remap_or_raise_the_x11_client(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
mapper.library = MagicMock()
mapper.display = ctypes.c_void_p(1)
with (
patch.object(mapper, "window_size", return_value=(1280, 720)),
patch.object(mapper, "_remove_decorations"),
):
self.assertEqual(mapper.map_window(12, borderless=True, raise_window=False), (1280, 720))
mapper.library.XMapRaised.assert_not_called()
mapper.library.XMapWindow.assert_not_called()
mapper.library.XSetInputFocus.assert_not_called()
def test_bad_bridge_timestamps_do_not_crash_the_supervisor(self) -> None:
with tempfile.TemporaryDirectory() as directory:
state = Path(directory) / "focus"
environment = {gw.GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state)}
for timestamp in ("nan", "inf", "-inf", "1e999", "1e308", "-1e308"):
with self.subTest(timestamp=timestamp):
state.write_text(f"1 {timestamp}\n", encoding="ascii")
self.assertFalse(gw._gamescope_host_focus_is_allowed(environment=environment, now=10))
def test_startup_recovery_does_not_activate_a_background_window(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
with patch.object(gw, "_prepare_game_window") as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
prepare.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_startup_recovery_does_not_undo_alt_tab_during_surface_refresh(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
def switch_away(*_args, **_kwargs) -> None:
self.mapper.window_is_active.return_value = False
with patch.object(gw, "_prepare_game_window", side_effect=switch_away) as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertIs(prepare.call_args.kwargs["raise_window"], False)
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_scaling_failures_keep_the_focus_supervisor_running(self) -> None:
for failure in ("subscribe", "configure", "poll", "repair"):
with self.subTest(failure=failure):
mapper = MagicMock(spec=gw._X11)
mapper.enable_window_scaling.return_value = failure != "configure"
if failure == "subscribe":
mapper.select_scaling_hint_events.side_effect = RuntimeError("subscription failed")
elif failure == "poll":
mapper.wait_for_scaling_hint_change.side_effect = RuntimeError("poll failed")
elif failure == "repair":
mapper.enable_window_scaling.side_effect = [True] + [RuntimeError("hints failed")] * 3
process = MagicMock()
process.poll.return_value = None
with patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain:
self.assertEqual(gw._maintain_game_window_scaling(mapper, process, 12), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids=(), expected_pid=None,
)
process.wait.assert_not_called()
def test_delayed_desktop_activation_is_repaired_during_startup_guard(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = False
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 1.5
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 4 else 0
repair_times: list[float] = []
def repair(_window: int) -> None:
repair_times.append(self.clock.now)
self.mapper.window_is_focused.return_value = True
self.mapper.restore_input_focus.side_effect = repair
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertEqual(len(repair_times), 1)
self.assertGreaterEqual(repair_times[0], 1.5)
self.assertLess(repair_times[0], 4)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_startup_guard_hands_a_vanished_window_to_the_monitor_immediately(self) -> None:
process = MagicMock()
process.poll.return_value = None
for state in (None, 0):
with self.subTest(state=state):
self.mapper.window_state.return_value = state
self.assertFalse(gw._guard_game_window_startup(
self.mapper, process, 12, borderless=False,
))
self.assertEqual(self.clock.now, 0.0)
self.assert_no_input_change()
def test_direct_monitor_follows_the_same_process_without_resizing_or_activating(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: 0 if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.restore_input_focus.side_effect = lambda _window: setattr(
self.mapper.window_is_focused, "return_value", True,
)
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 3 else 17
process.wait.return_value = 17
self.assertEqual(gw._maintain_game_window_focus(
self.mapper, process, 12, expected_pid=42, existing_window_ids={8, 9},
), 17)
self.mapper.find_first_window.assert_called_once_with(
gw.WINDOW_TITLE, excluded_window_ids={8, 9, 12}, expected_pid=42,
)
self.mapper.restore_input_focus.assert_called_once_with(22)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_a_replaced_background_window_does_not_take_keyboard_focus(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: None if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.window_is_active.return_value = False
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_minimized_direct_client_is_not_replaced_or_restored(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.mapper.find_first_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.restore_normal_window_state.assert_not_called()
self.assert_no_input_change()
def test_missing_process_identity_does_not_attach_to_another_account(self) -> None:
self.mapper.window_pid.return_value = None
self.mapper.window_state.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12)
self.mapper.find_first_window.assert_not_called()
self.assert_no_input_change()
def test_reused_window_id_from_another_process_is_not_focused(self) -> None:
self.mapper.window_pid.return_value = 99
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
for call in self.mapper.find_first_window.call_args_list:
self.assertEqual(call.kwargs["expected_pid"], 42)
self.assertIn(12, call.kwargs["excluded_window_ids"])
def test_discovery_keeps_the_original_pid_if_the_window_disappears_during_startup(self) -> None:
self.mapper.root_window_ids.return_value = {8, 9}
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_pid.return_value = 42
process = MagicMock()
process.poll.return_value = None
def disappear(*_args, **_kwargs) -> bool:
self.mapper.window_pid.return_value = None
return False
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_prepare_game_window"),
patch.object(gw, "_focus_game_window"),
patch.object(gw, "_guard_game_window_startup", side_effect=disappear),
patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(gw.run_game_window(["unused"]), 17)
maintain.assert_called_once_with(
self.mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids={8, 9}, expected_pid=42,
)
class ClientWindowDiscoveryTests(unittest.TestCase):
def test_replacement_discovery_rejects_other_processes_with_identical_titles(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50, 60, 70]),
patch.object(mapper, "_client_window", side_effect={50: 12, 60: 13, 70: 14}.__getitem__),
patch.object(mapper, "window_pid", side_effect={12: 99, 13: None, 14: 42}.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertEqual(mapper.find_first_window(
gw.WINDOW_TITLE, excluded_window_ids=set(), expected_pid=42,
), (14, "PristonTale EU V23.86.96"))
def test_title_is_read_from_the_client_inside_an_untitled_wm_frame(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", side_effect=lambda window:
"PristonTale EU V23.86.96" if window == 12 else None),
):
self.assertEqual(mapper.find_first_window("PristonTale EU", excluded_window_ids=set()),
(12, "PristonTale EU V23.86.96"))
def test_an_existing_client_is_excluded_even_if_its_frame_changes(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
frames = {50: 12, 60: 12, 70: 13}
with (
patch.object(mapper, "_root_windows", side_effect=[[50], [60, 70]]),
patch.object(mapper, "_client_window", side_effect=frames.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
existing = mapper.root_window_ids()
found = mapper.find_first_window("PristonTale EU", excluded_window_ids=existing)
self.assertIn(12, existing)
self.assertEqual(found, (13, "PristonTale EU V23.86.96"))
def test_a_retired_client_is_excluded_even_when_a_frame_supplies_its_title(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertIsNone(mapper.find_first_window("PristonTale EU", excluded_window_ids={12}))