Added better support for kubuntu and fixed some issues with telemetry.
This commit is contained in:
@@ -380,7 +380,9 @@ ptlinux_install_host_dependencies()
|
|||||||
ptlinux_detect_missing_dependencies "$family"
|
ptlinux_detect_missing_dependencies "$family"
|
||||||
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
|
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
|
||||||
then
|
then
|
||||||
printf '%s\n' "All required host dependencies are already available."
|
printf '%s\n' \
|
||||||
|
"All automatically managed host packages are already available." \
|
||||||
|
"Final graphics and runtime diagnostics run after launcher installation."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ "$family" == "unknown" ]]
|
if [[ "$family" == "unknown" ]]
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ from .telemetry import (
|
|||||||
network_http_status,
|
network_http_status,
|
||||||
shutdown_shared_telemetry,
|
shutdown_shared_telemetry,
|
||||||
)
|
)
|
||||||
|
from .system_identity import is_running_inside_steam_runtime
|
||||||
from .umu import (
|
from .umu import (
|
||||||
UmuBootstrapResult,
|
UmuBootstrapResult,
|
||||||
find_umu_launcher,
|
find_umu_launcher,
|
||||||
@@ -310,12 +311,9 @@ def main(
|
|||||||
initialized_telemetry, telemetry_owner = initialize_shared_telemetry(
|
initialized_telemetry, telemetry_owner = initialize_shared_telemetry(
|
||||||
paths,
|
paths,
|
||||||
session_metadata={"interface": interface, "command": command},
|
session_metadata={"interface": interface, "command": command},
|
||||||
|
report_launcher_lifecycle=command == "gui",
|
||||||
)
|
)
|
||||||
telemetry = initialized_telemetry
|
telemetry = initialized_telemetry
|
||||||
if command != "gui":
|
|
||||||
initialized_telemetry.mark_ready(
|
|
||||||
{"interface": interface, "command": command}
|
|
||||||
)
|
|
||||||
telemetry_events = _command_telemetry_events(command, arguments, paths)
|
telemetry_events = _command_telemetry_events(command, arguments, paths)
|
||||||
operation_started = time.monotonic()
|
operation_started = time.monotonic()
|
||||||
if telemetry is not None and telemetry_events is not None:
|
if telemetry is not None and telemetry_events is not None:
|
||||||
@@ -652,9 +650,24 @@ def _doctor(as_json: bool) -> int:
|
|||||||
status = tool.path or "MISSING"
|
status = tool.path or "MISSING"
|
||||||
required = "required" if tool.required else "optional"
|
required = "required" if tool.required else "optional"
|
||||||
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
|
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
|
||||||
|
optional_missing = [
|
||||||
|
tool.name for tool in report.tools if not tool.required and tool.path is None
|
||||||
|
]
|
||||||
|
if optional_missing:
|
||||||
|
print(
|
||||||
|
"Optional tools missing (these do not block launch): "
|
||||||
|
+ ", ".join(optional_missing)
|
||||||
|
)
|
||||||
|
print(f"Host readiness: {'READY' if report.ok else 'BLOCKED'}")
|
||||||
|
if report.blockers:
|
||||||
|
print("Blocking diagnostics:")
|
||||||
|
for blocker in report.blockers:
|
||||||
|
print(f" - {blocker}")
|
||||||
for warning in report.warnings:
|
for warning in report.warnings:
|
||||||
print(f"Warning: {warning}")
|
print(f"Warning: {warning}")
|
||||||
if report.package_hint:
|
if report.package_hint and any(
|
||||||
|
tool.required and tool.path is None for tool in report.tools
|
||||||
|
):
|
||||||
print(report.package_hint)
|
print(report.package_hint)
|
||||||
return 0 if report.ok else 1
|
return 0 if report.ok else 1
|
||||||
|
|
||||||
@@ -1322,6 +1335,28 @@ def _play_locked(
|
|||||||
"run pristontale-eu update before playing"
|
"run pristontale-eu update before playing"
|
||||||
)
|
)
|
||||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||||
|
launcher_nested_in_steam_runtime = is_running_inside_steam_runtime()
|
||||||
|
if launcher_nested_in_steam_runtime and not dry_run:
|
||||||
|
print(
|
||||||
|
"Warning: the launcher itself is running inside Steam Runtime. Start "
|
||||||
|
"it from the desktop menu or a host terminal; nesting its UMU runtime "
|
||||||
|
"can cause black game output.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
window_launch_metadata: dict[str, object] = {
|
||||||
|
"gamebox_enabled": not no_box,
|
||||||
|
"resizable_window_requested": settings.allow_window_resizing,
|
||||||
|
"launcher_inside_steam_runtime": launcher_nested_in_steam_runtime,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
launcher_nested_in_steam_runtime
|
||||||
|
and settings.allow_window_resizing
|
||||||
|
and not no_box
|
||||||
|
):
|
||||||
|
window_launch_metadata["gamescope_disabled_reason"] = (
|
||||||
|
"launcher_inside_steam_runtime"
|
||||||
|
)
|
||||||
start_option = select_depot_start_option(
|
start_option = select_depot_start_option(
|
||||||
read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
|
read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
|
||||||
realm or settings.realm,
|
realm or settings.realm,
|
||||||
@@ -1412,11 +1447,13 @@ def _play_locked(
|
|||||||
game_started_at = time.monotonic()
|
game_started_at = time.monotonic()
|
||||||
launch_success_lock = threading.Lock()
|
launch_success_lock = threading.Lock()
|
||||||
launch_success_reported = False
|
launch_success_reported = False
|
||||||
|
reported_runtime_warnings: set[str] = set()
|
||||||
if telemetry is not None and not dry_run:
|
if telemetry is not None and not dry_run:
|
||||||
telemetry.send_event(
|
telemetry.send_event(
|
||||||
"game_launch_started",
|
"game_launch_started",
|
||||||
metadata={
|
metadata={
|
||||||
**launch_session_metadata,
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
"launch_duration_ms": round(
|
"launch_duration_ms": round(
|
||||||
(game_started_at - launch_requested_at) * 1000
|
(game_started_at - launch_requested_at) * 1000
|
||||||
),
|
),
|
||||||
@@ -1438,6 +1475,27 @@ def _play_locked(
|
|||||||
"""Timestamp launch success when the supervisor first confirms readiness."""
|
"""Timestamp launch success when the supervisor first confirms readiness."""
|
||||||
|
|
||||||
nonlocal launch_success_reported
|
nonlocal launch_success_reported
|
||||||
|
presentation_failure = _gamescope_presentation_failure(line)
|
||||||
|
if telemetry is not None and not dry_run and presentation_failure is not None:
|
||||||
|
with launch_success_lock:
|
||||||
|
if presentation_failure not in reported_runtime_warnings:
|
||||||
|
reported_runtime_warnings.add(presentation_failure)
|
||||||
|
telemetry.send_event(
|
||||||
|
"game_window_diagnostic",
|
||||||
|
severity="warning",
|
||||||
|
metadata={
|
||||||
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
|
"component": "gamescope_host",
|
||||||
|
"diagnostic": presentation_failure,
|
||||||
|
"runtime": selection.kind,
|
||||||
|
"realm": start_option.name,
|
||||||
|
},
|
||||||
|
context={
|
||||||
|
"operation": "launch_game",
|
||||||
|
"stage": "presentation",
|
||||||
|
},
|
||||||
|
)
|
||||||
window_diagnostic = parse_window_diagnostic(line)
|
window_diagnostic = parse_window_diagnostic(line)
|
||||||
if window_diagnostic is not None:
|
if window_diagnostic is not None:
|
||||||
diagnostic_session_id = window_diagnostic.get("session_id")
|
diagnostic_session_id = window_diagnostic.get("session_id")
|
||||||
@@ -1470,6 +1528,7 @@ def _play_locked(
|
|||||||
"game_launch_success",
|
"game_launch_success",
|
||||||
metadata={
|
metadata={
|
||||||
**launch_session_metadata,
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
"launch_duration_ms": round(
|
"launch_duration_ms": round(
|
||||||
(ready_at - launch_requested_at) * 1000
|
(ready_at - launch_requested_at) * 1000
|
||||||
),
|
),
|
||||||
@@ -1514,6 +1573,7 @@ def _play_locked(
|
|||||||
message="Game launch was interrupted",
|
message="Game launch was interrupted",
|
||||||
metadata={
|
metadata={
|
||||||
**launch_session_metadata,
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
"runtime_seconds": round(time.monotonic() - game_started_at),
|
"runtime_seconds": round(time.monotonic() - game_started_at),
|
||||||
"clean_exit": False,
|
"clean_exit": False,
|
||||||
"session_duration_class": _session_duration_class(
|
"session_duration_class": _session_duration_class(
|
||||||
@@ -1586,6 +1646,7 @@ def _play_locked(
|
|||||||
if telemetry is not None and not dry_run:
|
if telemetry is not None and not dry_run:
|
||||||
common_game_metadata: dict[str, object] = {
|
common_game_metadata: dict[str, object] = {
|
||||||
**launch_session_metadata,
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
"runtime_seconds": round(game_runtime_seconds),
|
"runtime_seconds": round(game_runtime_seconds),
|
||||||
"clean_exit": clean_exit,
|
"clean_exit": clean_exit,
|
||||||
"timed_out": outcome.timed_out,
|
"timed_out": outcome.timed_out,
|
||||||
@@ -1610,6 +1671,7 @@ def _play_locked(
|
|||||||
"game_launch_success",
|
"game_launch_success",
|
||||||
metadata={
|
metadata={
|
||||||
**launch_session_metadata,
|
**launch_session_metadata,
|
||||||
|
**window_launch_metadata,
|
||||||
"detection_duration_ms": round(game_runtime_seconds * 1000),
|
"detection_duration_ms": round(game_runtime_seconds * 1000),
|
||||||
"detected_after_exit": True,
|
"detected_after_exit": True,
|
||||||
"game_window_ready": game_window_ready,
|
"game_window_ready": game_window_ready,
|
||||||
@@ -1896,12 +1958,32 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]:
|
|||||||
"gstreamer_missing_libraries": missing_libraries,
|
"gstreamer_missing_libraries": missing_libraries,
|
||||||
"gstreamer_stream_error": "winegstreamer error" in folded,
|
"gstreamer_stream_error": "winegstreamer error" in folded,
|
||||||
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
|
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
|
||||||
|
"gamescope_present_queue_unsupported": (
|
||||||
|
"physical device queue doesn't support presenting on our surface" in folded
|
||||||
|
),
|
||||||
|
"gamescope_vulkan_output_failed_count": folded.count(
|
||||||
|
"vulkan_make_output failed"
|
||||||
|
),
|
||||||
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
|
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
|
||||||
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
|
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
|
||||||
"bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded,
|
"bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gamescope_presentation_failure(output: str) -> str | None:
|
||||||
|
folded = output.casefold()
|
||||||
|
for diagnostic, marker in (
|
||||||
|
(
|
||||||
|
"gamescope_present_queue_unsupported",
|
||||||
|
"physical device queue doesn't support presenting on our surface",
|
||||||
|
),
|
||||||
|
("gamescope_vulkan_output_failed", "vulkan_make_output failed"),
|
||||||
|
):
|
||||||
|
if marker in folded:
|
||||||
|
return diagnostic
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _game_window_was_ready(stderr: str) -> bool:
|
def _game_window_was_ready(stderr: str) -> bool:
|
||||||
return _game_readiness_signal(stderr) is not None
|
return _game_readiness_signal(stderr) is not None
|
||||||
|
|
||||||
|
|||||||
@@ -42,19 +42,71 @@ class DoctorReport:
|
|||||||
package_hint: str | None
|
package_hint: str | None
|
||||||
warnings: list[str]
|
warnings: list[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blockers(self) -> tuple[str, ...]:
|
||||||
|
return host_readiness_blockers(self)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return asdict(self)
|
payload = asdict(self)
|
||||||
|
payload["blockers"] = list(self.blockers)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
def host_readiness_blockers(report: DoctorReport) -> tuple[str, ...]:
|
||||||
|
"""Explain every condition that makes a host report fail readiness."""
|
||||||
|
|
||||||
|
blockers = [
|
||||||
|
f"{tool.name} is missing ({tool.purpose})."
|
||||||
|
for tool in report.tools
|
||||||
|
if tool.required and tool.path is None
|
||||||
|
]
|
||||||
|
bubblewrap = next(
|
||||||
|
(tool for tool in report.tools if tool.name == "bubblewrap"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
bubblewrap is not None
|
||||||
|
and bubblewrap.required
|
||||||
|
and bubblewrap.path is not None
|
||||||
|
and not report.container_host.ready
|
||||||
|
):
|
||||||
|
issue = report.container_host.issue or "probe failed"
|
||||||
|
message = f"Bubblewrap cannot create the Steam Runtime sandbox ({issue})."
|
||||||
|
if report.container_host.recommendation:
|
||||||
|
message += f" {report.container_host.recommendation}"
|
||||||
|
blockers.append(message)
|
||||||
|
if report.container_host.broken_umu_profile_link:
|
||||||
|
blockers.append(
|
||||||
|
"UMU's AppArmor profile link is broken; run "
|
||||||
|
"`pristontale-eu container-host --repair`."
|
||||||
|
)
|
||||||
|
if not report.ok and not blockers:
|
||||||
|
blockers.append(
|
||||||
|
"The host readiness check failed for an unclassified reason; "
|
||||||
|
"run `pristontale-eu --json doctor` and report its output."
|
||||||
|
)
|
||||||
|
return tuple(blockers)
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_host(
|
||||||
|
environment: Mapping[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
vulkan_support: Mapping[str, object] | None = None,
|
||||||
|
) -> DoctorReport:
|
||||||
env = os.environ if environment is None else environment
|
env = os.environ if environment is None else environment
|
||||||
os_release = _read_os_release()
|
os_release = _read_os_release()
|
||||||
distribution = os_release.get("ID", "unknown")
|
distribution = os_release.get("ID", "unknown")
|
||||||
version = os_release.get("VERSION_ID", "unknown")
|
version = os_release.get("VERSION_ID", "unknown")
|
||||||
|
architecture = platform.machine()
|
||||||
wine_tools = find_wine_tools()
|
wine_tools = find_wine_tools()
|
||||||
umu_path = _umu_path(env)
|
umu_path = _umu_path(env)
|
||||||
container_host = inspect_container_host(environment=env)
|
container_host = inspect_container_host(environment=env)
|
||||||
container_required = umu_path is not None
|
container_required = umu_path is not None
|
||||||
|
detected_vulkan = (
|
||||||
|
dict(vulkan_support)
|
||||||
|
if vulkan_support is not None
|
||||||
|
else _inspect_vulkan_support(env)
|
||||||
|
)
|
||||||
tools = [
|
tools = [
|
||||||
ToolStatus(
|
ToolStatus(
|
||||||
"python3",
|
"python3",
|
||||||
@@ -117,9 +169,11 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
|||||||
"Vulkan diagnostics",
|
"Vulkan diagnostics",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
|
if architecture.casefold() in {"x86_64", "amd64"}:
|
||||||
|
tools.extend(_vulkan_tool_statuses(detected_vulkan, distribution))
|
||||||
|
tools.extend(_platform_optional_tools(distribution, architecture=architecture))
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if platform.machine().casefold() not in {"x86_64", "amd64"}:
|
if architecture.casefold() not in {"x86_64", "amd64"}:
|
||||||
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
|
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"):
|
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
|
||||||
warnings.append("No graphical X11 or Wayland session is visible.")
|
warnings.append("No graphical X11 or Wayland session is visible.")
|
||||||
@@ -138,7 +192,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
|||||||
"UMU's AppArmor profile link is broken and may prevent profiles from "
|
"UMU's AppArmor profile link is broken and may prevent profiles from "
|
||||||
"loading after reboot; run `pristontale-eu container-host --repair`."
|
"loading after reboot; run `pristontale-eu container-host --repair`."
|
||||||
)
|
)
|
||||||
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
|
warnings.extend(_arch_runtime_warnings(distribution, architecture))
|
||||||
required_ok = all(item.path is not None for item in tools if item.required)
|
required_ok = all(item.path is not None for item in tools if item.required)
|
||||||
return DoctorReport(
|
return DoctorReport(
|
||||||
ok=(
|
ok=(
|
||||||
@@ -148,7 +202,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
|||||||
),
|
),
|
||||||
distribution=distribution,
|
distribution=distribution,
|
||||||
version=version,
|
version=version,
|
||||||
architecture=platform.machine(),
|
architecture=architecture,
|
||||||
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
|
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
|
||||||
tools=tools,
|
tools=tools,
|
||||||
container_host=container_host,
|
container_host=container_host,
|
||||||
@@ -228,11 +282,6 @@ def _platform_optional_tools(distribution: str, *, architecture: str) -> list[To
|
|||||||
return []
|
return []
|
||||||
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||||
return [
|
return [
|
||||||
_optional_tool_status(
|
|
||||||
"lib32-vulkan",
|
|
||||||
"/usr/lib32/libvulkan.so.1",
|
|
||||||
"32-bit Vulkan runtime compatibility layer",
|
|
||||||
),
|
|
||||||
_optional_tool_status(
|
_optional_tool_status(
|
||||||
"lib32-gstreamer",
|
"lib32-gstreamer",
|
||||||
"/usr/lib32/gstreamer-1.0",
|
"/usr/lib32/gstreamer-1.0",
|
||||||
@@ -250,11 +299,6 @@ def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
|
|||||||
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
|
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
|
||||||
]
|
]
|
||||||
warnings: list[str] = []
|
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"):
|
if not _path_exists("/usr/lib32/gstreamer-1.0"):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
|
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
|
||||||
@@ -264,6 +308,77 @@ def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
|
|||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_vulkan_support(environment: Mapping[str, str]) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
from .telemetry_environment import inspect_vulkan_support
|
||||||
|
|
||||||
|
return inspect_vulkan_support(environment)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _vulkan_tool_statuses(
|
||||||
|
support: Mapping[str, object],
|
||||||
|
distribution: str,
|
||||||
|
) -> list[ToolStatus]:
|
||||||
|
return [
|
||||||
|
_vulkan_tool_status(64, support, distribution),
|
||||||
|
_vulkan_tool_status(32, support, distribution),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _vulkan_tool_status(
|
||||||
|
bits: int,
|
||||||
|
support: Mapping[str, object],
|
||||||
|
distribution: str,
|
||||||
|
) -> ToolStatus:
|
||||||
|
available = support.get(f"vulkan_{bits}bit") is True
|
||||||
|
loader_available = support.get(f"vulkan_{bits}bit_loader_available") is True
|
||||||
|
icd_available = support.get(f"vulkan_{bits}bit_icd_available") is True
|
||||||
|
purpose = f"{bits}-bit Vulkan loader and GPU ICD required by DXVK"
|
||||||
|
if not available:
|
||||||
|
missing_parts: list[str] = []
|
||||||
|
if not loader_available:
|
||||||
|
missing_parts.append("loader")
|
||||||
|
if not icd_available:
|
||||||
|
missing_parts.append("GPU driver/ICD")
|
||||||
|
purpose = (
|
||||||
|
f"{bits}-bit Vulkan {' and '.join(missing_parts)} required by DXVK; "
|
||||||
|
+ _vulkan_package_guidance(distribution, bits)
|
||||||
|
)
|
||||||
|
return ToolStatus(
|
||||||
|
f"vulkan-{bits}bit",
|
||||||
|
"loader+ICD" if available else None,
|
||||||
|
True,
|
||||||
|
purpose,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vulkan_package_guidance(distribution: str, bits: int) -> str:
|
||||||
|
suffix = ":i386" if bits == 32 else ""
|
||||||
|
if distribution == "ubuntu":
|
||||||
|
return (
|
||||||
|
"on Ubuntu/Kubuntu install "
|
||||||
|
f"libvulkan1{suffix} and the matching GPU package "
|
||||||
|
f"(mesa-vulkan-drivers{suffix} or libnvidia-gl-<version>{suffix})"
|
||||||
|
)
|
||||||
|
if distribution in {"debian", "linuxmint", "pop", "elementary", "zorin"}:
|
||||||
|
return (
|
||||||
|
f"with APT install libvulkan1{suffix} and the matching {bits}-bit GPU ICD"
|
||||||
|
)
|
||||||
|
if distribution in {"fedora", "nobara"}:
|
||||||
|
return f"install the matching {bits}-bit Vulkan loader and GPU driver with DNF"
|
||||||
|
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||||
|
return (
|
||||||
|
"install vulkan-icd-loader and the GPU vendor driver"
|
||||||
|
if bits == 64
|
||||||
|
else "install lib32-vulkan-icd-loader and the matching lib32 GPU driver"
|
||||||
|
)
|
||||||
|
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
|
||||||
|
return f"install the matching {bits}-bit Vulkan loader and GPU driver with Zypper"
|
||||||
|
return f"install the matching {bits}-bit Vulkan loader and GPU driver"
|
||||||
|
|
||||||
|
|
||||||
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
|
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
|
||||||
if _path_exists(path):
|
if _path_exists(path):
|
||||||
return ToolStatus(name, path, required=False, purpose=purpose)
|
return ToolStatus(name, path, required=False, purpose=purpose)
|
||||||
|
|||||||
@@ -479,6 +479,18 @@ class _X11:
|
|||||||
return None
|
return None
|
||||||
return values[0]
|
return values[0]
|
||||||
|
|
||||||
|
def restore_normal_window_state(self, window: int) -> bool:
|
||||||
|
"""Undo a nested game iconify without activating the outer host window."""
|
||||||
|
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"WM_STATE",
|
||||||
|
b"WM_STATE",
|
||||||
|
(_X_WM_STATE_NORMAL, 0),
|
||||||
|
)
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
return self.window_state(window) == _X_WM_STATE_NORMAL
|
||||||
|
|
||||||
def window_is_borderless(self, window: int) -> bool | None:
|
def window_is_borderless(self, window: int) -> bool | None:
|
||||||
"""Return whether the client currently requests no Motif decorations."""
|
"""Return whether the client currently requests no Motif decorations."""
|
||||||
|
|
||||||
@@ -1776,9 +1788,38 @@ def _resolve_gamescope_game_window(
|
|||||||
wm_state=_wm_state_name(state),
|
wm_state=_wm_state_name(state),
|
||||||
)
|
)
|
||||||
if state == _X_WM_STATE_ICONIC:
|
if state == _X_WM_STATE_ICONIC:
|
||||||
# IconicState can be a real desktop minimize request. Monitoring must never
|
# This mapper runs inside Gamescope's nested X server. The desktop owns a
|
||||||
# turn that user-controlled state back into a visible window.
|
# separate outer window, so restoring this game-driven login transition
|
||||||
return None
|
# neither activates nor raises a minimized host window.
|
||||||
|
try:
|
||||||
|
restored = mapper.restore_normal_window_state(window)
|
||||||
|
except Exception as exc:
|
||||||
|
restored = False
|
||||||
|
if diagnostics is not None:
|
||||||
|
diagnostics.record(
|
||||||
|
"nested_iconify_recovery_failed",
|
||||||
|
window_id=window,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
if restored:
|
||||||
|
if diagnostics is not None:
|
||||||
|
diagnostics.record(
|
||||||
|
"nested_iconify_recovered",
|
||||||
|
window_id=window,
|
||||||
|
**_sample_window_diagnostic_details(
|
||||||
|
diagnostics,
|
||||||
|
mapper,
|
||||||
|
window,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Game login window requested nested minimization; kept its "
|
||||||
|
"surface visible inside Gamescope.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return window
|
||||||
|
state = None
|
||||||
if state is not None and state != _X_WM_STATE_WITHDRAWN:
|
if state is not None and state != _X_WM_STATE_WITHDRAWN:
|
||||||
return window
|
return window
|
||||||
|
|
||||||
|
|||||||
@@ -428,10 +428,41 @@ def _bundled_gamescope_is_usable() -> bool:
|
|||||||
return os.access(path, os.X_OK)
|
return os.access(path, os.X_OK)
|
||||||
|
|
||||||
|
|
||||||
def _select_wayland_gamescope(executable: str) -> tuple[str, bool, bool]:
|
def _is_kde_session(environment: Mapping[str, str]) -> bool:
|
||||||
|
"""Recognize Plasma sessions without relying on an Ubuntu flavor name."""
|
||||||
|
|
||||||
|
if environment.get("KDE_FULL_SESSION", "").strip().casefold() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}:
|
||||||
|
return True
|
||||||
|
if environment.get("KDE_SESSION_VERSION", "").strip():
|
||||||
|
return True
|
||||||
|
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
|
||||||
|
tokens = re.split(
|
||||||
|
r"[^a-z0-9]+",
|
||||||
|
environment.get(key, "").strip().casefold(),
|
||||||
|
)
|
||||||
|
if "kde" in tokens or any(token.startswith("plasma") for token in tokens):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _select_wayland_gamescope(
|
||||||
|
executable: str,
|
||||||
|
*,
|
||||||
|
environment: Mapping[str, str] | None = None,
|
||||||
|
) -> tuple[str, bool, bool]:
|
||||||
"""Choose a direct-safe Gamescope or request the stable composed path."""
|
"""Choose a direct-safe Gamescope or request the stable composed path."""
|
||||||
|
|
||||||
version = _read_gamescope_version(executable)
|
version = _read_gamescope_version(executable)
|
||||||
|
env = os.environ if environment is None else environment
|
||||||
|
if _is_kde_session(env):
|
||||||
|
# This is reached only when Plasma has no usable XWayland display. Keep
|
||||||
|
# native Wayland fully composed because newer Gamescope builds still have
|
||||||
|
# KWin-specific presentation failures outside our backing-surface fixes.
|
||||||
|
return executable, True, False
|
||||||
if version is not None and version >= WAYLAND_BACKING_FIX_VERSION:
|
if version is not None and version >= WAYLAND_BACKING_FIX_VERSION:
|
||||||
return executable, False, False
|
return executable, False, False
|
||||||
if version == BUNDLED_GAMESCOPE_VERSION and _bundled_gamescope_is_usable():
|
if version == BUNDLED_GAMESCOPE_VERSION and _bundled_gamescope_is_usable():
|
||||||
@@ -458,9 +489,15 @@ def _wayland_socket_available(environment: Mapping[str, str]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _select_host_backend(environment: Mapping[str, str]) -> str:
|
def _select_host_backend(environment: Mapping[str, str]) -> str:
|
||||||
"""Prefer Wayland's retained-buffer resize path when it is usable."""
|
"""Choose the host backend with a Plasma-safe XWayland fallback."""
|
||||||
|
|
||||||
if _wayland_socket_available(environment):
|
if _wayland_socket_available(environment):
|
||||||
|
# Gamescope's native Wayland backend still has KWin-specific presentation,
|
||||||
|
# fullscreen, and fractional-scaling failures. Plasma normally exposes an
|
||||||
|
# XWayland display, so keep the resizable host window on the mature SDL/X11
|
||||||
|
# path while leaving non-Plasma Wayland sessions on direct presentation.
|
||||||
|
if _is_kde_session(environment) and environment.get("DISPLAY", "").strip():
|
||||||
|
return "sdl"
|
||||||
return "wayland"
|
return "wayland"
|
||||||
return "sdl"
|
return "sdl"
|
||||||
|
|
||||||
@@ -629,6 +666,13 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
diagnostics = WindowDiagnosticRecorder("gamescope_host")
|
diagnostics = WindowDiagnosticRecorder("gamescope_host")
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
backend = _select_host_backend(environment)
|
backend = _select_host_backend(environment)
|
||||||
|
kde_session = _is_kde_session(environment)
|
||||||
|
kde_wayland_xwayland_fallback = (
|
||||||
|
backend == "sdl"
|
||||||
|
and kde_session
|
||||||
|
and bool(environment.get("WAYLAND_DISPLAY", "").strip())
|
||||||
|
and bool(environment.get("DISPLAY", "").strip())
|
||||||
|
)
|
||||||
selected_command = list(map(str, command))
|
selected_command = list(map(str, command))
|
||||||
force_composition = backend == "sdl"
|
force_composition = backend == "sdl"
|
||||||
using_bundled_gamescope = False
|
using_bundled_gamescope = False
|
||||||
@@ -637,7 +681,10 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
selected_command[0],
|
selected_command[0],
|
||||||
force_composition,
|
force_composition,
|
||||||
using_bundled_gamescope,
|
using_bundled_gamescope,
|
||||||
) = _select_wayland_gamescope(selected_command[0])
|
) = _select_wayland_gamescope(
|
||||||
|
selected_command[0],
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
mapper = _open_host_x11() if backend == "sdl" else None
|
mapper = _open_host_x11() if backend == "sdl" else None
|
||||||
diagnostics.record(
|
diagnostics.record(
|
||||||
"runtime_configured",
|
"runtime_configured",
|
||||||
@@ -647,6 +694,22 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
display_available=bool(environment.get("DISPLAY")),
|
display_available=bool(environment.get("DISPLAY")),
|
||||||
wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
|
wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
|
||||||
host_x11_monitor_available=mapper is not None,
|
host_x11_monitor_available=mapper is not None,
|
||||||
|
kde_session=kde_session,
|
||||||
|
kde_wayland_compatibility_mode=(
|
||||||
|
kde_wayland_xwayland_fallback
|
||||||
|
or (backend == "wayland" and kde_session and force_composition)
|
||||||
|
),
|
||||||
|
kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback,
|
||||||
|
backend_reason=(
|
||||||
|
"kde_wayland_xwayland_compatibility"
|
||||||
|
if kde_wayland_xwayland_fallback
|
||||||
|
else "wayland_socket"
|
||||||
|
if backend == "wayland"
|
||||||
|
else "x11_or_unavailable_wayland"
|
||||||
|
),
|
||||||
|
presentation_mode=(
|
||||||
|
f"{backend}_{'composed' if force_composition else 'direct'}"
|
||||||
|
),
|
||||||
canvas_width=width,
|
canvas_width=width,
|
||||||
canvas_height=height,
|
canvas_height=height,
|
||||||
)
|
)
|
||||||
@@ -740,6 +803,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
diagnostics.close(process_started=False, backend=backend)
|
diagnostics.close(process_started=False, backend=backend)
|
||||||
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
|
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
|
||||||
|
|
||||||
|
if kde_wayland_xwayland_fallback:
|
||||||
|
print(
|
||||||
|
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||||
|
"SDL host window through XWayland to avoid KWin presentation, "
|
||||||
|
"fullscreen, and fractional-scaling failures.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
if backend == "wayland" and not force_composition:
|
if backend == "wayland" and not force_composition:
|
||||||
message = (
|
message = (
|
||||||
"Gamescope resize optimization active: native Wayland direct "
|
"Gamescope resize optimization active: native Wayland direct "
|
||||||
@@ -751,6 +822,13 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
"decoration-input, and login-window visibility fixes are active."
|
"decoration-input, and login-window visibility fixes are active."
|
||||||
)
|
)
|
||||||
print(message, file=sys.stderr, flush=True)
|
print(message, file=sys.stderr, flush=True)
|
||||||
|
elif backend == "wayland" and kde_session:
|
||||||
|
print(
|
||||||
|
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||||
|
"composed native fallback because no XWayland display is available.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
elif backend == "wayland":
|
elif backend == "wayland":
|
||||||
print(
|
print(
|
||||||
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
|
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ from .gamebox import (
|
|||||||
)
|
)
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
|
from .system_identity import (
|
||||||
|
detect_distribution_id as _detect_distribution_id,
|
||||||
|
is_running_inside_steam_runtime as _is_running_inside_steam_runtime,
|
||||||
|
)
|
||||||
from .umu import find_umu_launcher
|
from .umu import find_umu_launcher
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +472,14 @@ def run_game(
|
|||||||
if use_gamebox:
|
if use_gamebox:
|
||||||
gamescope_path = None
|
gamescope_path = None
|
||||||
game_size = None
|
game_size = None
|
||||||
if settings.allow_window_resizing:
|
if settings.allow_window_resizing and launcher_inside_steam_runtime():
|
||||||
|
print(
|
||||||
|
"Warning: resizable Gamescope was disabled because the launcher "
|
||||||
|
"is already inside Steam Runtime; using a fixed game window.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
elif settings.allow_window_resizing:
|
||||||
gamescope_environment = os.environ.copy()
|
gamescope_environment = os.environ.copy()
|
||||||
gamescope_environment.update(environment)
|
gamescope_environment.update(environment)
|
||||||
gamescope_path = find_gamescope(gamescope_environment)
|
gamescope_path = find_gamescope(gamescope_environment)
|
||||||
@@ -714,11 +725,13 @@ def _proton_environment(
|
|||||||
def detect_distribution_id() -> str:
|
def detect_distribution_id() -> str:
|
||||||
"""Return the freedesktop distribution ID without invoking distro tools."""
|
"""Return the freedesktop distribution ID without invoking distro tools."""
|
||||||
|
|
||||||
try:
|
return _detect_distribution_id()
|
||||||
value = platform.freedesktop_os_release().get("ID", "")
|
|
||||||
except OSError:
|
|
||||||
return ""
|
def launcher_inside_steam_runtime() -> bool:
|
||||||
return value.strip().casefold()
|
"""Return whether this launcher process is nested in Steam Runtime."""
|
||||||
|
|
||||||
|
return _is_running_inside_steam_runtime()
|
||||||
|
|
||||||
|
|
||||||
def is_cachyos_host(
|
def is_cachyos_host(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import uuid
|
|||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
|
from .system_identity import SystemIdentity, detect_system_identity, read_os_release
|
||||||
from .telemetry_environment import collect_extended_environment
|
from .telemetry_environment import collect_extended_environment
|
||||||
|
|
||||||
|
|
||||||
@@ -267,6 +268,7 @@ class TelemetryService:
|
|||||||
environment: Mapping[str, str] | None = None,
|
environment: Mapping[str, str] | None = None,
|
||||||
transport: TelemetryTransport | None = None,
|
transport: TelemetryTransport | None = None,
|
||||||
session_metadata: Mapping[str, object] | None = None,
|
session_metadata: Mapping[str, object] | None = None,
|
||||||
|
report_launcher_lifecycle: bool | None = None,
|
||||||
monotonic: Callable[[], float] = time.monotonic,
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.paths = paths
|
self.paths = paths
|
||||||
@@ -283,10 +285,15 @@ class TelemetryService:
|
|||||||
self._session_metadata = dict(session_metadata or {})
|
self._session_metadata = dict(session_metadata or {})
|
||||||
self._monotonic = monotonic
|
self._monotonic = monotonic
|
||||||
self._started_at = monotonic()
|
self._started_at = monotonic()
|
||||||
self._reports_launcher_lifecycle = (
|
process_reports_launcher_lifecycle = (
|
||||||
self._environment.get(TELEMETRY_PROCESS_ROLE_VARIABLE, "").strip().casefold()
|
self._environment.get(TELEMETRY_PROCESS_ROLE_VARIABLE, "").strip().casefold()
|
||||||
!= TELEMETRY_OPERATION_PROCESS_ROLE
|
!= TELEMETRY_OPERATION_PROCESS_ROLE
|
||||||
)
|
)
|
||||||
|
self._reports_launcher_lifecycle = process_reports_launcher_lifecycle and (
|
||||||
|
True
|
||||||
|
if report_launcher_lifecycle is None
|
||||||
|
else bool(report_launcher_lifecycle)
|
||||||
|
)
|
||||||
self._enabled = bool(enabled)
|
self._enabled = bool(enabled)
|
||||||
self._active = False
|
self._active = False
|
||||||
self._accepting = False
|
self._accepting = False
|
||||||
@@ -923,6 +930,7 @@ class TelemetryService:
|
|||||||
|
|
||||||
def _collect_common_payload(self, first_seen: str) -> dict[str, object]:
|
def _collect_common_payload(self, first_seen: str) -> dict[str, object]:
|
||||||
environment = self._environment
|
environment = self._environment
|
||||||
|
system_identity = detect_system_identity()
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"launcher_version": __version__,
|
"launcher_version": __version__,
|
||||||
"launcher_build": environment.get("PTLINUX_BUILD", __version__)[:32],
|
"launcher_build": environment.get("PTLINUX_BUILD", __version__)[:32],
|
||||||
@@ -934,10 +942,13 @@ class TelemetryService:
|
|||||||
"install_is_external": _install_is_external(self.paths, environment),
|
"install_is_external": _install_is_external(self.paths, environment),
|
||||||
"configuration_schema_version": _settings_schema_version(),
|
"configuration_schema_version": _settings_schema_version(),
|
||||||
},
|
},
|
||||||
"os": _operating_system_info(),
|
"os": _operating_system_info(system_identity),
|
||||||
"display": _display_info(environment),
|
"display": _display_info(environment),
|
||||||
"filesystem": _filesystem_info(self.paths.data),
|
"filesystem": _filesystem_info(self.paths.data),
|
||||||
}
|
}
|
||||||
|
execution_environment = _execution_environment_info(system_identity)
|
||||||
|
if execution_environment is not None:
|
||||||
|
payload["execution_environment"] = execution_environment
|
||||||
commit = environment.get("PTLINUX_COMMIT")
|
commit = environment.get("PTLINUX_COMMIT")
|
||||||
if commit and re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
|
if commit and re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
|
||||||
payload["launcher_commit"] = commit[:12].lower()
|
payload["launcher_commit"] = commit[:12].lower()
|
||||||
@@ -1097,6 +1108,7 @@ def initialize_shared_telemetry(
|
|||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
*,
|
*,
|
||||||
session_metadata: Mapping[str, object] | None = None,
|
session_metadata: Mapping[str, object] | None = None,
|
||||||
|
report_launcher_lifecycle: bool | None = None,
|
||||||
) -> tuple[TelemetryService, bool]:
|
) -> tuple[TelemetryService, bool]:
|
||||||
"""Return the process-wide service and whether this caller owns its shutdown."""
|
"""Return the process-wide service and whether this caller owns its shutdown."""
|
||||||
|
|
||||||
@@ -1108,6 +1120,7 @@ def initialize_shared_telemetry(
|
|||||||
paths,
|
paths,
|
||||||
enabled=_telemetry_enabled_from_settings(paths),
|
enabled=_telemetry_enabled_from_settings(paths),
|
||||||
session_metadata=session_metadata,
|
session_metadata=session_metadata,
|
||||||
|
report_launcher_lifecycle=report_launcher_lifecycle,
|
||||||
)
|
)
|
||||||
_shared_service = service
|
_shared_service = service
|
||||||
service.initialize()
|
service.initialize()
|
||||||
@@ -1230,8 +1243,11 @@ def _fsync_directory(path: Path) -> None:
|
|||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
def _operating_system_info() -> dict[str, object]:
|
def _operating_system_info(
|
||||||
release = _read_os_release()
|
identity: SystemIdentity | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
identity = detect_system_identity() if identity is None else identity
|
||||||
|
release = identity.operating_system_release
|
||||||
return {
|
return {
|
||||||
"name": release.get("NAME", platform.system() or "unknown"),
|
"name": release.get("NAME", platform.system() or "unknown"),
|
||||||
"id": release.get("ID", "unknown"),
|
"id": release.get("ID", "unknown"),
|
||||||
@@ -1241,27 +1257,38 @@ def _operating_system_info() -> dict[str, object]:
|
|||||||
"architecture": platform.machine() or "unknown",
|
"architecture": platform.machine() or "unknown",
|
||||||
"machine_architecture": platform.machine() or "unknown",
|
"machine_architecture": platform.machine() or "unknown",
|
||||||
"glibc_version": platform.libc_ver()[1] or None,
|
"glibc_version": platform.libc_ver()[1] or None,
|
||||||
|
"identity_source": (
|
||||||
|
"container_host_os_release"
|
||||||
|
if identity.host_release is not None
|
||||||
|
else "execution_os_release"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]:
|
def _read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]:
|
||||||
try:
|
return read_os_release(path)
|
||||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
||||||
except OSError:
|
|
||||||
return {}
|
def _execution_environment_info(
|
||||||
result: dict[str, str] = {}
|
identity: SystemIdentity,
|
||||||
for line in lines:
|
) -> dict[str, object] | None:
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not identity.running_inside_steam_runtime:
|
||||||
continue
|
return None
|
||||||
key, raw = line.split("=", 1)
|
release = identity.execution_release
|
||||||
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
|
return {
|
||||||
continue
|
"containerized": True,
|
||||||
value = raw.strip()
|
"kind": "steam_runtime",
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
"id": release.get("ID", "steamrt"),
|
||||||
value = value[1:-1]
|
"name": release.get("NAME", "Steam Runtime"),
|
||||||
value = value.replace(r"\n", " ").replace(r"\"", '"').replace(r"\\", "\\")
|
"version": release.get(
|
||||||
result[key] = value[:255]
|
"PRETTY_NAME",
|
||||||
return result
|
release.get("VERSION", "unknown"),
|
||||||
|
),
|
||||||
|
"version_id": release.get("VERSION_ID", "unknown"),
|
||||||
|
"host_os_release_available": identity.host_release is not None,
|
||||||
|
"launcher_inside_game_runtime": True,
|
||||||
|
"glibc_version": platform.libc_ver()[1] or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _display_info(environment: Mapping[str, str]) -> dict[str, object]:
|
def _display_info(environment: Mapping[str, str]) -> dict[str, object]:
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ def collect_extended_environment(
|
|||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
}
|
}
|
||||||
display = _display_info(sysfs_drm)
|
display = _display_info(sysfs_drm)
|
||||||
|
display.update(_kde_display_scaling_info(environment, run_probe))
|
||||||
if display:
|
if display:
|
||||||
result["display"] = display
|
result["display"] = display
|
||||||
if gpu:
|
if gpu:
|
||||||
@@ -263,6 +264,23 @@ def _vulkan_info(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_vulkan_support(
|
||||||
|
environment: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
probe_runner: ProbeRunner | None = None,
|
||||||
|
manifest_directories: Sequence[Path] = _VULKAN_MANIFEST_DIRECTORIES,
|
||||||
|
library_roots: Sequence[Path] = _LIBRARY_ROOTS,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return host Vulkan loader and ICD readiness for dependency checks."""
|
||||||
|
|
||||||
|
return _vulkan_info(
|
||||||
|
environment,
|
||||||
|
probe_runner or _run_probe,
|
||||||
|
manifest_directories=manifest_directories,
|
||||||
|
library_roots=library_roots,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _gpu_info(
|
def _gpu_info(
|
||||||
vulkan_summary: object,
|
vulkan_summary: object,
|
||||||
*,
|
*,
|
||||||
@@ -362,7 +380,7 @@ def _dependency_health(
|
|||||||
try:
|
try:
|
||||||
from .doctor import inspect_host
|
from .doctor import inspect_host
|
||||||
|
|
||||||
report = inspect_host(environment)
|
report = inspect_host(environment, vulkan_support=runtime)
|
||||||
for item in report.tools:
|
for item in report.tools:
|
||||||
available = item.path is not None
|
available = item.path is not None
|
||||||
tools[item.name] = available
|
tools[item.name] = available
|
||||||
@@ -834,15 +852,18 @@ def _vulkan_manifests(
|
|||||||
directories: Sequence[Path],
|
directories: Sequence[Path],
|
||||||
) -> tuple[Path, ...]:
|
) -> tuple[Path, ...]:
|
||||||
candidates: list[Path] = []
|
candidates: list[Path] = []
|
||||||
explicit = environment.get("VK_ICD_FILENAMES", "")
|
override = environment.get("VK_DRIVER_FILES", "").strip()
|
||||||
for value in explicit.split(os.pathsep):
|
if not override:
|
||||||
if value:
|
override = environment.get("VK_ICD_FILENAMES", "").strip()
|
||||||
candidates.append(Path(value).expanduser())
|
if override:
|
||||||
for directory in directories:
|
candidates.extend(_manifest_entries(override))
|
||||||
try:
|
else:
|
||||||
candidates.extend(sorted(directory.glob("*.json")))
|
candidates.extend(_manifest_entries(environment.get("VK_ADD_DRIVER_FILES", "")))
|
||||||
except OSError:
|
for directory in _vulkan_manifest_search_directories(environment, directories):
|
||||||
continue
|
try:
|
||||||
|
candidates.extend(sorted(directory.glob("*.json")))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
seen: set[Path] = set()
|
seen: set[Path] = set()
|
||||||
result: list[Path] = []
|
result: list[Path] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
@@ -857,6 +878,54 @@ def _vulkan_manifests(
|
|||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_entries(value: str) -> tuple[Path, ...]:
|
||||||
|
result: list[Path] = []
|
||||||
|
for raw_entry in value.split(os.pathsep):
|
||||||
|
if not raw_entry:
|
||||||
|
continue
|
||||||
|
entry = Path(raw_entry).expanduser()
|
||||||
|
try:
|
||||||
|
if entry.is_dir():
|
||||||
|
result.extend(sorted(entry.glob("*.json")))
|
||||||
|
else:
|
||||||
|
result.append(entry)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _vulkan_manifest_search_directories(
|
||||||
|
environment: Mapping[str, str],
|
||||||
|
directories: Sequence[Path],
|
||||||
|
) -> tuple[Path, ...]:
|
||||||
|
if directories is not _VULKAN_MANIFEST_DIRECTORIES:
|
||||||
|
return tuple(dict.fromkeys(directories))
|
||||||
|
home = environment.get("HOME", "").strip()
|
||||||
|
config_home = environment.get("XDG_CONFIG_HOME", "").strip()
|
||||||
|
data_home = environment.get("XDG_DATA_HOME", "").strip()
|
||||||
|
config_directories = environment.get("XDG_CONFIG_DIRS", "/etc/xdg")
|
||||||
|
data_directories = environment.get(
|
||||||
|
"XDG_DATA_DIRS",
|
||||||
|
"/usr/local/share:/usr/share",
|
||||||
|
)
|
||||||
|
result = list(directories)
|
||||||
|
if config_home:
|
||||||
|
result.append(Path(config_home).expanduser() / "vulkan/icd.d")
|
||||||
|
elif home:
|
||||||
|
result.append(Path(home).expanduser() / ".config/vulkan/icd.d")
|
||||||
|
for value in config_directories.split(os.pathsep):
|
||||||
|
if value:
|
||||||
|
result.append(Path(value).expanduser() / "vulkan/icd.d")
|
||||||
|
if data_home:
|
||||||
|
result.append(Path(data_home).expanduser() / "vulkan/icd.d")
|
||||||
|
elif home:
|
||||||
|
result.append(Path(home).expanduser() / ".local/share/vulkan/icd.d")
|
||||||
|
for value in data_directories.split(os.pathsep):
|
||||||
|
if value:
|
||||||
|
result.append(Path(value).expanduser() / "vulkan/icd.d")
|
||||||
|
return tuple(dict.fromkeys(result))
|
||||||
|
|
||||||
|
|
||||||
def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]:
|
def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]:
|
||||||
architectures: set[int] = set()
|
architectures: set[int] = set()
|
||||||
for root in library_roots:
|
for root in library_roots:
|
||||||
@@ -967,6 +1036,101 @@ def _display_info(sysfs_drm: Path) -> dict[str, object]:
|
|||||||
return {"monitor_count": monitor_count} if monitor_count is not None else {}
|
return {"monitor_count": monitor_count} if monitor_count is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _kde_display_scaling_info(
|
||||||
|
environment: Mapping[str, str],
|
||||||
|
probe_runner: ProbeRunner,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Collect bounded Plasma scale facts without monitor names or resolutions."""
|
||||||
|
|
||||||
|
if not environment.get("WAYLAND_DISPLAY", "").strip() or not _is_kde_desktop(
|
||||||
|
environment
|
||||||
|
):
|
||||||
|
return {}
|
||||||
|
result: dict[str, object] = {"kde_wayland": True}
|
||||||
|
executable = shutil.which(
|
||||||
|
"kscreen-doctor",
|
||||||
|
path=environment.get("PATH", ""),
|
||||||
|
)
|
||||||
|
result["kscreen_doctor_available"] = executable is not None
|
||||||
|
if executable is None:
|
||||||
|
return result
|
||||||
|
probe = probe_runner(
|
||||||
|
(executable, "--json"),
|
||||||
|
environment,
|
||||||
|
PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if probe is None or probe.returncode != 0:
|
||||||
|
result["scale_probe_succeeded"] = False
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
payload = json.loads(probe.stdout)
|
||||||
|
except (json.JSONDecodeError, TypeError, UnicodeError):
|
||||||
|
result["scale_probe_succeeded"] = False
|
||||||
|
return result
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
result["scale_probe_succeeded"] = False
|
||||||
|
return result
|
||||||
|
raw_outputs = payload.get("outputs")
|
||||||
|
if isinstance(raw_outputs, list):
|
||||||
|
outputs = raw_outputs
|
||||||
|
elif isinstance(raw_outputs, dict):
|
||||||
|
outputs = list(raw_outputs.values())
|
||||||
|
else:
|
||||||
|
outputs = []
|
||||||
|
scales: list[float] = []
|
||||||
|
for output in outputs[:32]:
|
||||||
|
if not isinstance(output, dict):
|
||||||
|
continue
|
||||||
|
if output.get("connected") is False or output.get("enabled") is False:
|
||||||
|
continue
|
||||||
|
value = output.get("scale")
|
||||||
|
if isinstance(value, bool):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
scale = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not 0.5 <= scale <= 8.0:
|
||||||
|
continue
|
||||||
|
scales.append(round(scale, 3))
|
||||||
|
result["scale_probe_succeeded"] = bool(scales)
|
||||||
|
if not scales:
|
||||||
|
return result
|
||||||
|
unique_scales = sorted(set(scales))
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"scale_output_count": len(scales),
|
||||||
|
"scale_min": unique_scales[0],
|
||||||
|
"scale_max": unique_scales[-1],
|
||||||
|
"fractional_scaling": any(
|
||||||
|
abs(scale - round(scale)) > 0.001 for scale in unique_scales
|
||||||
|
),
|
||||||
|
"mixed_scaling": len(unique_scales) > 1,
|
||||||
|
"scale_detection": "kscreen_doctor",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kde_desktop(environment: Mapping[str, str]) -> bool:
|
||||||
|
if environment.get("KDE_FULL_SESSION", "").strip().casefold() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}:
|
||||||
|
return True
|
||||||
|
if environment.get("KDE_SESSION_VERSION", "").strip():
|
||||||
|
return True
|
||||||
|
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
|
||||||
|
tokens = re.split(
|
||||||
|
r"[^a-z0-9]+",
|
||||||
|
environment.get(key, "").strip().casefold(),
|
||||||
|
)
|
||||||
|
if "kde" in tokens or any(token.startswith("plasma") for token in tokens):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _connected_monitor_count(sysfs_drm: Path) -> int | None:
|
def _connected_monitor_count(sysfs_drm: Path) -> int | None:
|
||||||
try:
|
try:
|
||||||
connectors = sorted(sysfs_drm.glob("card[0-9]*-*/status"))[:32]
|
connectors = sorted(sysfs_drm.glob("card[0-9]*-*/status"))[:32]
|
||||||
|
|||||||
Reference in New Issue
Block a user