Files
pristontale-eu-linux/ptlinux/doctor.py

233 lines
6.5 KiB
Python

"""Cross-distribution dependency and host diagnostics."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from importlib.util import find_spec
import os
from pathlib import Path
import platform
import shutil
from typing import Mapping
from .launcher import find_wine_tools
from .runtime import find_steam_proton7
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
)
@dataclass(frozen=True)
class ToolStatus:
name: str
path: str | None
required: bool
purpose: str
@dataclass(frozen=True)
class DoctorReport:
ok: bool
distribution: str
version: str
architecture: str
display_backend: str
tools: list[ToolStatus]
package_hint: str | None
warnings: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
env = os.environ if environment is None else environment
os_release = _read_os_release()
distribution = os_release.get("ID", "unknown")
version = os_release.get("VERSION_ID", "unknown")
wine_tools = find_wine_tools()
tools = [
ToolStatus(
"python3",
shutil.which("python3"),
True,
"native installer and command-line interface",
),
ToolStatus(
"python-tk",
_python_tk_path(),
True,
"native dashboard",
),
ToolStatus(
"wine",
str(wine_tools.wine) if wine_tools else None,
True,
"legacy updater support and game compatibility utilities",
),
ToolStatus(
"winetricks",
str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"),
True,
"native D3D helper DLLs for the Proton game prefix",
),
ToolStatus(
"gamescope",
shutil.which("gamescope"),
False,
"optional manual game-container diagnostics",
),
ToolStatus(
"cabextract",
shutil.which("cabextract"),
True,
"Microsoft runtime extraction through Winetricks",
),
ToolStatus(
"openssl",
shutil.which("openssl"),
True,
"signed native-launcher update verification",
),
ToolStatus(
"umu-run",
shutil.which("umu-run"),
distribution in ARCH_FAMILY_DISTRIBUTIONS,
"current portable Proton runtime; automatic on Arch-family hosts",
),
ToolStatus(
"vulkaninfo",
shutil.which("vulkaninfo"),
False,
"Vulkan diagnostics",
),
]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
warnings.append("No graphical X11 or Wayland session is visible.")
if shutil.which("umu-run") is None and not _has_steam_proton7(env):
warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
)
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
distribution=distribution,
version=version,
architecture=platform.machine(),
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
tools=tools,
package_hint=_package_hint(distribution),
warnings=warnings,
)
def _read_os_release() -> dict[str, str]:
result: dict[str, str] = {}
try:
lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines()
except OSError:
return result
for line in lines:
if "=" not in line or line.startswith("#"):
continue
key, value = line.split("=", 1)
result[key] = value.strip().strip('"')
return result
def _python_tk_path() -> str | None:
try:
specification = find_spec("_tkinter")
except (ImportError, ValueError):
return None
if specification is None:
return None
return specification.origin or "available"
def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return (
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, "
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
)
if distribution in {"fedora", "nobara"}:
return (
"Install python3-tkinter, wine, winetricks, cabextract, openssl, "
"and the matching 32-bit Vulkan driver with DNF."
)
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return (
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return (
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper."
)
return None
def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
return find_steam_proton7(environment) is not None
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
if architecture.casefold() not in {"x86_64", "amd64"}:
return []
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return [
_optional_tool_status(
"lib32-vulkan",
"/usr/lib32/libvulkan.so.1",
"32-bit Vulkan runtime compatibility layer",
),
_optional_tool_status(
"lib32-gstreamer",
"/usr/lib32/gstreamer-1.0",
"32-bit gstreamer runtime compatibility modules",
),
]
return []
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
if distribution not in ARCH_FAMILY_DISTRIBUTIONS:
return []
if architecture.casefold() not in {"x86_64", "amd64"}:
return [
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
]
warnings: list[str] = []
if not _path_exists("/usr/lib32/libvulkan.so.1"):
warnings.append(
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
)
if not _path_exists("/usr/lib32/gstreamer-1.0"):
warnings.append(
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
"other media does not play. Missing media plugins are not evidence of an "
"authentication failure."
)
return warnings
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
if _path_exists(path):
return ToolStatus(name, path, required=False, purpose=purpose)
return ToolStatus(name, None, required=False, purpose=purpose)
def _path_exists(path: str) -> bool:
entry = Path(path)
return entry.is_file() or entry.is_dir()