From 94f2260f43b835ba5b65362afb9d4e1710aae470 Mon Sep 17 00:00:00 2001 From: m4ko Date: Sun, 30 Aug 2026 12:32:39 +0200 Subject: [PATCH] Added support for multiple clients to be ran at the same time. --- ptlinux/cli.py | 81 +++++++++--- ptlinux/locking.py | 112 ++++++++++++++-- ptlinux/runtime.py | 10 ++ ptlinux/ui.py | 314 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 453 insertions(+), 64 deletions(-) diff --git a/ptlinux/cli.py b/ptlinux/cli.py index 0bd1f71..3016c69 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -10,7 +10,7 @@ import json from pathlib import Path import sys import time -from typing import Sequence +from typing import Callable, Sequence from ptdiag.logs import ( LogFileState, @@ -33,12 +33,13 @@ from .launcher import ( launch_official_updater, prepare_launcher_binary, ) -from .locking import launcher_operation_lock +from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock from .login_diagnostics import inspect_login_environment from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON from .overlay import cache_compatibility_binaries, prepare_runtime_view from .paths import AppPaths from .runtime import ( + game_runtime_ready, prepare_game_runtime, run_game, select_runtime, @@ -687,14 +688,15 @@ def _maybe_import_game( def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: - if game_dir is None and game_import_requires_verification(paths): - raise LauncherError( - "the imported game has not completed its full publisher verification; " - "run pristontale-eu update" - ) - settings = ensure_settings(paths) - source = _source_game(paths, settings, game_dir) - result = prepare_runtime_view(paths, source) + with _operation_guard(paths, "prepare", False): + if game_dir is None and game_import_requires_verification(paths): + raise LauncherError( + "the imported game has not completed its full publisher verification; " + "run pristontale-eu update" + ) + settings = ensure_settings(paths) + source = _source_game(paths, settings, game_dir) + result = prepare_runtime_view(paths, source) _emit({"ok": True, "runtime_view": asdict(result)}, as_json) return 0 @@ -707,7 +709,7 @@ def _play( skip_runtime_setup: bool, as_json: bool, ) -> int: - with _operation_guard(paths, "play", dry_run): + with _play_guard(paths, dry_run) as play_lease: return _play_locked( paths, game_dir, @@ -715,6 +717,8 @@ def _play( dry_run, skip_runtime_setup, as_json, + reuse_prepared=not play_lease.preparation_allowed, + before_game=play_lease.allow_shared_clients, ) @@ -725,6 +729,9 @@ def _play_locked( dry_run: bool, skip_runtime_setup: bool, as_json: bool, + *, + reuse_prepared: bool = False, + before_game: Callable[[], None] | None = None, ) -> int: if game_dir is None and game_import_requires_verification(paths): raise LauncherError( @@ -734,16 +741,49 @@ def _play_locked( settings = _load_or_default_settings(paths, persist=not dry_run) source = _source_game(paths, settings, game_dir) overlay = None - _print_operation_status(as_json, 1, 4, "Preparing the managed game view…") - if not dry_run: + _print_operation_status( + as_json, + 1, + 4, + ( + "Reusing the active managed game view…" + if reuse_prepared + else "Preparing the managed game view…" + ), + ) + if reuse_prepared: + if game_dir is not None: + raise LauncherError( + "an additional client cannot replace the active managed game view; " + "close every client before using --game-dir" + ) + if not paths.runtime_view.is_dir(): + raise LauncherError( + "another game client is active, but the prepared runtime view is missing" + ) + elif not dry_run: overlay = prepare_runtime_view(paths, source) _print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…") selection = select_runtime(settings) runner = _runner(paths, "play", dry_run) setup_results = [] - if not skip_runtime_setup: + if reuse_prepared: + _print_operation_status( + as_json, + 3, + 4, + "Reusing the active Proton and DirectX runtime…", + ) + if not game_runtime_ready(paths, selection): + raise LauncherError( + "another game client is active, but its prepared runtime no longer " + "matches the current settings; close every client and launch again" + ) + elif not skip_runtime_setup: _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") setup_results = prepare_game_runtime(paths, selection, runner) + else: + _print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…") _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") native_log_directory = paths.game_save / "logs" native_log_baseline = snapshot_native_logs( @@ -751,6 +791,8 @@ def _play_locked( patterns=("log_*.txt",), ) game_started_ns = time.time_ns() + if before_game is not None: + before_game() try: outcome = run_game( paths, @@ -1000,10 +1042,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings: def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: - timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") + timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f") return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run) +def _play_guard( + paths: AppPaths, + dry_run: bool, +) -> AbstractContextManager[LauncherPlayLease]: + if dry_run: + return nullcontext(LauncherPlayLease.dry_run()) + return launcher_play_lock(paths.launcher_operation_lock) + + def _operation_guard( paths: AppPaths, operation: str, diff --git a/ptlinux/locking.py b/ptlinux/locking.py index 54c17e3..517c848 100644 --- a/ptlinux/locking.py +++ b/ptlinux/locking.py @@ -7,13 +7,51 @@ from datetime import datetime import fcntl import os from pathlib import Path -from typing import Iterator +from typing import Iterator, TextIO class OperationBusyError(RuntimeError): """Raised when another launcher-prefix operation is still active.""" +class LauncherPlayLease: + """Exclusive preparation lease that can become a shared game lease.""" + + def __init__( + self, + handle: TextIO | None, + *, + preparation_allowed: bool, + record_owned: bool, + ) -> None: + self._handle = handle + self.preparation_allowed = preparation_allowed + self._record_owned = record_owned + self._shared = not preparation_allowed + + @classmethod + def dry_run(cls) -> LauncherPlayLease: + return cls(None, preparation_allowed=True, record_owned=False) + + @property + def shared(self) -> bool: + return self._shared + + def allow_shared_clients(self) -> None: + """Downgrade after all runtime mutations have completed.""" + + if self._shared: + return + if self._handle is not None: + _write_owner(self._handle, "play") + fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH) + self._shared = True + + @property + def record_owned(self) -> bool: + return self._record_owned + + @contextmanager def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: lock_path.parent.mkdir(parents=True, exist_ok=True) @@ -26,13 +64,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: raise OperationBusyError( f"launcher files are busy ({owner}); wait for it to finish and try again" ) from exc - handle.seek(0) - handle.truncate() - handle.write( - f"{operation}, pid={os.getpid()}, " - f"started={datetime.now().astimezone().isoformat()}\n" - ) - handle.flush() + _write_owner(handle, operation) try: yield finally: @@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: handle.truncate() handle.flush() fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def launcher_play_lock( + lock_path: Path, + operation: str = "play", +) -> Iterator[LauncherPlayLease]: + """Serialize preparation, then allow multiple active game clients. + + The first play process acquires an exclusive lease so it may safely rebuild + the runtime view or prefix. It downgrades that lease immediately before + starting Game.exe. Later play processes then receive shared, read-only leases, + while updates and repairs remain excluded until every client exits. + """ + + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+", encoding="utf-8") as handle: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError as exc: + owner = _read_owner(handle) + raise OperationBusyError( + f"launcher files are busy ({owner}); wait for it to finish and try again" + ) from exc + lease = LauncherPlayLease( + handle, + preparation_allowed=False, + record_owned=False, + ) + else: + _write_owner(handle, f"{operation} preparation") + lease = LauncherPlayLease( + handle, + preparation_allowed=True, + record_owned=True, + ) + try: + yield lease + finally: + # A downgraded owner record remains useful while any secondary shared + # lease is active. The next exclusive operation atomically replaces it. + if lease.record_owned and not lease.shared: + handle.seek(0) + handle.truncate() + handle.flush() + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _read_owner(handle: TextIO) -> str: + handle.seek(0) + return handle.read().strip() or "unknown operation" + + +def _write_owner(handle: TextIO, operation: str) -> None: + handle.seek(0) + handle.truncate() + handle.write( + f"{operation}, pid={os.getpid()}, " + f"started={datetime.now().astimezone().isoformat()}\n" + ) + handle.flush() diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 8e9ab51..1554047 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -277,6 +277,16 @@ def prepare_game_runtime( return results +def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: + """Return whether another client may reuse the prepared runtime read-only.""" + + if selection.kind == "umu": + return native_d3d_ready(paths.umu_prefix) + if selection.kind in {"proton7", "proton7-container"}: + return _game_runtime_ready(paths, selection) + return False + + def run_game( paths: AppPaths, settings: Settings, diff --git a/ptlinux/ui.py b/ptlinux/ui.py index e80b429..57939be 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -96,6 +96,58 @@ class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" +class GameClientTracker: + """Track dashboard-launched clients without retaining process objects in Tk.""" + + def __init__(self) -> None: + self._next_client_id = 1 + self._starting: set[int] = set() + self._running: set[int] = set() + + def begin(self) -> int: + client_id = self._next_client_id + self._next_client_id += 1 + self._starting.add(client_id) + return client_id + + 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) + 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: + self._running.remove(client_id) + return "running" + return None + + @property + def starting_count(self) -> int: + return len(self._starting) + + @property + def running_count(self) -> int: + return len(self._running) + + @property + def active_count(self) -> int: + return self.starting_count + self.running_count + + +def game_client_activity_text(running_count: int) -> str: + if running_count < 0: + raise ValueError("running client count must not be negative") + if running_count == 0: + return "" + label = "client" if running_count == 1 else "clients" + return f"{running_count} {label} running" + + def launcher_update_presentation( check: LauncherUpdateCheck, ) -> LauncherUpdatePresentation: @@ -874,7 +926,19 @@ def run_dashboard(paths: AppPaths) -> None: status = tk.StringVar(value="Ready") progress_text = tk.StringVar(value="Click Play to start your journey") speed_text = tk.StringVar(value="") - ttk.Label(activity_copy, textvariable=status, style="Status.TLabel").pack(anchor="w") + client_activity_text = tk.StringVar(value="") + activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame") + activity_heading.pack(fill="x") + ttk.Label( + activity_heading, + textvariable=status, + style="Status.TLabel", + ).pack(side="left", anchor="w") + ttk.Label( + activity_heading, + textvariable=client_activity_text, + style="Muted.TLabel", + ).pack(side="right", anchor="e") progress_bar = ttk.Progressbar( activity_copy, style="Depot.Horizontal.TProgressbar", @@ -980,6 +1044,8 @@ def run_dashboard(paths: AppPaths) -> None: preloader_status: tk.StringVar | None = None preloader_progress: ttk.Progressbar | None = None settings_window: tk.Toplevel | None = None + game_clients = GameClientTracker() + active_game_launch_id: int | None = None operation_active = False game_window_active = False activity_log_lines = 0 @@ -1002,6 +1068,12 @@ def run_dashboard(paths: AppPaths) -> None: import_button, launcher_update_button, ) + game_mutation_buttons = ( + update_button, + repair_button, + import_button, + launcher_update_button, + ) def append_log(value: str) -> None: nonlocal activity_log_lines @@ -1034,6 +1106,9 @@ def run_dashboard(paths: AppPaths) -> None: new_state = "disabled" if value else "normal" for button in buttons: button.configure(state=new_state) + if not value and game_clients.running_count: + for button in game_mutation_buttons: + button.configure(state="disabled") def update_progress(event: Mapping[str, object]) -> None: phase = event.get("phase") @@ -1184,6 +1259,7 @@ def run_dashboard(paths: AppPaths) -> None: show_preloader: bool = False, restart_on_success: bool = False, game_files_may_change: bool = False, + game_client_id: int | None = None, ) -> None: nonlocal operation_active, game_window_active nonlocal operation_started_at @@ -1193,7 +1269,7 @@ def run_dashboard(paths: AppPaths) -> None: nonlocal operation_refreshes_compatibility nonlocal last_elapsed_second operation_active = True - game_window_active = False + game_window_active = game_clients.running_count > 0 operation_started_at = time.monotonic() operation_stage = 0 operation_stage_total = 0 @@ -1215,6 +1291,12 @@ def run_dashboard(paths: AppPaths) -> None: append_log(f"$ {' '.join(command)}") def worker() -> None: + def publish_output(line: str) -> None: + if game_client_id is None: + events.put(("output", line)) + else: + events.put(("output", f"[Client {game_client_id}] {line}")) + try: process = subprocess.Popen( command, @@ -1226,8 +1308,11 @@ def run_dashboard(paths: AppPaths) -> None: bufsize=1, ) except OSError as process_error: - events.put(("output", f"Could not start operation: {process_error}\n")) - events.put(("done", 1)) + publish_output(f"Could not start operation: {process_error}\n") + if game_client_id is None: + events.put(("done", 1)) + else: + events.put(("game_done", (game_client_id, 1))) return if process.stdout is not None: for line in process.stdout: @@ -1235,39 +1320,68 @@ def run_dashboard(paths: AppPaths) -> None: try: parsed_status = json.loads(line.removeprefix("STATUS ")) except json.JSONDecodeError: - events.put(("output", line)) + publish_output(line) else: if isinstance(parsed_status, dict): - events.put(("operation_status", parsed_status)) + if game_client_id is None: + events.put(("operation_status", parsed_status)) + else: + events.put( + ( + "game_operation_status", + (game_client_id, parsed_status), + ) + ) else: - events.put(("output", line)) + publish_output(line) elif line.startswith("PROGRESS "): try: parsed = json.loads(line.removeprefix("PROGRESS ")) except json.JSONDecodeError: - events.put(("output", line)) + publish_output(line) else: if isinstance(parsed, dict): progress_updates.publish(parsed) else: - events.put(("output", line)) + publish_output(line) else: - events.put(("output", line)) - if show_preloader and line.startswith("Game window created;"): + publish_output(line) + if game_client_id is not None and line.startswith("Game window created;"): events.put( - ("operation_status", { - "step": 4, - "total": LAUNCH_STAGE_TOTAL, - "message": "Game client started.", - }) + ( + "game_operation_status", + ( + game_client_id, + { + "step": 4, + "total": LAUNCH_STAGE_TOTAL, + "message": "Game client started.", + }, + ), + ) ) - events.put(("game_started", None)) - if show_preloader and line.startswith(("Game window ready:", "Rendered game frame:")): - events.put(("game_window_ready", None)) + events.put(("game_started", game_client_id)) + if game_client_id is not None and line.startswith( + ("Game window ready:", "Rendered game frame:") + ): + events.put(("game_window_ready", game_client_id)) returncode = process.wait() - events.put(("launcher_update_done" if restart_on_success else "done", returncode)) + if game_client_id is None: + events.put( + ("launcher_update_done" if restart_on_success else "done", returncode) + ) + else: + events.put(("game_done", (game_client_id, returncode))) - threading.Thread(target=worker, name="ptlinux-operation", daemon=True).start() + threading.Thread( + target=worker, + name=( + f"ptlinux-game-client-{game_client_id}" + if game_client_id is not None + else "ptlinux-operation" + ), + daemon=True, + ).start() def open_article(url: str) -> None: webbrowser.open(url) @@ -1561,18 +1675,28 @@ def run_dashboard(paths: AppPaths) -> None: else None ), ) - play_button.configure(text=installation.action.value) + 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( + text="PLAY" if additional_client_ready else installation.action.value + ) if not operation_active: play_button.configure( state=( "disabled" - if installation.action is PrimaryAction.CHECKING + if ( + installation.action is PrimaryAction.CHECKING + and not additional_client_ready + ) else "normal" ), ) if not update_activity or operation_active: return installation - if installation.action is PrimaryAction.INSTALL: + if additional_client_ready: + status.set("Ready for another account.") + progress_text.set("Click Play to open another game window.") + elif installation.action is PrimaryAction.INSTALL: status.set("Game is not installed.") progress_text.set("Click Install to download and verify the official game files.") elif installation.action is PrimaryAction.UPDATE: @@ -1640,7 +1764,11 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_button.configure( text=presentation.button_text, style=presentation.button_style, - state="normal", + state=( + "disabled" + if operation_active or game_clients.running_count + else "normal" + ), ) if presentation.badge_text is not None: launcher_update_badge.configure(text=presentation.badge_text) @@ -1687,12 +1815,20 @@ def run_dashboard(paths: AppPaths) -> None: threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() refresh_launcher_update_check() - def finish_operation(returncode: int) -> None: + def release_game_launch_controls( + client_id: int, + *, + ready: bool, + returncode: int | None = None, + ) -> None: + nonlocal active_game_launch_id nonlocal operation_active, game_window_active nonlocal operation_refreshes_compatibility, operation_started_at - refresh_compatibility = operation_refreshes_compatibility + if active_game_launch_id != client_id: + return + active_game_launch_id = None operation_active = False - game_window_active = False + game_window_active = game_clients.running_count > 0 operation_refreshes_compatibility = False operation_started_at = None progress_updates.clear() @@ -1701,7 +1837,35 @@ def run_dashboard(paths: AppPaths) -> None: progress_bar.stop() close_game_preloader() root.deiconify() - root.lift() + set_busy(False) + refresh_primary_action(update_activity=False) + if ready: + status.set("Ready for another account.") + progress_text.set("Click Play to open another game window.") + else: + root.lift() + status.set( + f"Client {client_id} failed to start" + + (f" (exit {returncode})" if returncode is not None else "") + ) + progress_text.set("See the activity log for details.") + + def finish_operation(returncode: int) -> None: + nonlocal operation_active, game_window_active + nonlocal operation_refreshes_compatibility, operation_started_at + refresh_compatibility = operation_refreshes_compatibility + operation_active = False + game_window_active = game_clients.running_count > 0 + operation_refreshes_compatibility = False + operation_started_at = None + progress_updates.clear() + transfer_rate.reset() + speed_text.set("") + progress_bar.stop() + close_game_preloader() + root.deiconify() + if not game_window_active: + root.lift() set_busy(False) if refresh_compatibility: refresh_runtime_compatibility(update_activity=returncode == 0) @@ -1749,12 +1913,55 @@ 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 in {"game_started", "game_window_ready"}: - game_window_active = True + elif ( + kind == "game_operation_status" + and isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], int) + and isinstance(value[1], Mapping) + and value[0] == active_game_launch_id + ): + step = _as_int(value[1].get("step")) + total = _as_int(value[1].get("total")) + message = value[1].get("message") + 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): progress_bar.stop() - status.set("Game client started.") - close_game_preloader() - root.iconify() + status.set(f"Game client {value} started; preparing its window…") + elif kind == "game_window_ready" and isinstance(value, int): + if game_clients.mark_running(value): + game_window_active = True + release_game_launch_controls(value, ready=True) + elif ( + kind == "game_done" + and isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], int) + and isinstance(value[1], int) + ): + client_id, returncode = value + phase = game_clients.finish(client_id) + game_window_active = game_clients.running_count > 0 + append_log(f"Client {client_id} exited with status {returncode}.") + if phase == "starting": + release_game_launch_controls( + client_id, + ready=False, + returncode=returncode, + ) + elif phase == "running" and not operation_active: + set_busy(False) + refresh_primary_action(update_activity=False) + count = game_clients.running_count + if count: + status.set("Ready for another account.") + progress_text.set("Click Play to open another game window.") + else: + status.set("All game clients have closed.") + progress_text.set("Click Play to start a new client.") + root.deiconify() + root.lift() elif kind == "broadcast": show_broadcast(value if isinstance(value, Broadcast) else None) mark_startup_component_ready( @@ -1834,7 +2041,11 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_button.configure( text="CHECK LAUNCHER", style="Secondary.TButton", - state="normal", + state=( + "disabled" + if operation_active or game_clients.running_count + else "normal" + ), ) launcher_update_badge.pack_forget() append_log(f"Launcher update check unavailable: {value}") @@ -1915,14 +2126,24 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_button.configure(command=run_launcher_update) + def start_game_client() -> None: + nonlocal active_game_launch_id + client_id = game_clients.begin() + active_game_launch_id = client_id + run_operation( + f"Starting PristonTale EU client {client_id}…", + command_for("play"), + show_preloader=True, + game_client_id=client_id, + ) + def run_primary_action() -> None: installation = refresh_primary_action(update_activity=False) - if installation.action is PrimaryAction.PLAY: - run_operation( - "Starting PristonTale EU…", - command_for("play"), - show_preloader=True, - ) + if ( + installation.action is PrimaryAction.PLAY + or (game_clients.running_count > 0 and installation.installed) + ): + start_game_client() elif installation.action is PrimaryAction.INSTALL: run_operation( "Installing official game files…", @@ -2273,6 +2494,17 @@ def run_dashboard(paths: AppPaths) -> None: import_button.configure(command=choose_import_source) log_toggle.configure(command=toggle_log) + def close_dashboard() -> None: + # Keep draining each client process while it owns a shared runtime lease. + # 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: + root.iconify() + return + root.destroy() + + root.protocol("WM_DELETE_WINDOW", close_dashboard) + def update_news_viewport(_event: object | None = None) -> None: news_canvas.configure(scrollregion=news_canvas.bbox("all"))