Added a debug system and fixed thread issues with update / repair.
This commit is contained in:
687
ptlinux/ui.py
687
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 = "<Control-Shift-F12>"
|
||||
DASHBOARD_DEBUG_KEY = "<Control-Shift-F12>"
|
||||
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"
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user