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