Release 1.1.3 with game window and Gamescope lifecycle fixes

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

View File

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