595 lines
27 KiB
Python
595 lines
27 KiB
Python
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))
|