From 15dbd90dbb50e91876907ee00d9ef40499308f02 Mon Sep 17 00:00:00 2001 From: m4ko Date: Tue, 1 Sep 2026 22:21:54 +0200 Subject: [PATCH] Optimizations and performance boost to pre-loader for launcher and game client. --- ptlinux/__main__.py | 26 +++- ptlinux/overlay.py | 344 +++++++++++++++++++++++++++++++++++++++++--- ptlinux/ui.py | 31 ++-- 3 files changed, 370 insertions(+), 31 deletions(-) diff --git a/ptlinux/__main__.py b/ptlinux/__main__.py index faaa63b..44f5523 100644 --- a/ptlinux/__main__.py +++ b/ptlinux/__main__.py @@ -1,4 +1,28 @@ -from .cli import main +from __future__ import annotations + +import sys + + +def main() -> int: + """Dispatch the common GUI path without importing every CLI operation.""" + + arguments = sys.argv[1:] + if not arguments or arguments == ["gui"]: + from .paths import AppPaths + from .ui import run_dashboard + + try: + run_dashboard(AppPaths.from_environment()) + except KeyboardInterrupt: + return 130 + except (ValueError, OSError, RuntimeError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + + from .cli import main as cli_main + + return cli_main(arguments) raise SystemExit(main()) diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index 1f33903..b2eb551 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -44,15 +44,46 @@ PATCHED_V21_GAME_DLL_SHA256 = ( RETIRED_POST_AUTH_GAME_DLL_SHA256 = ( "93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd" ) -RUNTIME_VIEW_CONFIGURATION_VERSION = 1 +RUNTIME_VIEW_CONFIGURATION_VERSION = 2 class OverlayError(RuntimeError): """Raised when a safe runtime view cannot be built.""" +@dataclass(frozen=True) +class RuntimeFileIdentity: + """Cheap change detector for one previously hash-verified runtime input.""" + + name: str + device: int + inode: int + mode: int + size: int + mtime_ns: int + ctime_ns: int + + @dataclass(frozen=True) class OverlayResult: + source_game: str + runtime_view: str + source_scene_sha256: str + runtime_scene_sha256: str + scene_action: str + binary_action: str + game_exe_sha256: str + game_dll_sha256: str + input_identities: tuple[RuntimeFileIdentity, ...] + runtime_scene_identity: RuntimeFileIdentity + created_at: str + links_created: int + + +@dataclass(frozen=True) +class LegacyOverlayResult: + """Version-one runtime manifest retained only for a one-time fast migration.""" + source_game: str runtime_view: str source_scene_sha256: str @@ -94,8 +125,9 @@ def inspect_runtime_compatibility( This preflight is intentionally read-only: it never normalizes a scene, builds the verified runtime DLL, prepares save data, or replaces the runtime - view. The normal preparation path still performs every authoritative check - immediately before launch. + view. Preparation reuses a prior authoritative result only while every input + identity and runtime-view link remains unchanged; otherwise it repeats the + full checks before launch. """ requested_source = source_game.expanduser() @@ -105,6 +137,9 @@ def inspect_runtime_compatibility( try: source = requested_source.resolve(strict=True) _validate_game_tree(source) + cached = _cached_runtime_compatibility(paths, source) + if cached is not None: + return cached source_scene = source / WHISPERING_VALE_RELATIVE inspection = inspect_wrapped_assbin(source_scene) source_scene_sha256 = inspection.file_sha256 @@ -139,10 +174,14 @@ def prepare_runtime_view( source = source_game.expanduser().resolve(strict=True) _validate_game_tree(source) _validate_runtime_target(paths) + game_save = _prepare_persistent_save(paths, source) + reused = _reuse_runtime_view(paths, source, game_save) + if reused is not None: + return reused + input_identities = _runtime_input_identities(paths, source) source_scene = source / WHISPERING_VALE_RELATIVE inspection = inspect_wrapped_assbin(source_scene) action = _scene_action(inspection) - game_save = _prepare_persistent_save(paths, source) ( binary_action, game_exe_source, @@ -152,7 +191,7 @@ def prepare_runtime_view( paths, source, ) - reused = _reuse_runtime_view( + legacy_reuse = _reuse_legacy_runtime_view( paths, source, inspection, @@ -162,9 +201,10 @@ def prepare_runtime_view( game_exe_source, game_dll_source, source_binary_hashes, + input_identities, ) - if reused is not None: - return reused + if legacy_reuse is not None: + return legacy_reuse build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}" backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}" @@ -213,6 +253,10 @@ def prepare_runtime_view( validate_scene_normalization(normalization) else: shutil.copy2(source_scene, runtime_scene) + if _runtime_input_identities(paths, source) != input_identities: + raise OverlayError( + "game files changed while the managed runtime view was being prepared" + ) if paths.runtime_view.exists() or paths.runtime_view.is_symlink(): paths.runtime_view.replace(backup) @@ -234,6 +278,11 @@ def prepare_runtime_view( binary_action=binary_action, game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"), game_dll_sha256=_sha256(paths.runtime_view / "game.dll"), + input_identities=input_identities, + runtime_scene_identity=_runtime_file_identity( + "runtime-scene", + paths.runtime_view / WHISPERING_VALE_RELATIVE, + ), created_at=datetime.now().astimezone().isoformat(), links_created=links_created, ) @@ -470,7 +519,99 @@ def _reject_save_symlinks(root: Path) -> None: raise OverlayError(f"persistent game save contains a symbolic link: {path}") -def _reuse_runtime_view( +def _runtime_file_identity( + name: str, + path: Path, + *, + follow_symlinks: bool = True, +) -> RuntimeFileIdentity: + state = path.stat(follow_symlinks=follow_symlinks) + return RuntimeFileIdentity( + name=name, + device=state.st_dev, + inode=state.st_ino, + mode=state.st_mode, + size=state.st_size, + mtime_ns=state.st_mtime_ns, + ctime_ns=state.st_ctime_ns, + ) + + +def _runtime_input_identities( + paths: AppPaths, + source: Path, +) -> tuple[RuntimeFileIdentity, ...]: + """Snapshot inputs whose hashes established the cached runtime profile. + + Nanosecond mtime plus ctime/inode/device prevents ordinary edits, replacements, + or timestamp restoration from bypassing the next authoritative hash check. + """ + + identities = [ + _runtime_file_identity("source-game-exe", source / "Game.exe"), + _runtime_file_identity("source-game-dll", source / "game.dll"), + _runtime_file_identity("source-scene", source / WHISPERING_VALE_RELATIVE), + ] + cache = paths.compatibility_binaries + if cache.exists() or cache.is_symlink(): + identities.append( + _runtime_file_identity( + "compatibility-cache", + cache, + follow_symlinks=False, + ) + ) + if cache.is_dir(): + for name in COMPATIBILITY_BINARY_HASHES: + path = cache / name + if path.exists() or path.is_symlink(): + identities.append( + _runtime_file_identity( + f"compatibility-cache-{name.casefold()}", + path, + ) + ) + return tuple(identities) + + +def _matching_cached_runtime_result( + paths: AppPaths, + source: Path, +) -> OverlayResult | None: + result = _read_overlay_result(paths.runtime_manifest) + if result is None: + return None + if result.source_game != str(source): + return None + if result.runtime_view != str(paths.runtime_view): + return None + try: + current_identities = _runtime_input_identities(paths, source) + except OSError: + return None + if current_identities != result.input_identities: + return None + return result + + +def _cached_runtime_compatibility( + paths: AppPaths, + source: Path, +) -> RuntimeCompatibility | None: + result = _matching_cached_runtime_result(paths, source) + if result is None: + return None + return RuntimeCompatibility( + compatible=True, + source_game=str(source), + source_scene_sha256=result.source_scene_sha256, + scene_action=result.scene_action, + binary_action=result.binary_action, + reason=None, + ) + + +def _reuse_legacy_runtime_view( paths: AppPaths, source: Path, inspection: SceneInspection, @@ -480,22 +621,82 @@ def _reuse_runtime_view( game_exe_source: Path, game_dll_source: Path, source_binary_hashes: Mapping[str, str], + input_identities: tuple[RuntimeFileIdentity, ...], ) -> OverlayResult | None: - result = _read_overlay_result(paths.runtime_manifest) + """Upgrade an unchanged v1 view after its original hash checks pass once.""" + + legacy = _read_legacy_overlay_result(paths.runtime_manifest) + if legacy is None: + return None + if legacy.source_game != str(source): + return None + if legacy.runtime_view != str(paths.runtime_view): + return None + if legacy.source_scene_sha256 != inspection.file_sha256: + return None + if legacy.scene_action != scene_action or legacy.binary_action != binary_action: + return None + if legacy.game_exe_sha256 != source_binary_hashes.get("Game.exe"): + return None + if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"): + return None + if not _runtime_layout_ready( + paths, + source, + game_save, + game_exe_source, + game_dll_source, + legacy.links_created, + ): + return None + runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE + try: + if _sha256(runtime_scene) != legacy.runtime_scene_sha256: + return None + if _runtime_input_identities(paths, source) != input_identities: + raise OverlayError( + "game files changed while the legacy runtime view was being verified" + ) + runtime_scene_identity = _runtime_file_identity( + "runtime-scene", + runtime_scene, + ) + except OSError: + return None + result = OverlayResult( + source_game=legacy.source_game, + runtime_view=legacy.runtime_view, + source_scene_sha256=legacy.source_scene_sha256, + runtime_scene_sha256=legacy.runtime_scene_sha256, + scene_action=legacy.scene_action, + binary_action=legacy.binary_action, + game_exe_sha256=legacy.game_exe_sha256, + game_dll_sha256=legacy.game_dll_sha256, + input_identities=input_identities, + runtime_scene_identity=runtime_scene_identity, + created_at=legacy.created_at, + links_created=legacy.links_created, + ) + _write_json( + paths.runtime_manifest, + { + "configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION, + **asdict(result), + }, + ) + return result + + +def _reuse_runtime_view( + paths: AppPaths, + source: Path, + game_save: Path, +) -> OverlayResult | None: + result = _matching_cached_runtime_result(paths, source) if result is None: return None - if result.source_game != str(source): - return None - if result.runtime_view != str(paths.runtime_view): - return None - if result.source_scene_sha256 != inspection.file_sha256: - return None - if result.scene_action != scene_action or result.binary_action != binary_action: - return None - if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"): - return None - if result.game_dll_sha256 != source_binary_hashes.get("game.dll"): - return None + game_exe_source = source / "Game.exe" + game_dll_source = source / "game.dll" if not _runtime_layout_ready( paths, source, @@ -507,7 +708,12 @@ def _reuse_runtime_view( return None runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE try: - if _sha256(runtime_scene) != result.runtime_scene_sha256: + if ( + _runtime_file_identity("runtime-scene", runtime_scene) + != result.runtime_scene_identity + ): + return None + if _runtime_input_identities(paths, source) != result.input_identities: return None except OSError: return None @@ -651,7 +857,101 @@ def _read_overlay_result(path: Path) -> OverlayResult | None: return None if links_created < 0: return None + input_values = value.get("input_identities") + if not isinstance(input_values, list) or not input_values: + return None + input_identities: list[RuntimeFileIdentity] = [] + for input_value in input_values: + identity = _parse_runtime_file_identity(input_value) + if identity is None: + return None + input_identities.append(identity) + input_names = {identity.name for identity in input_identities} + if len(input_names) != len(input_identities) or not { + "source-game-exe", + "source-game-dll", + "source-scene", + }.issubset(input_names): + return None + runtime_scene_identity = _parse_runtime_file_identity( + value.get("runtime_scene_identity") + ) + if ( + runtime_scene_identity is None + or runtime_scene_identity.name != "runtime-scene" + ): + return None return OverlayResult( + source_game=value["source_game"], + runtime_view=value["runtime_view"], + source_scene_sha256=value["source_scene_sha256"], + runtime_scene_sha256=value["runtime_scene_sha256"], + scene_action=value["scene_action"], + binary_action=value["binary_action"], + game_exe_sha256=value["game_exe_sha256"], + game_dll_sha256=value["game_dll_sha256"], + input_identities=tuple(input_identities), + runtime_scene_identity=runtime_scene_identity, + created_at=value["created_at"], + links_created=links_created, + ) + + +def _parse_runtime_file_identity(value: object) -> RuntimeFileIdentity | None: + if not isinstance(value, dict): + return None + name = value.get("name") + if not isinstance(name, str) or not name: + return None + fields: dict[str, int] = {} + for field in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns"): + field_value = value.get(field) + if ( + isinstance(field_value, bool) + or not isinstance(field_value, int) + or field_value < 0 + ): + return None + fields[field] = field_value + return RuntimeFileIdentity( + name=name, + device=fields["device"], + inode=fields["inode"], + mode=fields["mode"], + size=fields["size"], + mtime_ns=fields["mtime_ns"], + ctime_ns=fields["ctime_ns"], + ) + + +def _read_legacy_overlay_result(path: Path) -> LegacyOverlayResult | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(value, dict) or value.get("configuration_version") != 1: + return None + string_fields = ( + "source_game", + "runtime_view", + "source_scene_sha256", + "runtime_scene_sha256", + "scene_action", + "binary_action", + "game_exe_sha256", + "game_dll_sha256", + "created_at", + ) + if not all(isinstance(value.get(name), str) for name in string_fields): + return None + links_created = value.get("links_created") + if ( + isinstance(links_created, bool) + or not isinstance(links_created, int) + or links_created < 0 + ): + return None + return LegacyOverlayResult( source_game=value["source_game"], runtime_view=value["runtime_view"], source_scene_sha256=value["source_scene_sha256"], diff --git a/ptlinux/ui.py b/ptlinux/ui.py index f372056..d4ab430 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -105,7 +105,7 @@ ACTIVITY_LOG_TRIM_LINES = 500 TRANSFER_RATE_WINDOW_SECONDS = 3.0 TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 TRANSFER_RATE_IDLE_SECONDS = 2.0 -DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 650 +DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300 DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEPS = 9 @@ -120,6 +120,7 @@ SERVER_STATUS_STALE_SECONDS = 5 * 60 DASHBOARD_STARTUP_COMPONENTS = frozenset( { "broadcast", + "cached_content", "compatibility", "launcher_update", "logo", @@ -130,6 +131,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset( "version", } ) +DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"}) @dataclass(frozen=True) @@ -559,9 +561,9 @@ def dashboard_preloader_delay( def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool: - """Return whether every user-visible startup component has settled.""" + """Return whether cached content is ready for an interactive dashboard.""" - return DASHBOARD_STARTUP_COMPONENTS.issubset(ready_components) + return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components) def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: @@ -1284,6 +1286,7 @@ def run_dashboard(paths: AppPaths) -> None: startup_timeout_after: str | None = None startup_progress_after: str | None = None startup_progress_target = 0.0 + remote_refresh_started = False root.deiconify() root.lift() startup_overlay.lift() @@ -1375,6 +1378,7 @@ def run_dashboard(paths: AppPaths) -> None: set_root_opacity(1.0) root.lift() root.focus_set() + root.after_idle(start_remote_content_refresh) def fade_dashboard_in(step: int = 0) -> None: if not set_root_opacity( @@ -3389,6 +3393,13 @@ def run_dashboard(paths: AppPaths) -> None: threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() refresh_launcher_update_check() + def start_remote_content_refresh() -> None: + nonlocal remote_refresh_started + if remote_refresh_started: + return + remote_refresh_started = True + refresh_remote_content() + def release_game_launch_controls( client_id: int, *, @@ -4282,15 +4293,19 @@ def run_dashboard(paths: AppPaths) -> None: if cached_launcher_update is not None: show_launcher_update_check(cached_launcher_update, log_reason=False) refresh_runtime_compatibility() + mark_startup_component_ready( + "cached_content", + "Cached dashboard content ready", + ) poll_events() load_official_logo() update_startup_progress("Loading online dashboard content…") startup_overlay.update_idletasks() - startup_timeout_after = root.after( - DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, - timeout_startup_preloader, - ) - refresh_remote_content() + if not startup_transition_started: + startup_timeout_after = root.after( + DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + timeout_startup_preloader, + ) maybe_start_dashboard_transition() try: root.mainloop()