691 lines
32 KiB
Python
691 lines
32 KiB
Python
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()
|