diff --git a/ptlinux/__init__.py b/ptlinux/__init__.py index 23f87e1..edb01db 100644 --- a/ptlinux/__init__.py +++ b/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "0.5.8" +__version__ = "0.5.9" diff --git a/ptlinux/cli.py b/ptlinux/cli.py index f189511..aa4d0fb 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -139,8 +139,13 @@ def build_parser() -> argparse.ArgumentParser: play.add_argument("--no-box", action="store_true", help="launch without X11 window styling") play.add_argument( "--publisher-dll", + "--no-world-fix", action="store_true", - help="diagnostic fallback: disable the post-authentication world fix", + dest="publisher_dll", + help=( + "diagnostic fallback: disable the in-memory post-login world fix " + "(the publisher DLL is always used)" + ), ) play.add_argument("--dry-run", action="store_true") play.add_argument("--skip-runtime-setup", action="store_true") @@ -348,6 +353,10 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) - print(f"game.dll: {report.game_dll}") if report.game_dll_sha256: print(f"DLL SHA-256: {report.game_dll_sha256}") + world_fix_status = "ready" if report.world_fix_helper_ready else "not ready" + print(f"World fix: {world_fix_status} ({report.world_fix_helper})") + if report.world_fix_helper_sha256: + print(f"Helper SHA: {report.world_fix_helper_sha256}") if report.runtime_kind: print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})") else: @@ -626,11 +635,7 @@ def _play_locked( overlay = None _print_operation_status(as_json, 1, 4, "Preparing the managed game view…") if not dry_run: - overlay = prepare_runtime_view( - paths, - source, - use_world_fix=not publisher_dll, - ) + 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) @@ -652,6 +657,7 @@ def _play_locked( selection, runner, use_gamebox=not no_box, + use_world_fix=not publisher_dll, ) except KeyboardInterrupt: current_game_log = _current_game_log( diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index 2e67448..18ea0ef 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -19,11 +19,7 @@ from ptdiag.assbin import ( ) from .paths import AppPaths -from .v21_post_auth_reclaim import ( - PATCHED_SHA256 as POST_AUTH_RECLAIM_GAME_DLL_SHA256, - PostAuthReclaimPatchError, - build_post_auth_reclaim_patch, -) +from .world_fix import V21_BINARY_HASHES WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug") @@ -39,10 +35,7 @@ COMPATIBILITY_BINARY_HASHES = { "Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5", "game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09", } -CURRENT_V21_BINARY_HASHES = { - "Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837", - "game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868", -} +CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES PATCHED_V21_GAME_DLL_SHA256 = ( "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" ) @@ -75,6 +68,67 @@ class CompatibilityCacheResult: created_at: str +@dataclass(frozen=True) +class RuntimeCompatibility: + """Read-only compatibility result for the managed game currently on disk.""" + + compatible: bool + source_game: str + source_scene_sha256: str | None + scene_action: str | None + binary_action: str | None + reason: str | None + + +def inspect_runtime_compatibility( + paths: AppPaths, + source_game: Path, + *, + use_world_fix: bool = True, +) -> RuntimeCompatibility: + """Check whether ``prepare_runtime_view`` can recognize the installed release. + + 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. + """ + + requested_source = source_game.expanduser() + source_scene_sha256 = None + scene_action = None + binary_action = None + try: + source = requested_source.resolve(strict=True) + _validate_game_tree(source) + source_scene = source / WHISPERING_VALE_RELATIVE + inspection = inspect_wrapped_assbin(source_scene) + source_scene_sha256 = inspection.file_sha256 + scene_action = _scene_action(inspection) + # Retain the keyword for callers from the 0.5.8 candidate. The runtime + # view is now always publisher-pristine; activation happens in memory. + _ = use_world_fix + binary_action = _inspect_runtime_binary_action(paths, source) + except (OSError, ValueError, OverlayError) as exc: + reason = str(exc).strip() or type(exc).__name__ + return RuntimeCompatibility( + compatible=False, + source_game=str(requested_source.resolve(strict=False)), + source_scene_sha256=source_scene_sha256, + scene_action=scene_action, + binary_action=binary_action, + reason=reason, + ) + return RuntimeCompatibility( + compatible=True, + source_game=str(source), + source_scene_sha256=source_scene_sha256, + scene_action=scene_action, + binary_action=binary_action, + reason=None, + ) + + def prepare_runtime_view( paths: AppPaths, source_game: Path, @@ -90,10 +144,12 @@ def prepare_runtime_view( inspection = inspect_wrapped_assbin(source_scene) action = _scene_action(inspection) game_save = _prepare_persistent_save(paths, source) + # ``use_world_fix`` is retained as a compatibility keyword. No code path may + # replace the publisher DLL; the post-login fix is selected by ``run_game``. + _ = use_world_fix binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources( paths, source, - use_world_fix=use_world_fix, ) build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}" @@ -207,22 +263,14 @@ def cache_compatibility_binaries( def select_binary_action( cache_hashes: dict[str, str] | None, source_hashes: dict[str, str], - runtime_patch_hash: str | None = None, ) -> str: - if runtime_patch_hash is not None: - if runtime_patch_hash != POST_AUTH_RECLAIM_GAME_DLL_SHA256: - raise OverlayError("post-authentication runtime patch has an unknown profile") if cache_hashes is not None: if cache_hashes != COMPATIBILITY_BINARY_HASHES: raise OverlayError("compatibility cache has an unknown binary profile") if source_hashes == COMPATIBILITY_BINARY_HASHES: return "verified-source-pair" - if runtime_patch_hash is not None: - if source_hashes != CURRENT_V21_BINARY_HASHES: - raise OverlayError( - "post-authentication runtime patch does not match the installed client profile" - ) - return "verified-v21-post-auth-world-fix" + if source_hashes == CURRENT_V21_BINARY_HASHES: + return "verified-v21-publisher-pair" return "upstream-pair" @@ -378,38 +426,30 @@ def _reject_save_symlinks(root: Path) -> None: def _runtime_game_binary_sources( paths: AppPaths, source: Path, - *, - use_world_fix: bool = True, ) -> tuple[str, Path, Path]: cache = paths.compatibility_binaries cache_hashes = _binary_hashes(cache) if cache.is_dir() else None source_hashes = _binary_hashes(source) - patch = paths.runtime_patch_dll - patch_hash = None - if source_hashes == CURRENT_V21_BINARY_HASHES and use_world_fix: - if patch.exists() or patch.is_symlink(): - if not patch.is_file() or patch.is_symlink(): - raise OverlayError(f"runtime patch cache is not a regular file: {patch}") - patch_hash = _sha256(patch) - else: - try: - result = build_post_auth_reclaim_patch(source / "game.dll", patch) - except PostAuthReclaimPatchError as exc: - raise OverlayError( - f"could not build the verified post-authentication world fix: {exc}" - ) from exc - patch_hash = result.destination_sha256 - payload = {"ok": True, **asdict(result)} - _write_json(patch.parent / "patch.json", payload) - _write_json(paths.runtime_patch_manifest, payload) - action = select_binary_action(cache_hashes, source_hashes, patch_hash) + action = select_binary_action(cache_hashes, source_hashes) return ( action, source / "Game.exe", - patch if action == "verified-v21-post-auth-world-fix" else source / "game.dll", + source / "game.dll", ) +def _inspect_runtime_binary_action( + paths: AppPaths, + source: Path, +) -> str: + """Mirror publisher binary selection without writing any runtime cache.""" + + cache = paths.compatibility_binaries + cache_hashes = _binary_hashes(cache) if cache.is_dir() else None + source_hashes = _binary_hashes(source) + return select_binary_action(cache_hashes, source_hashes) + + def _binary_hashes(directory: Path) -> dict[str, str]: return { name: _sha256(directory / name) diff --git a/ptlinux/paths.py b/ptlinux/paths.py index e03583c..d6059c2 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -182,10 +182,14 @@ class AppPaths: @property def runtime_patch_directory(self) -> Path: + """Retired 0.5.8 disk-patch cache; never selected for play.""" + return self.data / "runtime-patches" / "v21-post-auth-reclaim" @property def runtime_patch_dll(self) -> Path: + """Retired 0.5.8 DLL retained only so diagnostics can identify it.""" + return self.runtime_patch_directory / "game.dll" @property diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index bb2bc6c..b60c861 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -16,6 +16,7 @@ from .command import CommandOutcome, CommandRunner, OutputLineFilter from .gamebox import build_gamebox_command from .paths import AppPaths from .settings import Settings +from .world_fix import WorldFixError, resolve_world_fix_helper WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe" @@ -250,6 +251,7 @@ def run_game( runner: CommandRunner, *, use_gamebox: bool = True, + use_world_fix: bool = False, timeout_seconds: float = 12 * 60 * 60, check: bool = True, extra_environment: Mapping[str, str] | None = None, @@ -257,21 +259,30 @@ def run_game( ) -> CommandOutcome: if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") + world_fix_helper = None + if use_world_fix and not runner.dry_run: + try: + world_fix_helper = resolve_world_fix_helper(paths.runtime_view) + except WorldFixError as exc: + raise RuntimeErrorPT(str(exc)) from exc + launch_executable = world_fix_helper or (paths.runtime_view / "Game.exe") if selection.kind == "umu": game_command = [ selection.executable, - str(paths.runtime_view / "Game.exe"), - "PTEU_REBORN", + str(launch_executable), ] + if world_fix_helper is None: + game_command.append("PTEU_REBORN") environment = _umu_environment(paths) elif selection.kind == "proton7-container": if not selection.proton_path: raise RuntimeErrorPT("Proton script path is missing") game_command = [ selection.executable, - str(paths.runtime_view / "Game.exe"), - "PTEU_REBORN", + str(launch_executable), ] + if world_fix_helper is None: + game_command.append("PTEU_REBORN") environment = _umu_proton7_environment( paths, selection, @@ -286,9 +297,10 @@ def run_game( "ptlinux.proton_compat", selection.proton_path, "runinprefix", - WINDOWS_GAME_EXE, - "PTEU_REBORN", + str(world_fix_helper) if world_fix_helper is not None else WINDOWS_GAME_EXE, ] + if world_fix_helper is None: + game_command.append("PTEU_REBORN") environment = _proton_environment( paths, selection, diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index 8c3d345..51fc29b 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -637,6 +637,7 @@ def _validate_release_directory(release: Path) -> str: Path("ptlinux/cli.py"), Path("ptlinux/self_update.py"), Path("ptlinux") / PUBLIC_KEY_FILENAME, + Path("ptlinux/native/pt-world-fix.exe"), Path("ptdiag/__init__.py"), Path("ptdiag/assbin.py"), Path("ptdiag/logs.py"), diff --git a/ptlinux/ui.py b/ptlinux/ui.py index d051f22..4c0ae3c 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -29,6 +29,7 @@ from .news import ( fetch_news_image, fetch_official_logo, ) +from .overlay import RuntimeCompatibility, inspect_runtime_compatibility from .paths import AppPaths from .settings import Settings, ensure_settings, save_settings from .self_update import LauncherUpdateCheck, check_for_launcher_update @@ -568,9 +569,12 @@ def run_dashboard(paths: AppPaths) -> None: operation_stage = 0 operation_stage_total = 0 operation_message = "" + operation_refreshes_compatibility = False last_elapsed_second = -1 official_snapshot: DepotSnapshot | None = None launcher_update_check: LauncherUpdateCheck | None = None + runtime_compatibility: RuntimeCompatibility | None = None + compatibility_check_generation = 0 buttons = ( play_button, update_button, @@ -753,18 +757,21 @@ def run_dashboard(paths: AppPaths) -> None: *, show_preloader: bool = False, restart_on_success: bool = False, + game_files_may_change: bool = False, ) -> None: nonlocal operation_active nonlocal operation_started_at nonlocal operation_stage nonlocal operation_stage_total nonlocal operation_message + nonlocal operation_refreshes_compatibility nonlocal last_elapsed_second operation_active = True operation_started_at = time.monotonic() operation_stage = 0 operation_stage_total = 0 operation_message = "" + operation_refreshes_compatibility = game_files_may_change last_elapsed_second = -1 progress_updates.clear() transfer_rate.reset() @@ -1068,8 +1075,21 @@ def run_dashboard(paths: AppPaths) -> None: installation = inspect_game_installation( paths, official_snapshot.build if official_snapshot is not None else None, + compatibility_ready=( + runtime_compatibility.compatible + if runtime_compatibility is not None + else None + ), ) play_button.configure(text=installation.action.value) + if not operation_active: + play_button.configure( + state=( + "disabled" + if installation.action is PrimaryAction.CHECKING + else "normal" + ), + ) if not update_activity or operation_active: return installation if installation.action is PrimaryAction.INSTALL: @@ -1089,11 +1109,42 @@ def run_dashboard(paths: AppPaths) -> None: f"Installed depot {installation.local_build} · " f"official depot {official_build}" ) + elif installation.action is PrimaryAction.CHECKING: + status.set("Checking game compatibility…") + progress_text.set("The installed release is being inspected without modifying it.") + elif installation.action is PrimaryAction.LAUNCHER_UPDATE: + status.set("Launcher compatibility update required.") + progress_text.set("This installed game release is not recognized by the launcher.") else: status.set("Ready") progress_text.set("Click Play to start your journey") return installation + def refresh_runtime_compatibility(*, update_activity: bool = True) -> None: + nonlocal compatibility_check_generation, runtime_compatibility + compatibility_check_generation += 1 + check_generation = compatibility_check_generation + runtime_compatibility = None + local_status = inspect_game_installation(paths) + refresh_primary_action(update_activity=update_activity) + if not local_status.installed: + return + + def worker() -> None: + result = inspect_runtime_compatibility(paths, paths.source_game) + events.put( + ( + "compatibility", + (check_generation, result, update_activity), + ) + ) + + threading.Thread( + target=worker, + name="ptlinux-compatibility", + daemon=True, + ).start() + def refresh_remote_content() -> None: def broadcast_worker() -> None: events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache))) @@ -1127,8 +1178,10 @@ def run_dashboard(paths: AppPaths) -> None: ).start() def finish_operation(returncode: int) -> None: - nonlocal operation_active, operation_started_at + nonlocal operation_active, operation_refreshes_compatibility, operation_started_at + refresh_compatibility = operation_refreshes_compatibility operation_active = False + operation_refreshes_compatibility = False operation_started_at = None progress_updates.clear() transfer_rate.reset() @@ -1138,13 +1191,16 @@ def run_dashboard(paths: AppPaths) -> None: root.deiconify() root.lift() set_busy(False) - refresh_primary_action(update_activity=returncode == 0) + if refresh_compatibility: + refresh_runtime_compatibility(update_activity=returncode == 0) + else: + refresh_primary_action(update_activity=returncode == 0) if returncode != 0: status.set(f"Operation failed (exit {returncode})") progress_text.set("See the activity log for details.") def poll_events() -> None: - nonlocal launcher_update_check + nonlocal launcher_update_check, runtime_compatibility processed = 0 try: while processed < EVENT_BATCH_SIZE: @@ -1192,6 +1248,29 @@ def run_dashboard(paths: AppPaths) -> None: install_news_image(value) elif kind == "logo_image": install_official_logo(value) + elif ( + kind == "compatibility" + and isinstance(value, tuple) + and len(value) == 3 + and isinstance(value[0], int) + and isinstance(value[1], RuntimeCompatibility) + and isinstance(value[2], bool) + ): + check_generation, checked, update_activity = value + if check_generation == compatibility_check_generation: + runtime_compatibility = checked + if checked.compatible: + append_log( + "Compatibility check passed: " + f"scene={checked.scene_action}, " + f"binaries={checked.binary_action}" + ) + else: + append_log( + "Launcher compatibility update required: " + f"{checked.reason or 'unknown release profile'}" + ) + refresh_primary_action(update_activity=update_activity) elif kind == "version" and isinstance(value, DepotSnapshot): version.set( f"Launcher {__version__} · {value.version} · depot {value.build}" @@ -1241,18 +1320,29 @@ def run_dashboard(paths: AppPaths) -> None: def command_for(*arguments: str) -> list[str]: return [sys.executable, "-m", "ptlinux", *arguments] - def run_launcher_update() -> None: + def run_launcher_update(required_reason: str | None = None) -> None: check = launcher_update_check if check is None: launcher_update_button.configure(text="CHECKING…", state="disabled") + if required_reason is not None: + status.set("Checking for a compatible launcher update…") + progress_text.set("Play remains disabled until this release is supported.") refresh_remote_content() return if not check.available: - messagebox.showinfo( - "Launcher update", - check.reason, - parent=root, - ) + if required_reason is None: + messagebox.showinfo( + "Launcher update", + check.reason, + parent=root, + ) + else: + messagebox.showwarning( + "Compatibility update required", + "The installed game needs a launcher compatibility update:\n\n" + f"{required_reason}\n\n{check.reason}", + parent=root, + ) return if not check.supported: messagebox.showwarning( @@ -1287,11 +1377,19 @@ def run_dashboard(paths: AppPaths) -> None: run_operation( "Installing official game files…", command_for("update"), + game_files_may_change=True, ) - else: + elif installation.action is PrimaryAction.UPDATE: run_operation( "Synchronizing official game files…", command_for("update"), + game_files_may_change=True, + ) + elif installation.action is PrimaryAction.LAUNCHER_UPDATE: + run_launcher_update( + runtime_compatibility.reason + if runtime_compatibility is not None + else "The installed release profile is not recognized.", ) play_button.configure(command=run_primary_action) @@ -1299,12 +1397,14 @@ def run_dashboard(paths: AppPaths) -> None: command=lambda: run_operation( "Synchronizing official game files…", command_for("update"), + game_files_may_change=True, ) ) repair_button.configure( command=lambda: run_operation( "Checking every game file against the official depot…", command_for("update", "--no-import", "--repair"), + game_files_may_change=True, ) ) doctor_button.configure( @@ -1603,6 +1703,7 @@ def run_dashboard(paths: AppPaths) -> None: run_operation( "Importing the existing game into the managed installation…", command_for("import-game", source), + game_files_may_change=True, ) import_button.configure(command=choose_import_source) @@ -1619,6 +1720,7 @@ def run_dashboard(paths: AppPaths) -> None: news_content.bind("", update_news_viewport) news_canvas.bind("", resize_news_content) refresh_primary_action() + refresh_runtime_compatibility() poll_events() load_official_logo() refresh_remote_content()