From fe883047e05f9df0385e2a0e602c6daba2d752f1 Mon Sep 17 00:00:00 2001 From: m4ko Date: Mon, 31 Aug 2026 20:23:23 +0200 Subject: [PATCH] Added a debug system and fixed thread issues with update / repair. --- ptlinux/ui.py | 691 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 623 insertions(+), 68 deletions(-) diff --git a/ptlinux/ui.py b/ptlinux/ui.py index a8b144c..0b00e3a 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -39,6 +39,12 @@ from .desktop_identity import ( APPLICATION_WM_CLASS, find_application_icon, ) +from .dashboard_debug import ( + RuntimeDebugInfo, + VerificationDebugInfo, + inspect_runtime_debug, + read_verification_debug, +) from .install_status import ( GameInstallationStatus, PrimaryAction, @@ -57,7 +63,7 @@ from .news import ( ) from .overlay import RuntimeCompatibility, inspect_runtime_compatibility 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 ( LauncherUpdateCheck, cache_launcher_update_check, @@ -94,7 +100,8 @@ DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEPS = 9 DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 PRELOADER_STATUS_CHARACTERS = 64 -SERVER_POPULATION_DEBUG_KEY = "" +DASHBOARD_DEBUG_KEY = "" +SERVER_STATUS_STALE_SECONDS = 5 * 60 DASHBOARD_STARTUP_COMPONENTS = frozenset( { "broadcast", @@ -135,6 +142,26 @@ class ServerStatusPresentation: 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): """Raised when the native dashboard cannot be displayed.""" @@ -142,32 +169,75 @@ class DashboardError(RuntimeError): class GameClientTracker: """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._starting: 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: client_id = self._next_client_id self._next_client_id += 1 self._starting.add(client_id) + self._started_at[client_id] = self._clock() 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: if client_id in self._running or client_id not in self._starting: return False self._starting.remove(client_id) self._running.add(client_id) + self._running_at[client_id] = self._clock() return True def finish(self, client_id: int) -> str | None: if client_id in self._starting: self._starting.remove(client_id) - return "starting" - if client_id in self._running: + phase = "starting" + elif client_id in self._running: self._running.remove(client_id) - return "running" - return None + phase = "running" + else: + 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 def starting_count(self) -> int: @@ -191,6 +261,59 @@ def game_client_activity_text(running_count: int) -> str: 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( *, installed: bool, @@ -224,29 +347,96 @@ def game_file_progress_status(current_file: str, verb: str) -> str: 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( status: RealmServerStatus, *, - show_player_count: bool = False, + show_debug_details: bool = False, ) -> ServerStatusPresentation: if status.online is True: - players = "" - if show_player_count and status.players_online is not None: + text = f"● {status.name}" + 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" - players = f" · {status.players_online} {label}" - return ServerStatusPresentation( - text=f"● {status.name}{players}", - style="ServerOnline.TLabel", + details.append(f"{status.players_online} {label}") + details.extend( + ( + _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]: @@ -1257,6 +1447,12 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(side="left", fill="y", padx=(0, 8)) server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame") 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( server_status_content, text="Checking official server status…", @@ -1382,7 +1578,11 @@ def run_dashboard(paths: AppPaths) -> None: preloader_status: tk.StringVar | None = None preloader_progress: ttk.Progressbar | 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() + client_runtime_debug: dict[int, RuntimeDebugInfo] = {} + client_launch_settings: dict[int, Settings] = {} active_game_launch_id: int | None = None operation_active = False game_window_active = False @@ -1391,15 +1591,22 @@ def run_dashboard(paths: AppPaths) -> None: operation_stage = 0 operation_stage_total = 0 operation_message = "" + operation_progress_detail: str | None = None operation_refreshes_compatibility = False operation_file_progress_verb = "Updating" last_elapsed_second = -1 official_snapshot: DepotSnapshot | None = None + latest_installation_status: GameInstallationStatus | None = None available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) preferred_realm = startup_settings.realm latest_server_statuses: tuple[RealmServerStatus, ...] | None = None + latest_server_status_check: ServerStatusCheck | None = None 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 runtime_compatibility: RuntimeCompatibility | None = None compatibility_check_generation = 0 @@ -1456,6 +1663,7 @@ def run_dashboard(paths: AppPaths) -> None: button.configure(state="disabled") def update_progress(event: Mapping[str, object]) -> None: + nonlocal operation_progress_detail phase = event.get("phase") files_completed = _as_int(event.get("files_completed")) files_total = _as_int(event.get("files_total")) @@ -1471,7 +1679,7 @@ def run_dashboard(paths: AppPaths) -> None: progress_bar.configure(mode="determinate") if 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"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}" ) @@ -1484,9 +1692,10 @@ def run_dashboard(paths: AppPaths) -> None: speed_text.set("") else: 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() speed_text.set("") + progress_text.set(activity_text()) if isinstance(current_file, str) and current_file: status.set( 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.") def activity_text() -> str: - if operation_started_at is None or operation_stage_total <= 0: - return operation_message - elapsed = int(time.monotonic() - operation_started_at) - return ( - f"Step {operation_stage}/{operation_stage_total} · " - f"{operation_message} · {elapsed}s elapsed" + return operation_activity_text( + operation_message, + started_at=operation_started_at, + step=operation_stage, + total=operation_stage_total, + detail=operation_progress_detail, ) def set_operation_stage(step: int, total: int, message: str) -> None: nonlocal operation_stage, operation_stage_total, operation_message + nonlocal operation_progress_detail operation_stage = min(max(step, 0), total) operation_stage_total = max(total, 1) operation_message = message + operation_progress_detail = None transfer_rate.reset() speed_text.set("") progress_bar.stop() @@ -1522,12 +1733,14 @@ def run_dashboard(paths: AppPaths) -> None: def update_elapsed_indicator() -> None: nonlocal last_elapsed_second - if ( - not operation_active - or operation_started_at is None - or operation_stage_total != LAUNCH_STAGE_TOTAL + if not should_refresh_operation_elapsed( + active=operation_active, + started_at=operation_started_at, + stage_total=operation_stage_total, + detail=operation_progress_detail, ): return + assert operation_started_at is not None elapsed = int(time.monotonic() - operation_started_at) if elapsed == last_elapsed_second: return @@ -1619,6 +1832,7 @@ def run_dashboard(paths: AppPaths) -> None: nonlocal operation_stage nonlocal operation_stage_total nonlocal operation_message + nonlocal operation_progress_detail nonlocal operation_refreshes_compatibility nonlocal operation_file_progress_verb nonlocal last_elapsed_second @@ -1628,6 +1842,7 @@ def run_dashboard(paths: AppPaths) -> None: operation_stage = 0 operation_stage_total = 0 operation_message = "" + operation_progress_detail = None operation_refreshes_compatibility = game_files_may_change operation_file_progress_verb = file_progress_verb last_elapsed_second = -1 @@ -1669,6 +1884,10 @@ def run_dashboard(paths: AppPaths) -> None: else: events.put(("game_done", (game_client_id, 1))) return + if game_client_id is not None: + events.put( + ("game_supervisor_started", (game_client_id, process.pid)) + ) if process.stdout is not None: for line in process.stdout: if line.startswith("STATUS "): @@ -2204,7 +2423,19 @@ def run_dashboard(paths: AppPaths) -> None: 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: + update_server_status_debug_text() for child in server_status_content.winfo_children(): child.destroy() if latest_server_statuses is None: @@ -2232,7 +2463,7 @@ def run_dashboard(paths: AppPaths) -> None: for index, server in enumerate(visible): presentation = server_status_presentation( server, - show_player_count=show_server_population, + show_debug_details=show_dashboard_debug, ) ttk.Label( server_status_content, @@ -2245,24 +2476,315 @@ def run_dashboard(paths: AppPaths) -> None: ) def show_server_status( - statuses: tuple[RealmServerStatus, ...] | None, - *, - placeholder: str = "Server status unavailable.", + check: ServerStatusCheck, ) -> None: - nonlocal latest_server_statuses, server_status_placeholder - latest_server_statuses = statuses - server_status_placeholder = placeholder + nonlocal latest_server_statuses, latest_server_status_check + nonlocal server_status_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() - def toggle_server_population_debug(_event: object | None = None) -> str: - nonlocal show_server_population - show_server_population = not show_server_population + def refresh_dashboard_debug_disk_state() -> None: + nonlocal dashboard_runtime_debug, dashboard_verification_debug + 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() + update_dashboard_debug_view(force=True) + + def toggle_dashboard_debug(_event: object | None = None) -> str: + set_dashboard_debug(not show_dashboard_debug) return "break" - root.bind( - SERVER_POPULATION_DEBUG_KEY, - toggle_server_population_debug, + root.bind_all( + DASHBOARD_DEBUG_KEY, + toggle_dashboard_debug, add="+", ) @@ -2320,7 +2842,7 @@ def run_dashboard(paths: AppPaths) -> None: *, update_activity: bool = True, ) -> GameInstallationStatus: - nonlocal official_snapshot + nonlocal official_snapshot, latest_installation_status if snapshot is not None: official_snapshot = snapshot installation = inspect_game_installation( @@ -2332,6 +2854,7 @@ def run_dashboard(paths: AppPaths) -> None: else None ), ) + latest_installation_status = installation additional_client_ready = game_clients.running_count > 0 and installation.installed client_activity_text.set(game_client_activity_text(game_clients.running_count)) play_button.configure( @@ -2463,12 +2986,7 @@ def run_dashboard(paths: AppPaths) -> None: events.put(("patches", fetch_patch_notes(paths.patches_cache))) def server_status_worker() -> None: - try: - statuses = fetch_server_status() - except ServerStatusError as status_error: - events.put(("server_status_error", str(status_error))) - else: - events.put(("server_status", statuses)) + events.put(("server_status_check", perform_server_status_check())) def version_worker() -> None: try: @@ -2554,6 +3072,9 @@ def run_dashboard(paths: AppPaths) -> None: refresh_runtime_compatibility(update_activity=returncode == 0) else: 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: status.set(f"Operation failed (exit {returncode})") progress_text.set("See the activity log for details.") @@ -2596,6 +3117,14 @@ def run_dashboard(paths: AppPaths) -> None: message = value.get("message") if step > 0 and total > 0 and isinstance(message, str) and 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 ( kind == "game_operation_status" 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: set_operation_stage(step, total, message) 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() status.set(f"Game client {value} started; preparing its window…") elif kind == "game_window_ready" and isinstance(value, int): @@ -2625,6 +3160,8 @@ def run_dashboard(paths: AppPaths) -> None: ): client_id, returncode = value 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 append_log(f"Client {client_id} exited with status {returncode}.") if phase == "starting": @@ -2645,28 +3182,28 @@ def run_dashboard(paths: AppPaths) -> None: progress_text.set("Click Play to start a new client.") root.deiconify() root.lift() + if show_dashboard_debug: + refresh_dashboard_debug_disk_state() elif kind == "broadcast": show_broadcast(value if isinstance(value, Broadcast) else None) mark_startup_component_ready( "broadcast", "Broadcast ready", ) - elif ( - kind == "server_status" - and isinstance(value, tuple) - and all(isinstance(item, RealmServerStatus) for item in value) + elif kind == "server_status_check" and isinstance( + value, + ServerStatusCheck, ): show_server_status(value) + if value.error is not None: + append_log(f"Server status check unavailable: {value.error}") mark_startup_component_ready( "server_status", - "Server status ready", - ) - elif kind == "server_status_error" and isinstance(value, str): - show_server_status(None) - append_log(f"Server status check unavailable: {value}") - mark_startup_component_ready( - "server_status", - "Server status check complete", + ( + "Server status ready" + if value.error is None + else "Server status check complete" + ), ) elif kind == "news" and isinstance(value, tuple): 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(): speed_text.set("0 B/s") update_elapsed_indicator() + update_server_status_debug_text() + update_dashboard_debug_view() poll_delay = ( ACTIVE_POLL_INTERVAL_MILLISECONDS if not startup_transition_started @@ -2853,7 +3392,19 @@ def run_dashboard(paths: AppPaths) -> None: parent=root, ) 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() + 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 run_operation( 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.") 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() display_card = ttk.LabelFrame( @@ -3224,6 +3778,7 @@ def run_dashboard(paths: AppPaths) -> None: # Closing the window therefore minimizes the dashboard until all clients # and any active launcher operation have finished. if operation_active or game_clients.active_count: + set_dashboard_debug(False) root.iconify() return root.destroy()