1222 lines
34 KiB
Python
1222 lines
34 KiB
Python
"""Bounded, privacy-safe compatibility details for launcher telemetry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
|
|
from .paths import AppPaths
|
|
|
|
|
|
PROBE_TIMEOUT_SECONDS = 3.0
|
|
MAX_MARKER_BYTES = 256 * 1024
|
|
MAX_DLL_SCAN_BYTES = 16 * 1024 * 1024
|
|
MAX_PROBE_OUTPUT_CHARACTERS = 64 * 1024
|
|
|
|
_VULKAN_MANIFEST_DIRECTORIES = (
|
|
Path("/etc/vulkan/icd.d"),
|
|
Path("/usr/local/share/vulkan/icd.d"),
|
|
Path("/usr/share/vulkan/icd.d"),
|
|
)
|
|
_LIBRARY_ROOTS = (
|
|
Path("/lib"),
|
|
Path("/usr/lib"),
|
|
Path("/usr/local/lib"),
|
|
)
|
|
_PCI_DATABASES = (
|
|
Path("/usr/share/hwdata/pci.ids"),
|
|
Path("/usr/share/misc/pci.ids"),
|
|
Path("/usr/share/pci.ids"),
|
|
)
|
|
_GPU_VENDORS = {
|
|
0x1002: "AMD",
|
|
0x10DE: "NVIDIA",
|
|
0x8086: "Intel",
|
|
}
|
|
_DXVK_VERSION_PATTERN = re.compile(
|
|
r"\bdxvk(?:\s+version|[- /:])?\s*v?(\d+\.\d+(?:\.\d+){0,2})\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_VERSION_PATTERN = re.compile(r"\d+(?:\.\d+){1,3}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProbeResult:
|
|
"""Small result surface for bounded local compatibility probes."""
|
|
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
ProbeRunner = Callable[[Sequence[str], Mapping[str, str], float], ProbeResult | None]
|
|
|
|
|
|
def collect_extended_environment(
|
|
paths: AppPaths,
|
|
environment: Mapping[str, str],
|
|
*,
|
|
probe_runner: ProbeRunner | None = None,
|
|
sysfs_drm: Path = Path("/sys/class/drm"),
|
|
vulkan_manifest_directories: Sequence[Path] = _VULKAN_MANIFEST_DIRECTORIES,
|
|
library_roots: Sequence[Path] = _LIBRARY_ROOTS,
|
|
pci_databases: Sequence[Path] = _PCI_DATABASES,
|
|
now: datetime | None = None,
|
|
) -> dict[str, object]:
|
|
"""Collect compatibility data once without returning paths or device IDs."""
|
|
|
|
run_probe = probe_runner or _run_probe
|
|
marker = _read_json_object(paths.game_runtime_marker)
|
|
prefix = _selected_prefix(paths, marker)
|
|
runtime = _runtime_info(
|
|
paths,
|
|
marker,
|
|
prefix,
|
|
environment,
|
|
run_probe,
|
|
vulkan_manifest_directories=vulkan_manifest_directories,
|
|
library_roots=library_roots,
|
|
now=now or datetime.now(timezone.utc),
|
|
)
|
|
gpu = _gpu_info(
|
|
runtime.pop("_vulkan_summary", None),
|
|
environment=environment,
|
|
sysfs_drm=sysfs_drm,
|
|
pci_databases=pci_databases,
|
|
)
|
|
dependencies = _dependency_health(
|
|
environment,
|
|
prefix,
|
|
runtime,
|
|
)
|
|
result: dict[str, object] = {
|
|
"runtime": runtime,
|
|
"dependencies": dependencies,
|
|
}
|
|
display = _display_info(sysfs_drm)
|
|
if display:
|
|
result["display"] = display
|
|
if gpu:
|
|
result["gpu"] = gpu
|
|
return result
|
|
|
|
|
|
def _runtime_info(
|
|
paths: AppPaths,
|
|
marker: Mapping[str, object] | None,
|
|
prefix: Path | None,
|
|
environment: Mapping[str, str],
|
|
probe_runner: ProbeRunner,
|
|
*,
|
|
vulkan_manifest_directories: Sequence[Path],
|
|
library_roots: Sequence[Path],
|
|
now: datetime,
|
|
) -> dict[str, object]:
|
|
runtime_marker = _object_field(marker, "runtime")
|
|
runtime_kind = _short_string(runtime_marker.get("kind")) if runtime_marker else None
|
|
result: dict[str, object] = {
|
|
"kind": runtime_kind or "unknown",
|
|
"wine_vendor": _wine_vendor(runtime_kind, runtime_marker, paths),
|
|
}
|
|
wine_command = _wine_command(runtime_marker, environment)
|
|
if wine_command is not None:
|
|
wine_probe = probe_runner(
|
|
(wine_command, "--version"),
|
|
environment,
|
|
PROBE_TIMEOUT_SECONDS,
|
|
)
|
|
wine_build = _first_probe_line(wine_probe)
|
|
if wine_build:
|
|
result["wine_build"] = wine_build
|
|
wine_version = _wine_version(wine_build)
|
|
if wine_version:
|
|
result["wine"] = wine_version
|
|
|
|
if runtime_marker:
|
|
proton_name = _proton_name(runtime_marker.get("proton_path"))
|
|
if proton_name:
|
|
result["proton_version"] = proton_name
|
|
|
|
if prefix is not None:
|
|
prefix_architecture = _wine_prefix_architecture(prefix)
|
|
if prefix_architecture:
|
|
result["prefix_arch"] = prefix_architecture
|
|
configured_at = _marker_timestamp(marker)
|
|
if configured_at is not None:
|
|
result["prefix_created_at"] = configured_at.isoformat()
|
|
result["prefix_age_days"] = max(0, (now - configured_at).days)
|
|
result.update(
|
|
_dxvk_info(
|
|
prefix,
|
|
packaged_version=_proton_dxvk_version(runtime_marker),
|
|
)
|
|
)
|
|
else:
|
|
result.update(
|
|
{
|
|
"dxvk_enabled": False,
|
|
"dxvk_install_status": "missing",
|
|
}
|
|
)
|
|
result.update(_runtime_component_info(paths.launcher_prefix, prefix))
|
|
|
|
vulkan = _vulkan_info(
|
|
environment,
|
|
probe_runner,
|
|
manifest_directories=vulkan_manifest_directories,
|
|
library_roots=library_roots,
|
|
)
|
|
result.update(vulkan)
|
|
return result
|
|
|
|
|
|
def _vulkan_info(
|
|
environment: Mapping[str, str],
|
|
probe_runner: ProbeRunner,
|
|
*,
|
|
manifest_directories: Sequence[Path],
|
|
library_roots: Sequence[Path],
|
|
) -> dict[str, object]:
|
|
loader_architectures = _vulkan_loader_architectures(library_roots)
|
|
manifest_architectures: set[int] = set()
|
|
api_versions: list[str] = []
|
|
for manifest in _vulkan_manifests(environment, manifest_directories):
|
|
parsed = _read_json_object(manifest)
|
|
icd = _object_field(parsed, "ICD")
|
|
if icd is None:
|
|
continue
|
|
api_version = _short_string(icd.get("api_version"))
|
|
if api_version and _VERSION_PATTERN.fullmatch(api_version):
|
|
api_versions.append(api_version)
|
|
library = _short_string(icd.get("library_path"))
|
|
architecture = _manifest_library_architecture(
|
|
manifest,
|
|
library,
|
|
library_roots,
|
|
)
|
|
if architecture is not None:
|
|
manifest_architectures.add(architecture)
|
|
|
|
vulkaninfo = shutil.which("vulkaninfo", path=environment.get("PATH", ""))
|
|
probe = (
|
|
probe_runner(
|
|
(vulkaninfo, "--summary"),
|
|
environment,
|
|
PROBE_TIMEOUT_SECONDS,
|
|
)
|
|
if vulkaninfo
|
|
else None
|
|
)
|
|
functional_64bit = probe is not None and probe.returncode == 0
|
|
summary = _probe_text(probe) if functional_64bit else ""
|
|
if summary:
|
|
api_version = _summary_value(summary, "apiVersion")
|
|
if api_version:
|
|
api_versions.append(api_version)
|
|
instance_version = re.search(
|
|
r"Vulkan\s+Instance\s+Version\s*:\s*(\S+)",
|
|
summary,
|
|
re.IGNORECASE,
|
|
)
|
|
if instance_version:
|
|
api_versions.append(instance_version.group(1))
|
|
|
|
available_64bit = functional_64bit or (
|
|
64 in loader_architectures and 64 in manifest_architectures
|
|
)
|
|
available_32bit = (
|
|
32 in loader_architectures and 32 in manifest_architectures
|
|
)
|
|
result: dict[str, object] = {
|
|
"vulkan_available": available_64bit,
|
|
"vulkan_64bit": available_64bit,
|
|
"vulkan_32bit": available_32bit,
|
|
"vulkan_64bit_detection": (
|
|
"vulkaninfo" if functional_64bit else "loader_and_icd"
|
|
),
|
|
"vulkan_32bit_detection": "loader_and_icd",
|
|
}
|
|
best_api_version = _maximum_version(api_versions)
|
|
if best_api_version:
|
|
result["vulkan_api_version"] = best_api_version
|
|
driver_info = _summary_value(summary, "driverInfo")
|
|
if driver_info:
|
|
result["vulkan_driver_version"] = _driver_version(driver_info) or driver_info
|
|
if summary:
|
|
result["_vulkan_summary"] = summary
|
|
return result
|
|
|
|
|
|
def _gpu_info(
|
|
vulkan_summary: object,
|
|
*,
|
|
environment: Mapping[str, str],
|
|
sysfs_drm: Path,
|
|
pci_databases: Sequence[Path],
|
|
) -> dict[str, object]:
|
|
summary = vulkan_summary if isinstance(vulkan_summary, str) else ""
|
|
vulkan_gpus = _vulkan_gpu_summaries(summary)
|
|
has_gpu_blocks = re.search(r"(?mi)^\s*GPU\d+\s*:\s*$", summary) is not None
|
|
if vulkan_gpus:
|
|
primary_summary = vulkan_gpus[0]
|
|
elif has_gpu_blocks:
|
|
primary_summary = ""
|
|
else:
|
|
primary_summary = summary
|
|
vendor_id = _hex_summary_value(primary_summary, "vendorID")
|
|
model = _summary_value(primary_summary, "deviceName")
|
|
driver_name = _summary_value(primary_summary, "driverName")
|
|
driver_info = _summary_value(primary_summary, "driverInfo")
|
|
sysfs_gpus = _sysfs_gpus(sysfs_drm)
|
|
sysfs = next(
|
|
(gpu for gpu in sysfs_gpus if gpu.get("boot_vga") is True),
|
|
sysfs_gpus[0] if sysfs_gpus else None,
|
|
)
|
|
if vendor_id is None and sysfs is not None:
|
|
vendor_id = sysfs.get("vendor_id")
|
|
if model is None and sysfs is not None:
|
|
model = _short_string(sysfs.get("model"))
|
|
if model is None and sysfs is not None:
|
|
device_id = sysfs.get("device_id")
|
|
if isinstance(vendor_id, int) and isinstance(device_id, int):
|
|
model = _pci_model(vendor_id, device_id, pci_databases)
|
|
if driver_name is None and sysfs is not None:
|
|
driver_name = _short_string(sysfs.get("driver"))
|
|
if driver_info is None and sysfs is not None:
|
|
driver_info = _short_string(sysfs.get("driver_version"))
|
|
|
|
vendor = _GPU_VENDORS.get(vendor_id) if isinstance(vendor_id, int) else None
|
|
if vendor is None:
|
|
vendor = _vendor_from_model(model)
|
|
driver = _friendly_driver_name(driver_name, driver_info)
|
|
driver_version = _driver_version(driver_info)
|
|
result: dict[str, object] = {}
|
|
if vendor:
|
|
result["vendor"] = vendor
|
|
if model:
|
|
result["model"] = model[:255]
|
|
if driver:
|
|
result["driver"] = driver[:128]
|
|
if driver_version:
|
|
result["driver_version"] = driver_version[:128]
|
|
if sysfs is not None and isinstance(sysfs.get("vram_mb"), int):
|
|
result["vram_mb"] = sysfs["vram_mb"]
|
|
summary_gpu_count = len(vulkan_gpus)
|
|
if summary_gpu_count == 0 and model:
|
|
summary_gpu_count = 1
|
|
gpu_count = max(len(sysfs_gpus), summary_gpu_count)
|
|
if gpu_count:
|
|
result["count"] = gpu_count
|
|
if vendor:
|
|
result["primary_vendor"] = vendor
|
|
if model:
|
|
result["primary_model"] = model[:255]
|
|
vendor_ids = {
|
|
vendor
|
|
for gpu in sysfs_gpus
|
|
if isinstance((vendor := gpu.get("vendor_id")), int)
|
|
}
|
|
for gpu_summary in vulkan_gpus:
|
|
vendor_ids.update(_hex_summary_values(gpu_summary, "vendorID"))
|
|
device_types: set[str] = set()
|
|
for gpu_summary in vulkan_gpus:
|
|
device_type = _summary_value(gpu_summary, "deviceType")
|
|
if isinstance(device_type, str):
|
|
device_types.add(device_type.casefold())
|
|
integrated_and_discrete = (
|
|
any("integrated" in value for value in device_types)
|
|
and any("discrete" in value for value in device_types)
|
|
)
|
|
result["hybrid_graphics"] = gpu_count > 1 and (
|
|
len(vendor_ids) > 1 or integrated_and_discrete
|
|
)
|
|
result["offload_detected"] = _gpu_offload_detected(environment)
|
|
return result
|
|
|
|
|
|
def _dependency_health(
|
|
environment: Mapping[str, str],
|
|
prefix: Path | None,
|
|
runtime: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
tools: dict[str, bool] = {}
|
|
required: list[str] = []
|
|
missing: list[str] = []
|
|
container_ready: bool | None = None
|
|
try:
|
|
from .doctor import inspect_host
|
|
|
|
report = inspect_host(environment)
|
|
for item in report.tools:
|
|
available = item.path is not None
|
|
tools[item.name] = available
|
|
if item.required:
|
|
required.append(item.name)
|
|
if not available:
|
|
missing.append(item.name)
|
|
container_ready = report.container_host.ready
|
|
report_ok: bool | None = report.ok
|
|
except Exception:
|
|
report_ok = None
|
|
|
|
wine_available = tools.get("wine")
|
|
if wine_available is None:
|
|
wine_available = _wine_command(None, environment) is not None
|
|
wine_32bit = _wine_32bit_available(prefix)
|
|
vulkan_64bit = _optional_bool(runtime.get("vulkan_64bit"))
|
|
vulkan_32bit = _optional_bool(runtime.get("vulkan_32bit"))
|
|
font_dependencies = _font_dependencies_present(prefix)
|
|
archive_tools = tools.get("cabextract")
|
|
if archive_tools is None:
|
|
archive_tools = shutil.which(
|
|
"cabextract",
|
|
path=environment.get("PATH", ""),
|
|
) is not None
|
|
shared_libraries = (
|
|
vulkan_64bit and vulkan_32bit
|
|
if platform.machine().casefold() in {"x86_64", "amd64"}
|
|
else vulkan_64bit
|
|
)
|
|
healthy = report_ok
|
|
if healthy is not None:
|
|
healthy = bool(healthy and shared_libraries)
|
|
result: dict[str, object] = {
|
|
"healthy": healthy,
|
|
"required_count": len(required),
|
|
"available_required_count": len(required) - len(missing),
|
|
"missing_required": missing,
|
|
"tools": tools,
|
|
"wine_available": wine_available,
|
|
"wine_32bit_available": wine_32bit,
|
|
"vulkan_available": bool(runtime.get("vulkan_available")),
|
|
"vulkan_64bit_available": vulkan_64bit,
|
|
"vulkan_32bit_available": vulkan_32bit,
|
|
"font_dependencies_present": font_dependencies,
|
|
"dotnet_present": bool(runtime.get("dotnet_present")),
|
|
"archive_tools_present": archive_tools,
|
|
"required_shared_libraries_present": shared_libraries,
|
|
"directx_runtime_present": bool(runtime.get("directx_runtime_present")),
|
|
}
|
|
if container_ready is not None:
|
|
result["container_ready"] = container_ready
|
|
return result
|
|
|
|
|
|
def _selected_prefix(
|
|
paths: AppPaths,
|
|
marker: Mapping[str, object] | None,
|
|
) -> Path | None:
|
|
runtime = _object_field(marker, "runtime")
|
|
kind = _short_string(runtime.get("kind")) if runtime else None
|
|
preferred = paths.umu_prefix if kind == "umu" else paths.proton_prefix
|
|
for candidate in (preferred, paths.proton_prefix, paths.umu_prefix):
|
|
if (candidate / "system.reg").is_file() or (
|
|
candidate / "drive_c/windows"
|
|
).is_dir():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _wine_command(
|
|
runtime: Mapping[str, object] | None,
|
|
environment: Mapping[str, str],
|
|
) -> str | None:
|
|
if runtime:
|
|
proton_path = _short_string(runtime.get("proton_path"))
|
|
if proton_path:
|
|
root = Path(proton_path).expanduser().parent
|
|
for relative in (
|
|
"files/bin/wine64",
|
|
"files/bin/wine",
|
|
"dist/bin/wine64",
|
|
"dist/bin/wine",
|
|
):
|
|
candidate = root / relative
|
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
return str(candidate)
|
|
executable = _short_string(runtime.get("executable"))
|
|
if executable and Path(executable).name.casefold() in {"wine", "wine64"}:
|
|
return executable
|
|
return shutil.which("wine", path=environment.get("PATH", ""))
|
|
|
|
|
|
def _wine_vendor(
|
|
runtime_kind: str | None,
|
|
runtime: Mapping[str, object] | None,
|
|
paths: AppPaths,
|
|
) -> str:
|
|
if runtime_kind in {"proton7", "proton7-container", "umu"}:
|
|
return "proton" if runtime_kind != "umu" else "umu"
|
|
if runtime:
|
|
executable = _short_string(runtime.get("executable"))
|
|
if executable:
|
|
lowered = executable.casefold()
|
|
if "lutris" in lowered:
|
|
return "lutris"
|
|
try:
|
|
if Path(executable).resolve().is_relative_to(paths.data.resolve()):
|
|
return "pristontale-runtime"
|
|
except (OSError, RuntimeError):
|
|
pass
|
|
return "system"
|
|
|
|
|
|
def _wine_version(build: str) -> str | None:
|
|
match = re.search(r"\bwine(?:64)?[- ]v?(\d+(?:\.\d+){1,3})", build, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1)
|
|
match = _VERSION_PATTERN.search(build)
|
|
return match.group(0) if match else None
|
|
|
|
|
|
def _proton_name(value: object) -> str | None:
|
|
path = _short_string(value)
|
|
if not path:
|
|
return None
|
|
name = Path(path).parent.name.strip()
|
|
if "proton" not in name.casefold():
|
|
return None
|
|
version = re.search(r"\d+(?:[._-]\d+)+(?:[A-Za-z0-9._-]*)?", name)
|
|
return version.group(0)[:64] if version else None
|
|
|
|
|
|
def _wine_prefix_architecture(prefix: Path) -> str | None:
|
|
try:
|
|
with (prefix / "system.reg").open("r", encoding="utf-8", errors="replace") as handle:
|
|
text = handle.read(4096)
|
|
except OSError:
|
|
return None
|
|
match = re.search(r"(?m)^#arch=(win32|win64)\s*$", text, re.IGNORECASE)
|
|
return match.group(1).casefold() if match else None
|
|
|
|
|
|
def _marker_timestamp(marker: Mapping[str, object] | None) -> datetime | None:
|
|
if marker is None:
|
|
return None
|
|
value = _short_string(marker.get("configured_at"))
|
|
if not value:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _dxvk_info(
|
|
prefix: Path,
|
|
*,
|
|
packaged_version: str | None = None,
|
|
) -> dict[str, object]:
|
|
components: dict[str, str | None] = {}
|
|
identified_components: set[str] = set()
|
|
files_present = 0
|
|
dxvk_components = 0
|
|
invalid = 0
|
|
for name in ("d3d9", "d3d11", "dxgi"):
|
|
versions: list[str] = []
|
|
component_present = False
|
|
component_is_dxvk = False
|
|
for system_directory in ("syswow64", "system32"):
|
|
dll = prefix / "drive_c/windows" / system_directory / f"{name}.dll"
|
|
if dll.is_symlink() and not dll.exists():
|
|
invalid += 1
|
|
continue
|
|
if not dll.is_file():
|
|
continue
|
|
component_present = True
|
|
is_dxvk, version = _dxvk_dll_details(dll)
|
|
component_is_dxvk = component_is_dxvk or is_dxvk
|
|
if version:
|
|
versions.append(version)
|
|
if component_present:
|
|
files_present += 1
|
|
if component_is_dxvk:
|
|
dxvk_components += 1
|
|
identified_components.add(name)
|
|
components[name] = _maximum_version(versions)
|
|
|
|
if invalid:
|
|
status = "invalid"
|
|
elif files_present == 0:
|
|
status = "missing"
|
|
elif dxvk_components == 0:
|
|
status = "unknown"
|
|
elif dxvk_components < len(components):
|
|
status = "partial"
|
|
else:
|
|
status = "installed"
|
|
result: dict[str, object] = {
|
|
"dxvk_enabled": dxvk_components > 0 and status != "invalid",
|
|
"dxvk_install_status": status,
|
|
}
|
|
all_versions = [
|
|
version for version in components.values() if isinstance(version, str)
|
|
]
|
|
dxvk_version = _maximum_version(all_versions) or packaged_version
|
|
if dxvk_version:
|
|
result["dxvk_version"] = dxvk_version
|
|
for name, version in components.items():
|
|
if version is None and name in identified_components:
|
|
version = packaged_version
|
|
if version:
|
|
result[f"dxvk_{name}_version"] = version
|
|
return result
|
|
|
|
|
|
def _proton_dxvk_version(runtime: Mapping[str, object] | None) -> str | None:
|
|
if runtime is None:
|
|
return None
|
|
proton_path = _short_string(runtime.get("proton_path"))
|
|
if not proton_path:
|
|
return None
|
|
root = Path(proton_path).expanduser().parent
|
|
for relative in (
|
|
"dist/lib/wine/dxvk/version",
|
|
"dist/lib64/wine/dxvk/version",
|
|
"files/lib/wine/dxvk/version",
|
|
"files/lib64/wine/dxvk/version",
|
|
):
|
|
value = _read_short_text(root / relative)
|
|
if not value:
|
|
continue
|
|
match = re.search(
|
|
r"\bdxvk\s+\(v([0-9][0-9A-Za-z.+-]{1,63})\)",
|
|
value,
|
|
re.IGNORECASE,
|
|
)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def _dxvk_dll_details(path: Path) -> tuple[bool, str | None]:
|
|
try:
|
|
with path.open("rb") as handle:
|
|
content = handle.read(MAX_DLL_SCAN_BYTES + 1)
|
|
except OSError:
|
|
return False, None
|
|
if len(content) > MAX_DLL_SCAN_BYTES:
|
|
content = content[:MAX_DLL_SCAN_BYTES]
|
|
texts = (
|
|
content.decode("latin-1", errors="ignore"),
|
|
content.decode("utf-16-le", errors="ignore"),
|
|
)
|
|
versions: list[str] = []
|
|
is_dxvk = False
|
|
for text in texts:
|
|
is_dxvk = is_dxvk or "dxvk" in text.casefold()
|
|
versions.extend(match.group(1) for match in _DXVK_VERSION_PATTERN.finditer(text))
|
|
version = _maximum_version(versions)
|
|
if version is None and is_dxvk:
|
|
version = _pe_fixed_file_version(content)
|
|
return is_dxvk, version
|
|
|
|
|
|
def _pe_fixed_file_version(
|
|
content: bytes,
|
|
*,
|
|
maximum_part: int = 10_000,
|
|
) -> str | None:
|
|
"""Read the numeric VS_FIXEDFILEINFO version without a PE dependency."""
|
|
|
|
signature = b"\xbd\x04\xef\xfe"
|
|
offset = content.find(signature)
|
|
while offset >= 0 and offset + 52 <= len(content):
|
|
try:
|
|
values = struct.unpack_from("<13I", content, offset)
|
|
except struct.error:
|
|
return None
|
|
structure_version = values[1]
|
|
major = values[2] >> 16
|
|
minor = values[2] & 0xFFFF
|
|
build = values[3] >> 16
|
|
revision = values[3] & 0xFFFF
|
|
parts = [major, minor, build, revision]
|
|
if structure_version >> 16 == 1 and 0 < major < 100 and all(
|
|
part < maximum_part for part in parts
|
|
):
|
|
while len(parts) > 2 and parts[-1] == 0:
|
|
parts.pop()
|
|
return ".".join(str(part) for part in parts)
|
|
offset = content.find(signature, offset + len(signature))
|
|
return None
|
|
|
|
|
|
def _directx_runtime_present(prefix: Path) -> bool:
|
|
directory = prefix / "drive_c/windows/syswow64"
|
|
return all((directory / f"{name}.dll").is_file() for name in ("d3dx9_43", "d3dcompiler_43"))
|
|
|
|
|
|
def _runtime_component_info(
|
|
launcher_prefix: Path,
|
|
game_prefix: Path | None,
|
|
) -> dict[str, object]:
|
|
prefixes = tuple(
|
|
dict.fromkeys(
|
|
prefix
|
|
for prefix in (game_prefix, launcher_prefix)
|
|
if prefix is not None
|
|
)
|
|
)
|
|
dotnet_runtime_versions: list[str] = []
|
|
dotnet_desktop_versions: list[str] = []
|
|
dotnet_framework_versions: list[str] = []
|
|
for prefix in prefixes:
|
|
dotnet_root = prefix / "drive_c/Program Files/dotnet/shared"
|
|
dotnet_runtime_versions.extend(
|
|
_directory_versions(dotnet_root / "Microsoft.NETCore.App")
|
|
)
|
|
dotnet_desktop_versions.extend(
|
|
_directory_versions(dotnet_root / "Microsoft.WindowsDesktop.App")
|
|
)
|
|
for framework_name in ("Framework", "Framework64"):
|
|
dotnet_framework_versions.extend(
|
|
_directory_versions(
|
|
prefix / "drive_c/windows/Microsoft.NET" / framework_name
|
|
)
|
|
)
|
|
|
|
dotnet_runtime = _maximum_version(dotnet_runtime_versions)
|
|
dotnet_desktop = _maximum_version(dotnet_desktop_versions)
|
|
dotnet_framework = _maximum_version(dotnet_framework_versions)
|
|
vc_present, vc_version = _vc_runtime_info(prefixes)
|
|
directx_present = (
|
|
_directx_runtime_present(game_prefix)
|
|
if game_prefix is not None
|
|
else False
|
|
)
|
|
winetricks_components: list[str] = []
|
|
if game_prefix is not None:
|
|
system = game_prefix / "drive_c/windows/syswow64"
|
|
for name in ("d3dcompiler_43", "d3dx9_43"):
|
|
if (system / f"{name}.dll").is_file():
|
|
winetricks_components.append(name)
|
|
if dotnet_desktop:
|
|
winetricks_components.append(
|
|
"dotnetdesktop6"
|
|
if dotnet_desktop.startswith("6.")
|
|
else "dotnetdesktop"
|
|
)
|
|
if vc_present:
|
|
winetricks_components.append("vcrun2015-2022")
|
|
|
|
runtime_components: dict[str, object] = {
|
|
"directx": directx_present,
|
|
"vc_runtime": vc_present,
|
|
}
|
|
if dotnet_runtime or dotnet_framework:
|
|
runtime_components["dotnet_runtime"] = dotnet_runtime or dotnet_framework
|
|
if dotnet_desktop:
|
|
runtime_components["dotnet_desktop"] = dotnet_desktop
|
|
if vc_version:
|
|
runtime_components["vc_runtime_version"] = vc_version
|
|
|
|
result: dict[str, object] = {
|
|
"dotnet_present": bool(
|
|
dotnet_runtime
|
|
or dotnet_desktop
|
|
or dotnet_framework
|
|
or (game_prefix is not None and _dotnet_present(game_prefix))
|
|
),
|
|
"vc_runtime_present": vc_present,
|
|
"directx_runtime_present": directx_present,
|
|
"winetricks_components": winetricks_components,
|
|
"runtime_components": runtime_components,
|
|
}
|
|
if dotnet_runtime or dotnet_framework:
|
|
result["dotnet_runtime_version"] = dotnet_runtime or dotnet_framework
|
|
if dotnet_desktop:
|
|
result["dotnet_desktop_runtime_version"] = dotnet_desktop
|
|
if vc_version:
|
|
result["vc_runtime_version"] = vc_version
|
|
return result
|
|
|
|
|
|
def _directory_versions(root: Path) -> tuple[str, ...]:
|
|
try:
|
|
iterator = root.iterdir()
|
|
except OSError:
|
|
return ()
|
|
versions: list[str] = []
|
|
try:
|
|
for index, entry in enumerate(iterator):
|
|
if index >= 64:
|
|
break
|
|
try:
|
|
is_directory = entry.is_dir()
|
|
except OSError:
|
|
continue
|
|
if not is_directory:
|
|
continue
|
|
version = entry.name.removeprefix("v")
|
|
if re.fullmatch(r"\d+(?:\.\d+){0,3}", version):
|
|
versions.append(version)
|
|
except OSError:
|
|
return tuple(versions)
|
|
return tuple(versions)
|
|
|
|
|
|
def _vc_runtime_info(prefixes: Sequence[Path]) -> tuple[bool, str | None]:
|
|
versions: list[str] = []
|
|
present = False
|
|
for prefix in prefixes:
|
|
for system_directory in ("syswow64", "system32"):
|
|
system = prefix / "drive_c/windows" / system_directory
|
|
for name in ("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll"):
|
|
dll = system / name
|
|
if not dll.is_file():
|
|
continue
|
|
present = True
|
|
version = _dll_file_version(dll)
|
|
if version:
|
|
versions.append(version)
|
|
return present, _maximum_version(versions)
|
|
|
|
|
|
def _dll_file_version(path: Path) -> str | None:
|
|
try:
|
|
with path.open("rb") as handle:
|
|
content = handle.read(MAX_DLL_SCAN_BYTES)
|
|
except OSError:
|
|
return None
|
|
return _pe_fixed_file_version(content, maximum_part=1_000_000)
|
|
|
|
|
|
def _dotnet_present(prefix: Path) -> bool:
|
|
framework = prefix / "drive_c/windows/Microsoft.NET"
|
|
try:
|
|
return any(path.is_file() for path in framework.glob("Framework*/v*/mscorlib.dll"))
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _wine_32bit_available(prefix: Path | None) -> bool | None:
|
|
if prefix is None:
|
|
return None
|
|
architecture = _wine_prefix_architecture(prefix)
|
|
if architecture == "win32":
|
|
return True
|
|
return (prefix / "drive_c/windows/syswow64").is_dir()
|
|
|
|
|
|
def _font_dependencies_present(prefix: Path | None) -> bool:
|
|
candidates = [
|
|
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
|
Path("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf"),
|
|
]
|
|
if prefix is not None:
|
|
fonts = prefix / "drive_c/windows/Fonts"
|
|
candidates.extend((fonts / "arial.ttf", fonts / "tahoma.ttf"))
|
|
return any(candidate.is_file() for candidate in candidates)
|
|
|
|
|
|
def _vulkan_manifests(
|
|
environment: Mapping[str, str],
|
|
directories: Sequence[Path],
|
|
) -> tuple[Path, ...]:
|
|
candidates: list[Path] = []
|
|
explicit = environment.get("VK_ICD_FILENAMES", "")
|
|
for value in explicit.split(os.pathsep):
|
|
if value:
|
|
candidates.append(Path(value).expanduser())
|
|
for directory in directories:
|
|
try:
|
|
candidates.extend(sorted(directory.glob("*.json")))
|
|
except OSError:
|
|
continue
|
|
seen: set[Path] = set()
|
|
result: list[Path] = []
|
|
for candidate in candidates:
|
|
try:
|
|
resolved = candidate.resolve()
|
|
except (OSError, RuntimeError):
|
|
continue
|
|
if resolved in seen or not resolved.is_file():
|
|
continue
|
|
seen.add(resolved)
|
|
result.append(resolved)
|
|
return tuple(result)
|
|
|
|
|
|
def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]:
|
|
architectures: set[int] = set()
|
|
for root in library_roots:
|
|
for candidate in _library_candidates(root, "libvulkan.so"):
|
|
architecture = _elf_architecture(candidate)
|
|
if architecture is not None:
|
|
architectures.add(architecture)
|
|
return architectures
|
|
|
|
|
|
def _manifest_library_architecture(
|
|
manifest: Path,
|
|
library: str | None,
|
|
library_roots: Sequence[Path],
|
|
) -> int | None:
|
|
if library:
|
|
path = Path(library)
|
|
if path.is_absolute():
|
|
architecture = _elf_architecture(path)
|
|
if architecture is not None:
|
|
return architecture
|
|
else:
|
|
candidates = [manifest.parent / path]
|
|
for root in library_roots:
|
|
candidates.extend(_library_candidates(root, path.name))
|
|
for candidate in candidates:
|
|
architecture = _elf_architecture(candidate)
|
|
if architecture is not None:
|
|
return architecture
|
|
lowered = manifest.name.casefold()
|
|
if any(token in lowered for token in ("i386", "i486", "i586", "i686", "x86_32")):
|
|
return 32
|
|
if any(token in lowered for token in ("x86_64", "amd64")):
|
|
return 64
|
|
return None
|
|
|
|
|
|
def _library_candidates(root: Path, prefix: str) -> tuple[Path, ...]:
|
|
try:
|
|
direct = tuple(root.glob(f"{prefix}*"))
|
|
children = tuple(root.glob(f"*/{prefix}*"))
|
|
except OSError:
|
|
return ()
|
|
return direct + children
|
|
|
|
|
|
def _elf_architecture(path: Path) -> int | None:
|
|
try:
|
|
with path.open("rb") as handle:
|
|
header = handle.read(5)
|
|
except OSError:
|
|
return None
|
|
if len(header) < 5 or header[:4] != b"\x7fELF":
|
|
return None
|
|
if header[4] == 1:
|
|
return 32
|
|
if header[4] == 2:
|
|
return 64
|
|
return None
|
|
|
|
|
|
def _sysfs_gpus(sysfs_drm: Path) -> tuple[dict[str, object], ...]:
|
|
try:
|
|
cards = sorted(
|
|
path for path in sysfs_drm.glob("card[0-9]*") if path.name[4:].isdigit()
|
|
)
|
|
except OSError:
|
|
return ()
|
|
result_gpus: list[dict[str, object]] = []
|
|
for card in cards[:16]:
|
|
device = card / "device"
|
|
vendor_id = _read_hex(device / "vendor")
|
|
device_id = _read_hex(device / "device")
|
|
if vendor_id is None or device_id is None:
|
|
continue
|
|
result: dict[str, object] = {
|
|
"vendor_id": vendor_id,
|
|
"device_id": device_id,
|
|
}
|
|
if _read_decimal(device / "boot_vga") == 1:
|
|
result["boot_vga"] = True
|
|
for name in ("product_name", "label"):
|
|
value = _read_short_text(device / name)
|
|
if value:
|
|
result["model"] = value
|
|
break
|
|
try:
|
|
driver = (device / "driver").resolve(strict=True).name
|
|
except (OSError, RuntimeError):
|
|
driver = None
|
|
if driver:
|
|
result["driver"] = driver
|
|
version = _read_short_text(Path("/sys/module") / driver / "version")
|
|
if version:
|
|
result["driver_version"] = version
|
|
vram_bytes = _read_decimal(device / "mem_info_vram_total")
|
|
if vram_bytes is not None and vram_bytes > 0:
|
|
result["vram_mb"] = round(vram_bytes / (1024 * 1024))
|
|
result_gpus.append(result)
|
|
return tuple(result_gpus)
|
|
|
|
|
|
def _display_info(sysfs_drm: Path) -> dict[str, object]:
|
|
monitor_count = _connected_monitor_count(sysfs_drm)
|
|
return {"monitor_count": monitor_count} if monitor_count is not None else {}
|
|
|
|
|
|
def _connected_monitor_count(sysfs_drm: Path) -> int | None:
|
|
try:
|
|
connectors = sorted(sysfs_drm.glob("card[0-9]*-*/status"))[:32]
|
|
except OSError:
|
|
return None
|
|
if not connectors:
|
|
return None
|
|
connected = 0
|
|
for status in connectors:
|
|
value = _read_short_text(status)
|
|
if value and value.casefold() == "connected":
|
|
connected += 1
|
|
return connected
|
|
|
|
|
|
def _pci_model(
|
|
vendor_id: int,
|
|
device_id: int,
|
|
databases: Sequence[Path],
|
|
) -> str | None:
|
|
vendor_key = f"{vendor_id:04x}"
|
|
device_key = f"{device_id:04x}"
|
|
for database in databases:
|
|
try:
|
|
lines = database.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
continue
|
|
inside_vendor = False
|
|
for line in lines:
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if not line.startswith("\t"):
|
|
parts = line.split(None, 1)
|
|
inside_vendor = bool(parts and parts[0].casefold() == vendor_key)
|
|
continue
|
|
if not inside_vendor or line.startswith("\t\t"):
|
|
continue
|
|
parts = line.strip().split(None, 1)
|
|
if len(parts) == 2 and parts[0].casefold() == device_key:
|
|
return parts[1].strip()[:255]
|
|
return None
|
|
|
|
|
|
def _friendly_driver_name(name: str | None, info: str | None) -> str | None:
|
|
lowered = (name or "").casefold()
|
|
combined = f"{name or ''} {info or ''}".casefold()
|
|
if "radv" in combined:
|
|
return "Mesa RADV"
|
|
if "anv" in combined or "intel open-source mesa" in combined:
|
|
return "Mesa ANV"
|
|
if "nvidia" in combined:
|
|
return "NVIDIA"
|
|
if "nouveau" in combined:
|
|
return "Mesa NVK" if "nvk" in combined else "Nouveau"
|
|
if lowered in {"amdgpu", "i915", "xe"}:
|
|
return lowered
|
|
return name.strip() if name and name.strip() else None
|
|
|
|
|
|
def _driver_version(info: str | None) -> str | None:
|
|
if not info:
|
|
return None
|
|
mesa = re.search(r"\bMesa\s+(\d+(?:\.\d+){1,3})", info, re.IGNORECASE)
|
|
if mesa:
|
|
return mesa.group(1)
|
|
match = _VERSION_PATTERN.search(info)
|
|
return match.group(0) if match else None
|
|
|
|
|
|
def _vendor_from_model(model: str | None) -> str | None:
|
|
lowered = (model or "").casefold()
|
|
if "amd" in lowered or "radeon" in lowered:
|
|
return "AMD"
|
|
if "nvidia" in lowered or "geforce" in lowered:
|
|
return "NVIDIA"
|
|
if "intel" in lowered:
|
|
return "Intel"
|
|
return None
|
|
|
|
|
|
def _summary_value(summary: str, key: str) -> str | None:
|
|
match = re.search(
|
|
rf"(?mi)^\s*{re.escape(key)}\s*=\s*([^\r\n]+?)\s*$",
|
|
summary,
|
|
)
|
|
return match.group(1).strip()[:255] if match else None
|
|
|
|
|
|
def _vulkan_gpu_summaries(summary: str) -> tuple[str, ...]:
|
|
headers = tuple(re.finditer(r"(?mi)^\s*GPU\d+\s*:\s*$", summary))
|
|
result: list[str] = []
|
|
for index, header in enumerate(headers):
|
|
end = headers[index + 1].start() if index + 1 < len(headers) else len(summary)
|
|
block = summary[header.start():end]
|
|
device_type = _summary_value(block, "deviceType")
|
|
if device_type and "cpu" in device_type.casefold():
|
|
continue
|
|
if _summary_value(block, "deviceName"):
|
|
result.append(block)
|
|
return tuple(result)
|
|
|
|
|
|
def _hex_summary_value(summary: str, key: str) -> int | None:
|
|
value = _summary_value(summary, key)
|
|
if not value:
|
|
return None
|
|
match = re.search(r"0x([0-9a-f]+)", value, re.IGNORECASE)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return int(match.group(1), 16)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _hex_summary_values(summary: str, key: str) -> set[int]:
|
|
values: set[int] = set()
|
|
for match in re.finditer(
|
|
rf"(?mi)^\s*{re.escape(key)}\s*=\s*0x([0-9a-f]+)\b",
|
|
summary,
|
|
):
|
|
try:
|
|
values.add(int(match.group(1), 16))
|
|
except ValueError:
|
|
continue
|
|
return values
|
|
|
|
|
|
def _gpu_offload_detected(environment: Mapping[str, str]) -> bool:
|
|
dri_prime = environment.get("DRI_PRIME", "").casefold().strip()
|
|
nvidia_offload = (
|
|
environment.get("__NV_PRIME_RENDER_OFFLOAD", "").casefold().strip()
|
|
)
|
|
glx_vendor = environment.get("__GLX_VENDOR_LIBRARY_NAME", "").casefold().strip()
|
|
nvidia_optimus = environment.get("__VK_LAYER_NV_optimus", "").strip()
|
|
return (
|
|
dri_prime not in {"", "0", "false"}
|
|
or nvidia_offload not in {"", "0", "false"}
|
|
or glx_vendor == "nvidia"
|
|
or bool(nvidia_optimus)
|
|
)
|
|
|
|
|
|
def _maximum_version(values: Sequence[str]) -> str | None:
|
|
parsed: list[tuple[tuple[int, ...], str]] = []
|
|
for value in values:
|
|
match = _VERSION_PATTERN.search(value)
|
|
if not match:
|
|
continue
|
|
version = match.group(0)
|
|
try:
|
|
parts = tuple(int(part) for part in version.split("."))
|
|
except ValueError:
|
|
continue
|
|
parsed.append((parts, version))
|
|
if not parsed:
|
|
return None
|
|
return max(parsed, key=lambda item: item[0])[1]
|
|
|
|
|
|
def _run_probe(
|
|
argv: Sequence[str],
|
|
environment: Mapping[str, str],
|
|
timeout_seconds: float,
|
|
) -> ProbeResult | None:
|
|
if not argv or timeout_seconds <= 0:
|
|
return None
|
|
probe_environment = os.environ.copy()
|
|
probe_environment.update(environment)
|
|
probe_environment["LC_ALL"] = "C"
|
|
probe_environment.pop("VK_LOADER_DEBUG", None)
|
|
try:
|
|
completed = subprocess.run(
|
|
[str(value) for value in argv],
|
|
env=probe_environment,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=timeout_seconds,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
return ProbeResult(
|
|
returncode=completed.returncode,
|
|
stdout=completed.stdout[-MAX_PROBE_OUTPUT_CHARACTERS:],
|
|
stderr=completed.stderr[-MAX_PROBE_OUTPUT_CHARACTERS:],
|
|
)
|
|
|
|
|
|
def _probe_text(result: ProbeResult | None) -> str:
|
|
if result is None:
|
|
return ""
|
|
return (result.stdout or result.stderr).strip()
|
|
|
|
|
|
def _first_probe_line(result: ProbeResult | None) -> str | None:
|
|
text = _probe_text(result)
|
|
if not text:
|
|
return None
|
|
return text.splitlines()[0].strip()[:128] or None
|
|
|
|
|
|
def _read_json_object(path: Path) -> dict[str, object] | None:
|
|
try:
|
|
with path.open("rb") as handle:
|
|
content = handle.read(MAX_MARKER_BYTES + 1)
|
|
if len(content) > MAX_MARKER_BYTES:
|
|
return None
|
|
value = json.loads(content.decode("utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
return None
|
|
return value if isinstance(value, dict) else None
|
|
|
|
|
|
def _object_field(
|
|
value: Mapping[str, object] | None,
|
|
key: str,
|
|
) -> dict[str, object] | None:
|
|
if value is None:
|
|
return None
|
|
field = value.get(key)
|
|
return field if isinstance(field, dict) else None
|
|
|
|
|
|
def _short_string(value: object) -> str | None:
|
|
return value[:255] if isinstance(value, str) and value else None
|
|
|
|
|
|
def _optional_bool(value: object) -> bool | None:
|
|
return value if isinstance(value, bool) else None
|
|
|
|
|
|
def _read_short_text(path: Path) -> str | None:
|
|
try:
|
|
value = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
except OSError:
|
|
return None
|
|
return value[:255] or None
|
|
|
|
|
|
def _read_hex(path: Path) -> int | None:
|
|
value = _read_short_text(path)
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value, 16)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _read_decimal(path: Path) -> int | None:
|
|
value = _read_short_text(path)
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value, 10)
|
|
except ValueError:
|
|
return None
|