Added a debug system and fixed thread issues with update / repair.

This commit is contained in:
2026-08-31 20:23:23 +02:00
parent 20a0bbcd75
commit fe883047e0

View File

@@ -39,6 +39,12 @@ from .desktop_identity import (
APPLICATION_WM_CLASS, APPLICATION_WM_CLASS,
find_application_icon, find_application_icon,
) )
from .dashboard_debug import (
RuntimeDebugInfo,
VerificationDebugInfo,
inspect_runtime_debug,
read_verification_debug,
)
from .install_status import ( from .install_status import (
GameInstallationStatus, GameInstallationStatus,
PrimaryAction, PrimaryAction,
@@ -57,7 +63,7 @@ from .news import (
) )
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
from .paths import AppPaths from .paths import AppPaths
from .settings import ensure_settings, update_settings from .settings import Settings, ensure_settings, load_settings, update_settings
from .self_update import ( from .self_update import (
LauncherUpdateCheck, LauncherUpdateCheck,
cache_launcher_update_check, cache_launcher_update_check,
@@ -94,7 +100,8 @@ DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
DASHBOARD_TRANSITION_STEPS = 9 DASHBOARD_TRANSITION_STEPS = 9
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
PRELOADER_STATUS_CHARACTERS = 64 PRELOADER_STATUS_CHARACTERS = 64
SERVER_POPULATION_DEBUG_KEY = "<Control-Shift-F12>" DASHBOARD_DEBUG_KEY = "<Control-Shift-F12>"
SERVER_STATUS_STALE_SECONDS = 5 * 60
DASHBOARD_STARTUP_COMPONENTS = frozenset( DASHBOARD_STARTUP_COMPONENTS = frozenset(
{ {
"broadcast", "broadcast",
@@ -135,6 +142,26 @@ class ServerStatusPresentation:
style: str style: str
@dataclass(frozen=True)
class ServerStatusCheck:
"""One measured attempt to retrieve official realm health."""
statuses: tuple[RealmServerStatus, ...] | None
checked_at: float
duration_seconds: float
error: str | None = None
@dataclass(frozen=True)
class GameClientDebugState:
"""Live process state exposed only in the hidden dashboard debug view."""
client_id: int
phase: str
supervisor_pid: int | None
elapsed_seconds: float
class DashboardError(RuntimeError): class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed.""" """Raised when the native dashboard cannot be displayed."""
@@ -142,32 +169,75 @@ class DashboardError(RuntimeError):
class GameClientTracker: class GameClientTracker:
"""Track dashboard-launched clients without retaining process objects in Tk.""" """Track dashboard-launched clients without retaining process objects in Tk."""
def __init__(self) -> None: def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
self._clock = clock
self._next_client_id = 1 self._next_client_id = 1
self._starting: set[int] = set() self._starting: set[int] = set()
self._running: set[int] = set() self._running: set[int] = set()
self._started_at: dict[int, float] = {}
self._running_at: dict[int, float] = {}
self._supervisor_pids: dict[int, int] = {}
def begin(self) -> int: def begin(self) -> int:
client_id = self._next_client_id client_id = self._next_client_id
self._next_client_id += 1 self._next_client_id += 1
self._starting.add(client_id) self._starting.add(client_id)
self._started_at[client_id] = self._clock()
return client_id return client_id
def attach_supervisor(self, client_id: int, pid: int) -> bool:
if pid <= 0 or (
client_id not in self._starting and client_id not in self._running
):
return False
self._supervisor_pids[client_id] = pid
return True
def mark_running(self, client_id: int) -> bool: def mark_running(self, client_id: int) -> bool:
if client_id in self._running or client_id not in self._starting: if client_id in self._running or client_id not in self._starting:
return False return False
self._starting.remove(client_id) self._starting.remove(client_id)
self._running.add(client_id) self._running.add(client_id)
self._running_at[client_id] = self._clock()
return True return True
def finish(self, client_id: int) -> str | None: def finish(self, client_id: int) -> str | None:
if client_id in self._starting: if client_id in self._starting:
self._starting.remove(client_id) self._starting.remove(client_id)
return "starting" phase = "starting"
if client_id in self._running: elif client_id in self._running:
self._running.remove(client_id) self._running.remove(client_id)
return "running" phase = "running"
else:
return None return None
self._started_at.pop(client_id, None)
self._running_at.pop(client_id, None)
self._supervisor_pids.pop(client_id, None)
return phase
def debug_states(
self,
*,
timestamp: float | None = None,
) -> tuple[GameClientDebugState, ...]:
now = self._clock() if timestamp is None else timestamp
return tuple(
GameClientDebugState(
client_id=client_id,
phase="running" if client_id in self._running else "starting",
supervisor_pid=self._supervisor_pids.get(client_id),
elapsed_seconds=max(
0.0,
now
- (
self._running_at.get(client_id, now)
if client_id in self._running
else self._started_at.get(client_id, now)
),
),
)
for client_id in sorted(self._starting | self._running)
)
@property @property
def starting_count(self) -> int: def starting_count(self) -> int:
@@ -191,6 +261,59 @@ def game_client_activity_text(running_count: int) -> str:
return f"{running_count} {label} running" return f"{running_count} {label} running"
def debug_elapsed_text(elapsed_seconds: float) -> str:
"""Format a live debug duration without sub-second UI churn."""
seconds = max(0, int(elapsed_seconds))
hours, remainder = divmod(seconds, 60 * 60)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes}m {seconds}s"
if minutes:
return f"{minutes}m {seconds}s"
return f"{seconds}s"
def operation_activity_text(
message: str,
*,
started_at: float | None,
step: int = 0,
total: int = 0,
detail: str | None = None,
timestamp: float | None = None,
) -> str:
"""Render live operation progress without dropping its elapsed timer."""
if detail is not None:
base = detail
elif total > 0:
base = f"Step {step}/{total} · {message}"
else:
return message
if started_at is None:
return base
now = time.monotonic() if timestamp is None else timestamp
elapsed = int(max(0.0, now - started_at))
return f"{base} · {elapsed}s elapsed"
def should_refresh_operation_elapsed(
*,
active: bool,
started_at: float | None,
stage_total: int,
detail: str | None,
) -> bool:
"""Return whether the dashboard has operation text with a live timer."""
return (
active
and started_at is not None
and (stage_total > 0 or detail is not None)
)
def game_sync_presentation( def game_sync_presentation(
*, *,
installed: bool, installed: bool,
@@ -224,29 +347,96 @@ def game_file_progress_status(current_file: str, verb: str) -> str:
return f"{verb} {current_file}" return f"{verb} {current_file}"
def perform_server_status_check(
*,
fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
) -> ServerStatusCheck:
"""Measure one official status request without leaking its endpoint."""
started_at = monotonic()
try:
statuses = fetcher()
except ServerStatusError as error:
duration_seconds = max(0.0, monotonic() - started_at)
return ServerStatusCheck(
statuses=None,
checked_at=wall_clock(),
duration_seconds=duration_seconds,
error=str(error),
)
duration_seconds = max(0.0, monotonic() - started_at)
return ServerStatusCheck(
statuses=statuses,
checked_at=wall_clock(),
duration_seconds=duration_seconds,
)
def server_status_check_text(
check: ServerStatusCheck,
*,
timestamp: float | None = None,
) -> str:
"""Render fetch age, latency, and freshness for the hidden debug view."""
now = time.time() if timestamp is None else timestamp
age_seconds = max(0.0, now - check.checked_at)
whole_seconds = int(age_seconds)
if whole_seconds < 60:
age = f"{whole_seconds}s"
elif whole_seconds < 60 * 60:
age = f"{whole_seconds // 60}m"
elif whole_seconds < 24 * 60 * 60:
age = f"{whole_seconds // (60 * 60)}h"
else:
age = f"{whole_seconds // (24 * 60 * 60)}d"
latency_milliseconds = max(0, round(check.duration_seconds * 1000))
timing = f"checked {age} ago · {latency_milliseconds} ms"
if check.error is not None:
return f"failed · {timing} · {check.error}"
freshness = "stale" if age_seconds >= SERVER_STATUS_STALE_SECONDS else "fresh"
return f"{freshness} · {timing}"
def _server_component_text(name: str, online: bool | None) -> str:
if online is True:
state = "up"
elif online is False:
state = "down"
else:
state = "unknown"
return f"{name} {state}"
def server_status_presentation( def server_status_presentation(
status: RealmServerStatus, status: RealmServerStatus,
*, *,
show_player_count: bool = False, show_debug_details: bool = False,
) -> ServerStatusPresentation: ) -> ServerStatusPresentation:
if status.online is True: if status.online is True:
players = "" text = f"● {status.name}"
if show_player_count and status.players_online is not None: style = "ServerOnline.TLabel"
elif status.online is False:
text = f"● {status.name} · offline"
style = "ServerOffline.TLabel"
else:
text = f"○ {status.name} · status unknown"
style = "ServerUnknown.TLabel"
if show_debug_details:
details: list[str] = []
if status.players_online is not None:
label = "player" if status.players_online == 1 else "players" label = "player" if status.players_online == 1 else "players"
players = f" · {status.players_online} {label}" details.append(f"{status.players_online} {label}")
return ServerStatusPresentation( details.extend(
text=f"● {status.name}{players}", (
style="ServerOnline.TLabel", _server_component_text("login", status.login_online),
_server_component_text("game", status.game_online),
) )
if status.online is False:
return ServerStatusPresentation(
text=f"● {status.name} · offline",
style="ServerOffline.TLabel",
)
return ServerStatusPresentation(
text=f"○ {status.name} · status unknown",
style="ServerUnknown.TLabel",
) )
text = " · ".join((text, *details))
return ServerStatusPresentation(text=text, style=style)
def patch_change_colors(type_key: str) -> tuple[str, str]: def patch_change_colors(type_key: str) -> tuple[str, str]:
@@ -1257,6 +1447,12 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(side="left", fill="y", padx=(0, 8)) ).pack(side="left", fill="y", padx=(0, 8))
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame") server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
server_status_content.pack(side="left", fill="y", anchor="w") server_status_content.pack(side="left", fill="y", anchor="w")
server_status_debug_text = tk.StringVar(value="")
ttk.Label(
server_status_row,
textvariable=server_status_debug_text,
style="Muted.TLabel",
).pack(side="right", anchor="e", padx=(8, 0))
ttk.Label( ttk.Label(
server_status_content, server_status_content,
text="Checking official server status…", text="Checking official server status…",
@@ -1382,7 +1578,11 @@ def run_dashboard(paths: AppPaths) -> None:
preloader_status: tk.StringVar | None = None preloader_status: tk.StringVar | None = None
preloader_progress: ttk.Progressbar | None = None preloader_progress: ttk.Progressbar | None = None
settings_window: tk.Toplevel | None = None settings_window: tk.Toplevel | None = None
dashboard_debug_window: tk.Toplevel | None = None
dashboard_debug_output: tk.Text | None = None
game_clients = GameClientTracker() game_clients = GameClientTracker()
client_runtime_debug: dict[int, RuntimeDebugInfo] = {}
client_launch_settings: dict[int, Settings] = {}
active_game_launch_id: int | None = None active_game_launch_id: int | None = None
operation_active = False operation_active = False
game_window_active = False game_window_active = False
@@ -1391,15 +1591,22 @@ def run_dashboard(paths: AppPaths) -> None:
operation_stage = 0 operation_stage = 0
operation_stage_total = 0 operation_stage_total = 0
operation_message = "" operation_message = ""
operation_progress_detail: str | None = None
operation_refreshes_compatibility = False operation_refreshes_compatibility = False
operation_file_progress_verb = "Updating" operation_file_progress_verb = "Updating"
last_elapsed_second = -1 last_elapsed_second = -1
official_snapshot: DepotSnapshot | None = None official_snapshot: DepotSnapshot | None = None
latest_installation_status: GameInstallationStatus | None = None
available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
preferred_realm = startup_settings.realm preferred_realm = startup_settings.realm
latest_server_statuses: tuple[RealmServerStatus, ...] | None = None latest_server_statuses: tuple[RealmServerStatus, ...] | None = None
latest_server_status_check: ServerStatusCheck | None = None
server_status_placeholder = "Checking official server status…" server_status_placeholder = "Checking official server status…"
show_server_population = False show_dashboard_debug = False
dashboard_runtime_debug: RuntimeDebugInfo | None = None
dashboard_verification_debug: VerificationDebugInfo | None = None
dashboard_debug_state_error: str | None = None
last_dashboard_debug_render = ""
launcher_update_check: LauncherUpdateCheck | None = None launcher_update_check: LauncherUpdateCheck | None = None
runtime_compatibility: RuntimeCompatibility | None = None runtime_compatibility: RuntimeCompatibility | None = None
compatibility_check_generation = 0 compatibility_check_generation = 0
@@ -1456,6 +1663,7 @@ def run_dashboard(paths: AppPaths) -> None:
button.configure(state="disabled") button.configure(state="disabled")
def update_progress(event: Mapping[str, object]) -> None: def update_progress(event: Mapping[str, object]) -> None:
nonlocal operation_progress_detail
phase = event.get("phase") phase = event.get("phase")
files_completed = _as_int(event.get("files_completed")) files_completed = _as_int(event.get("files_completed"))
files_total = _as_int(event.get("files_total")) files_total = _as_int(event.get("files_total"))
@@ -1471,7 +1679,7 @@ def run_dashboard(paths: AppPaths) -> None:
progress_bar.configure(mode="determinate") progress_bar.configure(mode="determinate")
if bytes_total: if bytes_total:
progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total)) progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total))
progress_text.set( operation_progress_detail = (
f"{files_completed}/{files_total} files · " f"{files_completed}/{files_total} files · "
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}" f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}"
) )
@@ -1484,9 +1692,10 @@ def run_dashboard(paths: AppPaths) -> None:
speed_text.set("") speed_text.set("")
else: else:
progress_bar.configure(maximum=3, value=3) progress_bar.configure(maximum=3, value=3)
progress_text.set("Game files are already up to date.") operation_progress_detail = "Game files are already up to date."
transfer_rate.reset() transfer_rate.reset()
speed_text.set("") speed_text.set("")
progress_text.set(activity_text())
if isinstance(current_file, str) and current_file: if isinstance(current_file, str) and current_file:
status.set( status.set(
game_file_progress_status(current_file, operation_file_progress_verb) game_file_progress_status(current_file, operation_file_progress_verb)
@@ -1495,19 +1704,21 @@ def run_dashboard(paths: AppPaths) -> None:
status.set("Game files are up to date and verified.") status.set("Game files are up to date and verified.")
def activity_text() -> str: def activity_text() -> str:
if operation_started_at is None or operation_stage_total <= 0: return operation_activity_text(
return operation_message operation_message,
elapsed = int(time.monotonic() - operation_started_at) started_at=operation_started_at,
return ( step=operation_stage,
f"Step {operation_stage}/{operation_stage_total} · " total=operation_stage_total,
f"{operation_message} · {elapsed}s elapsed" detail=operation_progress_detail,
) )
def set_operation_stage(step: int, total: int, message: str) -> None: def set_operation_stage(step: int, total: int, message: str) -> None:
nonlocal operation_stage, operation_stage_total, operation_message nonlocal operation_stage, operation_stage_total, operation_message
nonlocal operation_progress_detail
operation_stage = min(max(step, 0), total) operation_stage = min(max(step, 0), total)
operation_stage_total = max(total, 1) operation_stage_total = max(total, 1)
operation_message = message operation_message = message
operation_progress_detail = None
transfer_rate.reset() transfer_rate.reset()
speed_text.set("") speed_text.set("")
progress_bar.stop() progress_bar.stop()
@@ -1522,12 +1733,14 @@ def run_dashboard(paths: AppPaths) -> None:
def update_elapsed_indicator() -> None: def update_elapsed_indicator() -> None:
nonlocal last_elapsed_second nonlocal last_elapsed_second
if ( if not should_refresh_operation_elapsed(
not operation_active active=operation_active,
or operation_started_at is None started_at=operation_started_at,
or operation_stage_total != LAUNCH_STAGE_TOTAL stage_total=operation_stage_total,
detail=operation_progress_detail,
): ):
return return
assert operation_started_at is not None
elapsed = int(time.monotonic() - operation_started_at) elapsed = int(time.monotonic() - operation_started_at)
if elapsed == last_elapsed_second: if elapsed == last_elapsed_second:
return return
@@ -1619,6 +1832,7 @@ def run_dashboard(paths: AppPaths) -> None:
nonlocal operation_stage nonlocal operation_stage
nonlocal operation_stage_total nonlocal operation_stage_total
nonlocal operation_message nonlocal operation_message
nonlocal operation_progress_detail
nonlocal operation_refreshes_compatibility nonlocal operation_refreshes_compatibility
nonlocal operation_file_progress_verb nonlocal operation_file_progress_verb
nonlocal last_elapsed_second nonlocal last_elapsed_second
@@ -1628,6 +1842,7 @@ def run_dashboard(paths: AppPaths) -> None:
operation_stage = 0 operation_stage = 0
operation_stage_total = 0 operation_stage_total = 0
operation_message = "" operation_message = ""
operation_progress_detail = None
operation_refreshes_compatibility = game_files_may_change operation_refreshes_compatibility = game_files_may_change
operation_file_progress_verb = file_progress_verb operation_file_progress_verb = file_progress_verb
last_elapsed_second = -1 last_elapsed_second = -1
@@ -1669,6 +1884,10 @@ def run_dashboard(paths: AppPaths) -> None:
else: else:
events.put(("game_done", (game_client_id, 1))) events.put(("game_done", (game_client_id, 1)))
return return
if game_client_id is not None:
events.put(
("game_supervisor_started", (game_client_id, process.pid))
)
if process.stdout is not None: if process.stdout is not None:
for line in process.stdout: for line in process.stdout:
if line.startswith("STATUS "): if line.startswith("STATUS "):
@@ -2204,7 +2423,19 @@ def run_dashboard(paths: AppPaths) -> None:
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
def update_server_status_debug_text() -> None:
value = ""
if show_dashboard_debug:
value = (
server_status_check_text(latest_server_status_check)
if latest_server_status_check is not None
else "status check pending"
)
if server_status_debug_text.get() != value:
server_status_debug_text.set(value)
def render_server_status() -> None: def render_server_status() -> None:
update_server_status_debug_text()
for child in server_status_content.winfo_children(): for child in server_status_content.winfo_children():
child.destroy() child.destroy()
if latest_server_statuses is None: if latest_server_statuses is None:
@@ -2232,7 +2463,7 @@ def run_dashboard(paths: AppPaths) -> None:
for index, server in enumerate(visible): for index, server in enumerate(visible):
presentation = server_status_presentation( presentation = server_status_presentation(
server, server,
show_player_count=show_server_population, show_debug_details=show_dashboard_debug,
) )
ttk.Label( ttk.Label(
server_status_content, server_status_content,
@@ -2245,24 +2476,315 @@ def run_dashboard(paths: AppPaths) -> None:
) )
def show_server_status( def show_server_status(
statuses: tuple[RealmServerStatus, ...] | None, check: ServerStatusCheck,
*,
placeholder: str = "Server status unavailable.",
) -> None: ) -> None:
nonlocal latest_server_statuses, server_status_placeholder nonlocal latest_server_statuses, latest_server_status_check
latest_server_statuses = statuses nonlocal server_status_placeholder
server_status_placeholder = placeholder latest_server_status_check = check
latest_server_statuses = check.statuses
server_status_placeholder = (
"Server status unavailable."
if check.error is not None
else "Official status is unavailable for this realm."
)
render_server_status() render_server_status()
def toggle_server_population_debug(_event: object | None = None) -> str: def refresh_dashboard_debug_disk_state() -> None:
nonlocal show_server_population nonlocal dashboard_runtime_debug, dashboard_verification_debug
show_server_population = not show_server_population nonlocal dashboard_debug_state_error
dashboard_debug_state_error = None
try:
settings = load_settings(paths)
except (OSError, ValueError) as error:
dashboard_runtime_debug = None
dashboard_debug_state_error = f"settings unavailable: {error}"
else:
dashboard_runtime_debug = inspect_runtime_debug(paths, settings)
try:
dashboard_verification_debug = read_verification_debug(paths)
except OSError as error:
dashboard_verification_debug = None
detail = f"verification state unavailable: {error}"
dashboard_debug_state_error = (
f"{dashboard_debug_state_error}; {detail}"
if dashboard_debug_state_error
else detail
)
def current_debug_start_option() -> DepotStartOption | None:
selected_name = realm_value.get().casefold()
return next(
(
option
for option in available_start_options
if option.name.casefold() == selected_name
),
None,
)
def dashboard_debug_report() -> str:
lines = ["PT-LINUX DASHBOARD DEBUG", ""]
lines.extend(("SERVERS", "-------"))
lines.append(
server_status_check_text(latest_server_status_check)
if latest_server_status_check is not None
else "status check pending"
)
if latest_server_statuses:
lines.extend(
server_status_presentation(
server,
show_debug_details=True,
).text
for server in latest_server_statuses
)
else:
lines.append(server_status_placeholder)
lines.extend(("", "LAUNCH", "------"))
start_option = current_debug_start_option()
lines.append(f"Realm: {realm_value.get() or 'unknown'}")
lines.append(
f"Executable: {start_option.path if start_option is not None else 'unknown'}"
)
lines.append(
f"Arguments: {start_option.arguments if start_option is not None else 'unknown'}"
)
if official_snapshot is None:
lines.append("Official depot: unavailable")
else:
lines.append(
f"Official depot: build {official_snapshot.build} · "
f"version {official_snapshot.version}"
)
if latest_installation_status is None:
lines.append("Installed depot: unchecked")
else:
local_build = latest_installation_status.local_build
lines.append(
f"Installed depot: {local_build if local_build is not None else 'unknown'}"
)
client_states = game_clients.debug_states()
active_runtime = next(
(
client_runtime_debug[state.client_id]
for state in client_states
if state.client_id in client_runtime_debug
),
dashboard_runtime_debug,
)
lines.extend(("", "RUNTIME", "-------"))
lines.append(
"Source: active client launch snapshot"
if client_states and active_runtime is not dashboard_runtime_debug
else "Source: next-launch selection"
)
if active_runtime is None:
lines.append(dashboard_debug_state_error or "Runtime state unavailable")
else:
lines.append(
f"Requested / selected: {active_runtime.requested_kind} / "
f"{active_runtime.selected_kind or 'unavailable'}"
)
lines.append(f"Runtime executable: {active_runtime.executable or 'unavailable'}")
lines.append(f"Proton: {active_runtime.proton_path or 'not applicable'}")
lines.append(f"Prefix: {active_runtime.prefix or 'unavailable'}")
lines.append(
f"Synchronization: {active_runtime.sync_configured} / "
f"{active_runtime.sync_effective or 'runtime managed'}"
)
lines.append(
f"Prepared runtime: {active_runtime.prepared_kind or 'not recorded'} · "
f"{active_runtime.prepared_at or 'time unavailable'}"
)
if active_runtime.prepared_proton_path is not None:
lines.append(f"Prepared Proton: {active_runtime.prepared_proton_path}")
marker_match = active_runtime.marker_matches_selection
lines.append(
"Preparation marker matches selection: "
+ (
"yes"
if marker_match is True
else "no"
if marker_match is False
else "unknown"
)
)
lines.append(
f"DXVK D3D9 override: "
f"{active_runtime.dxvk_d3d9_override or 'not recorded'}"
)
lines.append(f"DXVK state cache: {paths.dxvk_cache}")
if active_runtime.error:
lines.append(f"Runtime selection error: {active_runtime.error}")
lines.extend(("", "CLIENTS", "-------"))
lines.append(
f"Starting: {game_clients.starting_count} · "
f"running: {game_clients.running_count}"
)
if not client_states:
lines.append("No active game clients")
for state in client_states:
pid = str(state.supervisor_pid) if state.supervisor_pid is not None else "pending"
lines.append(
f"Client {state.client_id}: {state.phase} · "
f"{debug_elapsed_text(state.elapsed_seconds)} · supervisor PID {pid}"
)
client_runtime = client_runtime_debug.get(state.client_id)
if client_runtime is not None:
lines.append(
f" runtime {client_runtime.selected_kind or 'unavailable'} · "
f"prefix {client_runtime.prefix or 'unavailable'}"
)
if state.client_id == active_game_launch_id and operation_stage_total > 0:
lines.append(
f" launch stage {operation_stage}/{operation_stage_total} · "
f"{operation_message or 'waiting'}"
)
if operation_active and active_game_launch_id is None:
lines.append(
f"Launcher operation: {operation_stage}/{operation_stage_total} · "
f"{operation_message or status.get()}"
)
lines.extend(("", "VERIFICATION", "------------"))
verification = dashboard_verification_debug
if verification is None:
lines.append(dashboard_debug_state_error or "Verification state unavailable")
else:
lines.append(f"Imported-tree trust: {verification.import_state}")
if verification.import_verified_at is not None:
lines.append(
f"Import verified: build "
f"{verification.import_build if verification.import_build is not None else 'unknown'} · "
f"{verification.import_verified_at}"
)
lines.append(
f"Last full verification: "
f"{verification.last_full_verification_at or 'not recorded'}"
)
lines.append(
f"Last depot sync: {verification.last_sync_at or 'not recorded'} · "
f"{verification.mode or 'mode unknown'}"
)
lines.append(
f"Cached integrity: build "
f"{verification.build if verification.build is not None else 'unknown'} · "
f"version {verification.version or 'unknown'} · "
f"{verification.files_total if verification.files_total is not None else '?'} files"
)
lines.append(
"Last sync changes: "
f"downloaded {verification.files_downloaded if verification.files_downloaded is not None else '?'} · "
f"deleted {verification.files_deleted if verification.files_deleted is not None else '?'} · "
f"created {verification.files_created if verification.files_created is not None else '?'} · "
f"quarantined {verification.files_quarantined if verification.files_quarantined is not None else '?'}"
)
if latest_installation_status is not None:
missing = latest_installation_status.missing_files
lines.append(
"Required files: complete"
if not missing
else "Required files missing: " + ", ".join(str(path) for path in missing)
)
return "\n".join(lines) + "\n"
def update_dashboard_debug_view(*, force: bool = False) -> None:
nonlocal last_dashboard_debug_render
if (
not show_dashboard_debug
or dashboard_debug_output is None
or not dashboard_debug_output.winfo_exists()
):
return
rendered = dashboard_debug_report()
if not force and rendered == last_dashboard_debug_render:
return
position = dashboard_debug_output.yview()[0]
dashboard_debug_output.configure(state="normal")
dashboard_debug_output.delete("1.0", "end")
dashboard_debug_output.insert("1.0", rendered)
dashboard_debug_output.configure(state="disabled")
dashboard_debug_output.yview_moveto(position)
last_dashboard_debug_render = rendered
def ensure_dashboard_debug_window() -> None:
nonlocal dashboard_debug_window, dashboard_debug_output
if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists():
return
window = tk.Toplevel(root)
dashboard_debug_window = window
window.title("PristonTale EU — Debug View")
window.geometry("900x640")
window.minsize(700, 440)
window.configure(background="#0c0908")
debug_header = tk.Frame(window, background="#24170f", padx=16, pady=12)
debug_header.pack(fill="x")
tk.Label(
debug_header,
text="DASHBOARD DEBUG VIEW",
background="#24170f",
foreground="#f5d18b",
font=("Sans", 10, "bold"),
).pack(anchor="w")
tk.Label(
debug_header,
text="Read-only live launcher, runtime, client, and trust state",
background="#24170f",
foreground="#c9baaa",
font=("Sans", 9),
).pack(anchor="w", pady=(2, 0))
body = tk.Frame(window, background="#0c0908")
body.pack(fill="both", expand=True, padx=12, pady=12)
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=1)
output = tk.Text(
body,
background="#100d0c",
foreground="#ded1c4",
insertbackground="#f8fafc",
font=("Monospace", 10),
wrap="none",
state="disabled",
padx=12,
pady=10,
relief="flat",
)
dashboard_debug_output = output
vertical = ttk.Scrollbar(body, orient="vertical", command=output.yview)
horizontal = ttk.Scrollbar(body, orient="horizontal", command=output.xview)
output.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set)
output.grid(row=0, column=0, sticky="nsew")
vertical.grid(row=0, column=1, sticky="ns")
horizontal.grid(row=1, column=0, sticky="ew")
window.protocol("WM_DELETE_WINDOW", lambda: set_dashboard_debug(False))
def set_dashboard_debug(enabled: bool) -> None:
nonlocal show_dashboard_debug, last_dashboard_debug_render
show_dashboard_debug = enabled
last_dashboard_debug_render = ""
if enabled:
refresh_dashboard_debug_disk_state()
ensure_dashboard_debug_window()
assert dashboard_debug_window is not None
dashboard_debug_window.deiconify()
dashboard_debug_window.lift()
dashboard_debug_window.focus_set()
else:
if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists():
dashboard_debug_window.withdraw()
render_server_status() render_server_status()
update_dashboard_debug_view(force=True)
def toggle_dashboard_debug(_event: object | None = None) -> str:
set_dashboard_debug(not show_dashboard_debug)
return "break" return "break"
root.bind( root.bind_all(
SERVER_POPULATION_DEBUG_KEY, DASHBOARD_DEBUG_KEY,
toggle_server_population_debug, toggle_dashboard_debug,
add="+", add="+",
) )
@@ -2320,7 +2842,7 @@ def run_dashboard(paths: AppPaths) -> None:
*, *,
update_activity: bool = True, update_activity: bool = True,
) -> GameInstallationStatus: ) -> GameInstallationStatus:
nonlocal official_snapshot nonlocal official_snapshot, latest_installation_status
if snapshot is not None: if snapshot is not None:
official_snapshot = snapshot official_snapshot = snapshot
installation = inspect_game_installation( installation = inspect_game_installation(
@@ -2332,6 +2854,7 @@ def run_dashboard(paths: AppPaths) -> None:
else None else None
), ),
) )
latest_installation_status = installation
additional_client_ready = game_clients.running_count > 0 and installation.installed additional_client_ready = game_clients.running_count > 0 and installation.installed
client_activity_text.set(game_client_activity_text(game_clients.running_count)) client_activity_text.set(game_client_activity_text(game_clients.running_count))
play_button.configure( play_button.configure(
@@ -2463,12 +2986,7 @@ def run_dashboard(paths: AppPaths) -> None:
events.put(("patches", fetch_patch_notes(paths.patches_cache))) events.put(("patches", fetch_patch_notes(paths.patches_cache)))
def server_status_worker() -> None: def server_status_worker() -> None:
try: events.put(("server_status_check", perform_server_status_check()))
statuses = fetch_server_status()
except ServerStatusError as status_error:
events.put(("server_status_error", str(status_error)))
else:
events.put(("server_status", statuses))
def version_worker() -> None: def version_worker() -> None:
try: try:
@@ -2554,6 +3072,9 @@ def run_dashboard(paths: AppPaths) -> None:
refresh_runtime_compatibility(update_activity=returncode == 0) refresh_runtime_compatibility(update_activity=returncode == 0)
else: else:
refresh_primary_action(update_activity=returncode == 0) refresh_primary_action(update_activity=returncode == 0)
if show_dashboard_debug:
refresh_dashboard_debug_disk_state()
update_dashboard_debug_view(force=True)
if returncode != 0: if returncode != 0:
status.set(f"Operation failed (exit {returncode})") status.set(f"Operation failed (exit {returncode})")
progress_text.set("See the activity log for details.") progress_text.set("See the activity log for details.")
@@ -2596,6 +3117,14 @@ def run_dashboard(paths: AppPaths) -> None:
message = value.get("message") message = value.get("message")
if step > 0 and total > 0 and isinstance(message, str) and message: if step > 0 and total > 0 and isinstance(message, str) and message:
set_operation_stage(step, total, message) set_operation_stage(step, total, message)
elif (
kind == "game_supervisor_started"
and isinstance(value, tuple)
and len(value) == 2
and isinstance(value[0], int)
and isinstance(value[1], int)
):
game_clients.attach_supervisor(value[0], value[1])
elif ( elif (
kind == "game_operation_status" kind == "game_operation_status"
and isinstance(value, tuple) and isinstance(value, tuple)
@@ -2610,6 +3139,12 @@ def run_dashboard(paths: AppPaths) -> None:
if step > 0 and total > 0 and isinstance(message, str) and message: if step > 0 and total > 0 and isinstance(message, str) and message:
set_operation_stage(step, total, message) set_operation_stage(step, total, message)
elif kind == "game_started" and isinstance(value, int): elif kind == "game_started" and isinstance(value, int):
launch_settings = client_launch_settings.get(value)
if launch_settings is not None:
client_runtime_debug[value] = inspect_runtime_debug(
paths,
launch_settings,
)
progress_bar.stop() progress_bar.stop()
status.set(f"Game client {value} started; preparing its window…") status.set(f"Game client {value} started; preparing its window…")
elif kind == "game_window_ready" and isinstance(value, int): elif kind == "game_window_ready" and isinstance(value, int):
@@ -2625,6 +3160,8 @@ def run_dashboard(paths: AppPaths) -> None:
): ):
client_id, returncode = value client_id, returncode = value
phase = game_clients.finish(client_id) phase = game_clients.finish(client_id)
client_runtime_debug.pop(client_id, None)
client_launch_settings.pop(client_id, None)
game_window_active = game_clients.running_count > 0 game_window_active = game_clients.running_count > 0
append_log(f"Client {client_id} exited with status {returncode}.") append_log(f"Client {client_id} exited with status {returncode}.")
if phase == "starting": if phase == "starting":
@@ -2645,28 +3182,28 @@ def run_dashboard(paths: AppPaths) -> None:
progress_text.set("Click Play to start a new client.") progress_text.set("Click Play to start a new client.")
root.deiconify() root.deiconify()
root.lift() root.lift()
if show_dashboard_debug:
refresh_dashboard_debug_disk_state()
elif kind == "broadcast": elif kind == "broadcast":
show_broadcast(value if isinstance(value, Broadcast) else None) show_broadcast(value if isinstance(value, Broadcast) else None)
mark_startup_component_ready( mark_startup_component_ready(
"broadcast", "broadcast",
"Broadcast ready", "Broadcast ready",
) )
elif ( elif kind == "server_status_check" and isinstance(
kind == "server_status" value,
and isinstance(value, tuple) ServerStatusCheck,
and all(isinstance(item, RealmServerStatus) for item in value)
): ):
show_server_status(value) show_server_status(value)
if value.error is not None:
append_log(f"Server status check unavailable: {value.error}")
mark_startup_component_ready( mark_startup_component_ready(
"server_status", "server_status",
"Server status ready", (
) "Server status ready"
elif kind == "server_status_error" and isinstance(value, str): if value.error is None
show_server_status(None) else "Server status check complete"
append_log(f"Server status check unavailable: {value}") ),
mark_startup_component_ready(
"server_status",
"Server status check complete",
) )
elif kind == "news" and isinstance(value, tuple): elif kind == "news" and isinstance(value, tuple):
show_news(value) show_news(value)
@@ -2773,6 +3310,8 @@ def run_dashboard(paths: AppPaths) -> None:
if speed_text.get() != "0 B/s" and transfer_rate.is_idle(): if speed_text.get() != "0 B/s" and transfer_rate.is_idle():
speed_text.set("0 B/s") speed_text.set("0 B/s")
update_elapsed_indicator() update_elapsed_indicator()
update_server_status_debug_text()
update_dashboard_debug_view()
poll_delay = ( poll_delay = (
ACTIVE_POLL_INTERVAL_MILLISECONDS ACTIVE_POLL_INTERVAL_MILLISECONDS
if not startup_transition_started if not startup_transition_started
@@ -2853,7 +3392,19 @@ def run_dashboard(paths: AppPaths) -> None:
parent=root, parent=root,
) )
return return
launch_settings: Settings | None = None
runtime_debug: RuntimeDebugInfo | None = None
try:
loaded_launch_settings = load_settings(paths)
except (OSError, ValueError) as settings_error:
append_log(f"Could not inspect launch runtime settings: {settings_error}")
else:
launch_settings = loaded_launch_settings
runtime_debug = inspect_runtime_debug(paths, loaded_launch_settings)
client_id = game_clients.begin() client_id = game_clients.begin()
if runtime_debug is not None and launch_settings is not None:
client_runtime_debug[client_id] = runtime_debug
client_launch_settings[client_id] = launch_settings
active_game_launch_id = client_id active_game_launch_id = client_id
run_operation( run_operation(
f"Starting PristonTale EU {realm_value.get()} client {client_id}…", f"Starting PristonTale EU {realm_value.get()} client {client_id}…",
@@ -3077,6 +3628,9 @@ def run_dashboard(paths: AppPaths) -> None:
) )
status.set("Settings saved. They will be used on the next game launch.") status.set("Settings saved. They will be used on the next game launch.")
progress_text.set("Open Settings again at any time to adjust the launcher.") progress_text.set("Open Settings again at any time to adjust the launcher.")
if show_dashboard_debug:
refresh_dashboard_debug_disk_state()
update_dashboard_debug_view(force=True)
close_settings() close_settings()
display_card = ttk.LabelFrame( display_card = ttk.LabelFrame(
@@ -3224,6 +3778,7 @@ def run_dashboard(paths: AppPaths) -> None:
# Closing the window therefore minimizes the dashboard until all clients # Closing the window therefore minimizes the dashboard until all clients
# and any active launcher operation have finished. # and any active launcher operation have finished.
if operation_active or game_clients.active_count: if operation_active or game_clients.active_count:
set_dashboard_debug(False)
root.iconify() root.iconify()
return return
root.destroy() root.destroy()