diff --git a/setup_tool.py b/setup_tool.py index b5eb47f..9d11cd4 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -1,5 +1,6 @@ import os import sys +import json import shutil import subprocess import math @@ -210,9 +211,17 @@ class WowSetupTool: self.safety_override = tk.BooleanVar(value=False) self.slider_widgets =[] + self._loaded_settings_dir = None + self._loading_settings = False + self.on_ratio_change() self.build_ui() + # Settings are stored per WoW installation. Loading on path selection + # means a newer copy of the tool can immediately restore the choices + # made by an older copy. + self.wow_dir.trace_add("write", self._on_wow_dir_changed) + def on_ratio_change(self, event=None): selection = self.ratio_var.get() ratio = self.ratio_options.get(selection, 4.0/3.0) @@ -266,6 +275,253 @@ class WowSetupTool: ).pack(side="right", padx=(6, 0)) return cb + + def _settings_path(self, target_dir): + return os.path.join(target_dir, ".modernization_tool", "settings.json") + + def _looks_like_managed_install(self, target_dir): + return any( + os.path.exists(os.path.join(target_dir, rel)) + for rel in ( + "WoW_Modernized.exe", + "Play Modernized WoW.lnk", + ".modernization_tool", + ) + ) + + def _collect_settings(self): + live_plugins = {} + for name in ( + "classicapi_enabled", + "auction_throttle_enabled", + "vmmfix_enabled", + "interact_enabled", + ): + var = getattr(self, name, None) + if var is not None: + live_plugins[name] = bool(var.get()) + + return { + "version": 1, + "rendering_mode": self.rendering_mode.get(), + "install_autologin": bool(self.install_autologin.get()), + "core_plugins": { + name: bool(var.get()) for name, var in self.core_plugins.items() + }, + "optional_plugins": { + name: bool(var.get()) for name, var in self.optional_plugins.items() + }, + "live_plugins": live_plugins, + "vanilla_tweaks": { + "ratio": self.ratio_var.get(), + "fov": float(self.vt_fov.get()), + "farclip": int(self.vt_farclip.get()), + "frill": int(self.vt_frill.get()), + "nameplate": int(self.vt_nameplate.get()), + "sound_channels": int(self.vt_soundchan.get()), + "max_camera": int(self.vt_maxcam.get()), + "quickloot": bool(self.vt_quickloot.get()), + "background_sound": bool(self.vt_bg_sound.get()), + "laa": bool(self.vt_laa.get()), + "camera_fix": bool(self.vt_cam_fix.get()), + "dep_fix": bool(self.vt_dep_fix.get()), + "script_memory": bool(self.vt_script_memory.get()), + "crossfaction_res": bool(self.vt_crossfaction_res.get()), + "custom_glues": bool(self.vt_custom_glues.get()), + "bluemoon": bool(self.vt_bluemoon.get()), + "clear_wdb": bool(self.vt_clear_wdb.get()), + "safety_override": bool(self.safety_override.get()), + }, + "visual_mods": { + name: bool(var.get()) for name, var in self.visual_mods.items() + }, + "audio_mods": { + name: bool(var.get()) for name, var in self.audio_mods.items() + }, + } + + def save_settings(self, target_dir): + settings_path = self._settings_path(target_dir) + os.makedirs(os.path.dirname(settings_path), exist_ok=True) + temp_path = settings_path + ".new" + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(self._collect_settings(), handle, indent=2, sort_keys=True) + os.replace(temp_path, settings_path) + self._loaded_settings_dir = os.path.normcase(os.path.abspath(target_dir)) + + def _set_bool_mapping(self, saved, variables): + if not isinstance(saved, dict): + return + for name, value in saved.items(): + var = variables.get(name) + if var is not None and isinstance(value, bool): + var.set(value) + + def _load_legacy_install_state(self, target_dir): + """Best-effort migration for installs made before settings.json existed.""" + if not self._looks_like_managed_install(target_dir): + return + + if ( + os.path.isfile(os.path.join(target_dir, "d3d9.dll")) + and os.path.isfile(os.path.join(target_dir, "dxvk.conf")) + ): + self.rendering_mode.set("dxvk") + else: + self.rendering_mode.set("directx9") + + # Once this tool has clearly been used on the folder, on-disk presence + # is the safest migration signal for plugin checkboxes. + for filename, var in self.core_plugins.items(): + var.set(os.path.isfile(os.path.join(target_dir, filename))) + + for filename, var in self.optional_plugins.items(): + var.set(os.path.isfile(os.path.join(target_dir, filename))) + + live_files = { + "classicapi_enabled": "ClassicAPI.dll", + "auction_throttle_enabled": "AuctionQueryThrottle.dll", + "vmmfix_enabled": "VanillaMultiMonitorFix.dll", + "interact_enabled": "Interact.dll", + } + for attr, filename in live_files.items(): + var = getattr(self, attr, None) + if var is not None: + var.set(os.path.isfile(os.path.join(target_dir, filename))) + + autologin_lua = os.path.join( + target_dir, "Data", "Interface", "GlueXML", "AutoLogin.lua" + ) + self.install_autologin.set(os.path.isfile(autologin_lua)) + + managed_ids = { + "darker_nights": "visual_darker_nights", + "pretty_night_sky": "visual_pretty_night_sky", + "epoch_water": "visual_epoch_water", + "fog_pushback": "visual_fog_pushback", + "pink_herbs": "visual_pink_herbs", + } + for key, managed_id in managed_ids.items(): + self.visual_mods[key].set( + remote_packages.managed_mod_is_installed(target_dir, managed_id) + ) + + managed_audio = { + "no_error_sounds": "audio_no_error_sounds", + "fish_ping": "audio_fish_ping", + "warlock_muted_demons": "audio_warlock_muted_demons", + } + for key, managed_id in managed_audio.items(): + self.audio_mods[key].set( + remote_packages.managed_mod_is_installed(target_dir, managed_id) + ) + + wdb_path = os.path.join(target_dir, "WDB") + if os.path.isfile(wdb_path): + self.vt_clear_wdb.set(True) + elif os.path.isdir(wdb_path): + self.vt_clear_wdb.set(False) + + def load_settings(self, target_dir): + settings_path = self._settings_path(target_dir) + self._loading_settings = True + try: + if not os.path.isfile(settings_path): + self._load_legacy_install_state(target_dir) + return False + + with open(settings_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + if not isinstance(saved, dict): + return False + + rendering = saved.get("rendering_mode") + if rendering in ("directx9", "dxvk"): + self.rendering_mode.set(rendering) + + if isinstance(saved.get("install_autologin"), bool): + self.install_autologin.set(saved["install_autologin"]) + + self._set_bool_mapping(saved.get("core_plugins"), self.core_plugins) + self._set_bool_mapping(saved.get("optional_plugins"), self.optional_plugins) + self._set_bool_mapping(saved.get("visual_mods"), self.visual_mods) + self._set_bool_mapping(saved.get("audio_mods"), self.audio_mods) + + live = saved.get("live_plugins") + if isinstance(live, dict): + for name, value in live.items(): + var = getattr(self, name, None) + if var is not None and isinstance(value, bool): + var.set(value) + + tweaks = saved.get("vanilla_tweaks") + if isinstance(tweaks, dict): + ratio = tweaks.get("ratio") + if isinstance(ratio, str) and ratio in self.ratio_options: + self.ratio_var.set(ratio) + + numeric = ( + ("fov", self.vt_fov, float), + ("farclip", self.vt_farclip, int), + ("frill", self.vt_frill, int), + ("nameplate", self.vt_nameplate, int), + ("sound_channels", self.vt_soundchan, int), + ("max_camera", self.vt_maxcam, int), + ) + for key, var, converter in numeric: + value = tweaks.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + var.set(converter(value)) + + boolean = ( + ("quickloot", self.vt_quickloot), + ("background_sound", self.vt_bg_sound), + ("laa", self.vt_laa), + ("camera_fix", self.vt_cam_fix), + ("dep_fix", self.vt_dep_fix), + ("script_memory", self.vt_script_memory), + ("crossfaction_res", self.vt_crossfaction_res), + ("custom_glues", self.vt_custom_glues), + ("bluemoon", self.vt_bluemoon), + ("clear_wdb", self.vt_clear_wdb), + ("safety_override", self.safety_override), + ) + for key, var in boolean: + value = tweaks.get(key) + if isinstance(value, bool): + var.set(value) + + self.toggle_safety_limits() + self.update_superwow_managed_controls() + return True + except (OSError, json.JSONDecodeError, ValueError, tk.TclError): + # A damaged settings file must never stop the tool from opening. + return False + finally: + self._loading_settings = False + + def _on_wow_dir_changed(self, *_args): + if self._loading_settings: + return + + target_dir = self.wow_dir.get().strip() + if not target_dir: + return + + normalized = os.path.normcase(os.path.abspath(target_dir)) + if normalized == self._loaded_settings_dir: + return + + # Do not pop validation errors while the user is typing a path. + if not ( + os.path.isfile(os.path.join(target_dir, "WoW.exe")) + and os.path.isdir(os.path.join(target_dir, "Data")) + ): + return + + self.load_settings(target_dir) + self._loaded_settings_dir = normalized + def build_ui(self): help_banner = tk.Label( self.root, @@ -913,6 +1169,7 @@ class WowSetupTool: def configure_visual_audio(self, target): progress = getattr(self, "_report_download_progress", None) close_progress = getattr(self, "_close_download_progress", None) + warnings = [] visual_defs = [ ("darker_nights", "visual_darker_nights", "Darker Nights", remote_packages.install_darker_nights), @@ -922,33 +1179,97 @@ class WowSetupTool: ("pink_herbs", "visual_pink_herbs", "Pink Herbs", remote_packages.install_pink_herbs), ] audio_defs = [ - ("no_error_sounds", "audio_no_error_sounds", "NoErrorSounds", remote_packages.install_no_error_sounds), - ("fish_ping", "audio_fish_ping", "FishPing", remote_packages.install_fish_ping), - ("warlock_muted_demons", "audio_warlock_muted_demons", "Warlock Muted Demons", remote_packages.install_warlock_muted_demons), + ( + "no_error_sounds", + "audio_no_error_sounds", + "NoErrorSounds", + remote_packages.install_no_error_sounds, + os.path.join("Audio", "NoErrorSounds", "Sound"), + "Sound", + ), + ( + "fish_ping", + "audio_fish_ping", + "FishPing", + remote_packages.install_fish_ping, + os.path.join("Audio", "FishPing", "Sound"), + "Sound", + ), + ( + "warlock_muted_demons", + "audio_warlock_muted_demons", + "Warlock Muted Demons", + remote_packages.install_warlock_muted_demons, + os.path.join("Audio", "WarlockMutedDemons", "Data"), + "Data", + ), ] try: + # Large visual MPQs deliberately stay online-only. If a source goes + # down, keep the copy previously installed by this tool. for key, managed_id, display_name, installer in visual_defs: if self.visual_mods[key].get(): try: installer(target, progress=progress) except Exception as exc: - raise RuntimeError(f"{display_name} installation failed:\n{exc}") from exc + if remote_packages.managed_mod_is_installed(target, managed_id): + warnings.append( + f"{display_name}: online source unavailable; existing installed copy kept." + ) + else: + raise RuntimeError( + f"{display_name} installation failed and no bundled MPQ backup is provided:\n{exc}" + ) from exc else: remote_packages.remove_managed_mod(target, managed_id) - for key, managed_id, display_name, installer in audio_defs: + fallback_root = os.path.join(get_base_path(), "Payload", "Fallback") + for ( + key, + managed_id, + display_name, + installer, + fallback_rel, + destination_prefix, + ) in audio_defs: if self.audio_mods[key].get(): try: installer(target, progress=progress) except Exception as exc: - raise RuntimeError(f"{display_name} installation failed:\n{exc}") from exc + if remote_packages.managed_mod_is_installed(target, managed_id): + warnings.append( + f"{display_name}: online source unavailable; existing installed copy kept." + ) + continue + + bundled = os.path.join(fallback_root, fallback_rel) + if not os.path.isdir(bundled): + raise RuntimeError( + f"{display_name} installation failed and its bundled backup is missing:\n{exc}" + ) from exc + + remote_packages.install_bundled_tree( + target, + managed_id, + bundled, + destination_prefix, + ) + warnings.append( + f"{display_name}: online source unavailable; bundled backup installed." + ) else: remote_packages.remove_managed_mod(target, managed_id) finally: if callable(close_progress): close_progress() + if warnings: + messagebox.showwarning( + "Some online sources were unavailable", + "\n\n".join(warnings), + ) + def apply_process_mitigations(self): """Runs the Set-ProcessMitigation PowerShell command with a UAC prompt if needed.""" @@ -991,8 +1312,22 @@ class WowSetupTool: # Generate the seamless launcher shortcut self.create_launcher_shortcut(target_dir) self.cleanup_legacy_outputs(target_dir) + + settings_warning = "" + try: + self.save_settings(target_dir) + except OSError as exc: + settings_warning = ( + "\n\nWarning: your choices could not be saved for the next run. " + f"Details: {exc}" + ) - messagebox.showinfo("Success", "Installation and patching complete!\n\nUse the new 'Play Modernized WoW' shortcut in your directory to launch the game.") + messagebox.showinfo( + "Success", + "Installation and patching complete!\n\n" + "Use the new 'Play Modernized WoW' shortcut in your directory to launch the game." + + settings_warning, + ) except PermissionError as e: messagebox.showerror( "Permission Error",