From eca8599f0c549fa1b7f7b1aa946a83ec9a428f03 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Fri, 4 Sep 2026 11:21:16 +0200 Subject: [PATCH] Make Vanilla Tweaks selections fully authoritative --- setup_tool_dynamic.py | 1881 ++------------------ setup_tool_dynamic_core.py | 1750 ++++++++++++++++++ tests/test_vanilla_tweaks_normalization.py | 236 ++- 3 files changed, 2144 insertions(+), 1723 deletions(-) create mode 100644 setup_tool_dynamic_core.py diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 70c6330..27a290d 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -1,1129 +1,104 @@ +import math import os -import json -import shutil import struct -import tempfile import tkinter as tk -from tkinter import ttk, messagebox -import remote_packages -from setup_tool import WowSetupTool, ToolTip, get_base_path - - -class ModernWowSetupTool(WowSetupTool): - """Feature-branch entry point adding live GitHub package updates.""" - - def __init__(self, root): - # Define these before WowSetupTool.__init__ because the parent constructor - # calls self.build_ui(), which dispatches to our overridden Plugins tab. - self.classicapi_enabled = tk.BooleanVar(master=root, value=True) - self.auction_throttle_enabled = tk.BooleanVar(master=root, value=True) - self.vmmfix_enabled = tk.BooleanVar(master=root, value=False) - self.interact_enabled = tk.BooleanVar(master=root, value=False) - - # Discord Rich Presence detail choices are kept independently from the - # main WowPresence checkbox so hiding/disabling the integration never - # destroys the user's selected disclosure preferences. - self.discord_show_character_details = tk.BooleanVar(master=root, value=True) - self.discord_detail_vars = { - "name": tk.BooleanVar(master=root, value=True), - "guild": tk.BooleanVar(master=root, value=True), - "race": tk.BooleanVar(master=root, value=True), - "faction": tk.BooleanVar(master=root, value=True), - "class": tk.BooleanVar(master=root, value=True), - "level": tk.BooleanVar(master=root, value=True), - "zone": tk.BooleanVar(master=root, value=True), - } - self.discord_presence_details_frame = None - self.discord_detail_checkbuttons = {} - - self._download_window = None - self._download_label = None - self._download_detail = None - self._download_percent = None - self._download_bar = None - self._download_indeterminate = False - super().__init__(root) - - def _bundled_manifest_components(self): - metadata_path = os.path.join( - get_base_path(), - "Payload", - "Fallback", - "versions.json", - ) - try: - with open(metadata_path, "r", encoding="utf-8") as handle: - data = json.load(handle) - except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc: - raise RuntimeError( - "Could not read bundled component integrity metadata." - ) from exc - - components = data.get("components") if isinstance(data, dict) else None - if not isinstance(components, dict): - raise RuntimeError("Bundled component integrity metadata is invalid.") - return components - - def _bundled_manifest_record(self, relative_path): - """Return the expected size/hash record for one bundled payload file.""" - wanted = relative_path.replace("\\", "/").casefold() - for component in self._bundled_manifest_components().values(): - files = component.get("files") if isinstance(component, dict) else None - if not isinstance(files, list): - continue - for record in files: - if not isinstance(record, dict): - continue - path = record.get("path") - if isinstance(path, str) and path.replace("\\", "/").casefold() == wanted: - return record - - raise RuntimeError( - f"No integrity record exists for bundled file {relative_path}." - ) - - def _bundled_manifest_component(self, component_name): - component = self._bundled_manifest_components().get(component_name) - if not isinstance(component, dict): - raise RuntimeError( - f"No bundled integrity metadata exists for {component_name}." - ) - return component - - def _verified_bundled_file(self, relative_path, label): - """Validate a bundled file before it is allowed to touch the WoW folder.""" - record = self._bundled_manifest_record(relative_path) - source = os.path.join( - get_base_path(), - *relative_path.replace("\\", "/").split("/"), - ) - if not os.path.isfile(source): - raise RuntimeError(f"Bundled file is missing: {relative_path}") - - expected_size = record.get("size") - if not isinstance(expected_size, int) or expected_size <= 0: - raise RuntimeError(f"Invalid bundled size metadata for {relative_path}.") - if os.path.getsize(source) != expected_size: - raise RuntimeError( - f"Bundled {label} has an unexpected size and will not be installed." - ) - - expected_sha = record.get("sha256") - if ( - not isinstance(expected_sha, str) - or len(expected_sha) != 64 - or any(ch not in "0123456789abcdefABCDEF" for ch in expected_sha) - ): - raise RuntimeError(f"Invalid bundled SHA-256 metadata for {relative_path}.") - expected_sha = expected_sha.lower() - - if self._file_sha256(source).lower() != expected_sha: - raise RuntimeError( - f"Bundled {label} failed its SHA-256 integrity check." - ) - - if os.path.splitext(source)[1].lower() in (".dll", ".exe"): - remote_packages._verify_x86_pe(source, f"bundled {label}") - - return source, expected_sha - - def _verified_bundled_tree( - self, - component_name, - records_key, - root_relative_path, - label, - ): - """Verify an exact bundled file tree using recorded canonical Git blob IDs.""" - component = self._bundled_manifest_component(component_name) - records = component.get(records_key) - if not isinstance(records, list) or not records: - raise RuntimeError(f"Missing bundled tree metadata for {label}.") - - root = os.path.join( - get_base_path(), - *root_relative_path.replace("\\", "/").split("/"), - ) - if not os.path.isdir(root): - raise RuntimeError(f"Bundled {label} folder is missing.") - - expected = {} - prefix = root_relative_path.replace("\\", "/").rstrip("/") + "/" - for record in records: - if not isinstance(record, dict): - raise RuntimeError(f"Invalid bundled tree metadata for {label}.") - path = record.get("path") - size = record.get("size") - blob_sha = record.get("git_blob_sha1") - if ( - not isinstance(path, str) - or not path.replace("\\", "/").startswith(prefix) - or not isinstance(size, int) - or size < 0 - or not isinstance(blob_sha, str) - or len(blob_sha) != 40 - or any(ch not in "0123456789abcdefABCDEF" for ch in blob_sha) - ): - raise RuntimeError(f"Invalid bundled tree metadata for {label}.") - key = path.replace("\\", "/").casefold() - if key in expected: - raise RuntimeError(f"Duplicate bundled tree metadata for {label}.") - expected[key] = (size, blob_sha.lower()) - - actual = {} - for current_root, dirs, files in os.walk(root): - dirs.sort(key=str.casefold) - files.sort(key=str.casefold) - for filename in files: - full_path = os.path.join(current_root, filename) - rel_from_root = os.path.relpath(full_path, root).replace(os.sep, "/") - manifest_path = prefix + rel_from_root - actual[manifest_path.casefold()] = full_path - - if set(actual) != set(expected): - raise RuntimeError( - f"Bundled {label} file set does not match its integrity metadata." - ) - - for key, full_path in actual.items(): - expected_size, expected_blob_sha = expected[key] - if os.path.getsize(full_path) != expected_size: - raise RuntimeError( - f"Bundled {label} contains a file with an unexpected size." - ) - if remote_packages._git_blob_sha1(full_path) != expected_blob_sha: - raise RuntimeError( - f"Bundled {label} failed its exact file integrity check." - ) - - return root - - def _install_verified_bundled_file(self, relative_path, target_path, label): - source, expected_sha = self._verified_bundled_file(relative_path, label) - if os.path.isfile(target_path): - try: - if self._file_sha256(target_path).lower() == expected_sha: - return - except OSError: - pass - remote_packages._atomic_replace_file(source, target_path) - - def copy_base_files(self, target): - """Install only true base files; component addons are handled with their DLLs.""" - payload_dir = os.path.join(get_base_path(), "Payload") - if not os.path.isdir(payload_dir): - return - - if self.install_autologin.get(): - data_source = os.path.join(payload_dir, "Data") - if os.path.isdir(data_source): - shutil.copytree( - data_source, - os.path.join(target, "Data"), - dirs_exist_ok=True, - ) - - # Do not copy Payload/Interface wholesale here. Nampower and UnitXP - # addons must stay paired with the exact DLL version selected later. - vanilla_fixes, vf_sha = self._verified_bundled_file( - "Payload/VanillaFixes.exe", - "VanillaFixes.exe", - ) - vf_patcher, patcher_sha = self._verified_bundled_file( - "Payload/VfPatcher.dll", - "VfPatcher.dll", - ) - target_fixes = os.path.join(target, "VanillaFixes.exe") - target_patcher = os.path.join(target, "VfPatcher.dll") - - already_current = False - if os.path.isfile(target_fixes) and os.path.isfile(target_patcher): - try: - already_current = ( - self._file_sha256(target_fixes).lower() == vf_sha - and self._file_sha256(target_patcher).lower() == patcher_sha - ) - except OSError: - already_current = False - - if not already_current: - remote_packages._transactional_replace_bundle( - [ - ("file", vanilla_fixes, target_fixes), - ("file", vf_patcher, target_patcher), - ], - label="VanillaFixes", - ) - - def _collect_settings(self): - settings = super()._collect_settings() - settings["discord_presence"] = { - "show_character_details": bool(self.discord_show_character_details.get()), - "details": { - name: bool(var.get()) - for name, var in self.discord_detail_vars.items() - }, - } - return settings - - def _apply_settings_dict(self, saved): - super()._apply_settings_dict(saved) - - discord = saved.get("discord_presence") if isinstance(saved, dict) else None - if not isinstance(discord, dict): - return - - show_details = discord.get("show_character_details") - if isinstance(show_details, bool): - self.discord_show_character_details.set(show_details) - - details = discord.get("details") - if isinstance(details, dict): - for name, value in details.items(): - var = self.discord_detail_vars.get(name) - if var is not None and isinstance(value, bool): - var.set(value) - - def _load_wowpresence_broadcast_preferences(self, target_dir): - """Migrate the old six-bit mask into the new seven-choice UI.""" - mask = remote_packages.read_wowpresence_broadcast_flags(target_dir) - if mask is None: - return - - bits = { - "name": remote_packages.WOWPRESENCE_SHARE_NAME, - "guild": remote_packages.WOWPRESENCE_SHARE_GUILD, - "faction": remote_packages.WOWPRESENCE_SHARE_FACTION, - "class": remote_packages.WOWPRESENCE_SHARE_CLASS, - "level": remote_packages.WOWPRESENCE_SHARE_LEVEL, - "zone": remote_packages.WOWPRESENCE_SHARE_ZONE, - } - for name, bit in bits.items(): - self.discord_detail_vars[name].set(bool(mask & bit)) - - # WowPresence versions before the dedicated Race flag always exposed - # race information regardless of the six-bit mask. Treat masks <= 63 - # as legacy so the migration preserves exactly that visible behavior. - if mask <= 63: - self.discord_detail_vars["race"].set(True) - else: - self.discord_detail_vars["race"].set( - bool(mask & remote_packages.WOWPRESENCE_SHARE_RACE) - ) - - # "Show character details" now means "show everything". Only select it - # automatically when the existing configuration already represents the - # full legacy/default disclosure set. Any custom mask stays custom. - legacy_all = ( - mask <= 63 - and (mask & 63) == 63 - ) - current_all = ( - mask > 63 - and (mask & remote_packages.WOWPRESENCE_SHARE_ALL) - == remote_packages.WOWPRESENCE_SHARE_ALL - ) - self.discord_show_character_details.set(legacy_all or current_all) - if self.discord_show_character_details.get(): - for var in self.discord_detail_vars.values(): - var.set(True) - - def _load_legacy_install_state(self, target_dir): - super()._load_legacy_install_state(target_dir) - self._load_wowpresence_broadcast_preferences(target_dir) - - def load_settings(self, target_dir): - settings_path = self._settings_path(target_dir) - has_saved_discord_preferences = False - try: - with open(settings_path, "r", encoding="utf-8") as handle: - saved = json.load(handle) - has_saved_discord_preferences = isinstance( - saved.get("discord_presence") if isinstance(saved, dict) else None, - dict, - ) - except (OSError, json.JSONDecodeError, ValueError, TypeError): - pass - - loaded = super().load_settings(target_dir) - - # Existing v2.1 settings.json files predate the detailed controls. Read - # their actual WowPresence mask once so manual choices are not lost. - if os.path.isfile(settings_path) and not has_saved_discord_preferences: - self._load_wowpresence_broadcast_preferences(target_dir) - - self.update_discord_presence_controls() - return loaded - - def _discord_broadcast_mask(self): - if self.discord_show_character_details.get(): - return remote_packages.WOWPRESENCE_SHARE_ALL - - bits = { - "name": remote_packages.WOWPRESENCE_SHARE_NAME, - "guild": remote_packages.WOWPRESENCE_SHARE_GUILD, - "race": remote_packages.WOWPRESENCE_SHARE_RACE, - "faction": remote_packages.WOWPRESENCE_SHARE_FACTION, - "class": remote_packages.WOWPRESENCE_SHARE_CLASS, - "level": remote_packages.WOWPRESENCE_SHARE_LEVEL, - "zone": remote_packages.WOWPRESENCE_SHARE_ZONE, - } - mask = 0 - for name, bit in bits.items(): - var = self.discord_detail_vars.get(name) - if var is not None and var.get(): - mask |= bit - return mask - - def _toggle_discord_all_details(self): - show_all = bool(self.discord_show_character_details.get()) - if show_all: - for var in self.discord_detail_vars.values(): - var.set(True) - self.update_discord_detail_states() - - def update_discord_detail_states(self): - state = "disabled" if self.discord_show_character_details.get() else "normal" - for checkbox in getattr(self, "discord_detail_checkbuttons", {}).values(): - try: - checkbox.configure(state=state) - except tk.TclError: - pass - - def update_discord_presence_controls(self): - frame = getattr(self, "discord_presence_details_frame", None) - if frame is None: - return - - var = self.optional_plugins.get("WowPresence.dll") - visible = bool(var is not None and var.get()) - manager = frame.winfo_manager() - - if visible and not manager: - frame.pack(fill="x", padx=(22, 8), pady=(0, 4)) - elif not visible and manager: - frame.pack_forget() - - if visible: - self.update_discord_detail_states() - - def _build_discord_presence_details(self, parent): - frame = ttk.Frame(parent) - self.discord_presence_details_frame = frame - - show_cb = ttk.Checkbutton( - frame, - text="Show character details", - variable=self.discord_show_character_details, - command=self._toggle_discord_all_details, - ) - show_cb.pack(anchor="w", padx=6, pady=(1, 2)) - ToolTip( - show_cb, - "When enabled, all character details are shown and the individual choices " - "below are locked. Uncheck it to choose each detail separately.", - ) - - labels = { - "name": "Character Name", - "guild": "Guild", - "race": "Race", - "faction": "Faction", - "class": "Class", - "level": "Level", - "zone": "Zone", - } - for name, label in labels.items(): - cb = ttk.Checkbutton( - frame, - text=label, - variable=self.discord_detail_vars[name], - ) - cb.pack(anchor="w", padx=24, pady=1) - self.discord_detail_checkbuttons[name] = cb - ToolTip( - cb, - f"Choose whether WowPresence may publish your {label.lower()} on Discord.", - ) - - self.update_discord_presence_controls() - - def _show_download_progress(self): - if self._download_window is not None and self._download_window.winfo_exists(): - return - - win = tk.Toplevel(self.root) - win.title("Updating components") - win.resizable(False, False) - win.transient(self.root) - win.protocol("WM_DELETE_WINDOW", lambda: None) - - frame = ttk.Frame(win, padding=18) - frame.pack(fill="both", expand=True) - frame.columnconfigure(0, weight=1) - - title = ttk.Label( - frame, - text="Updating selected components", - font=("Segoe UI", 11, "bold"), - anchor="w", - ) - title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) - - self._download_label = ttk.Label( - frame, - text="Preparing updates...", - font=("Segoe UI", 9, "bold"), - anchor="w", - ) - self._download_label.grid(row=1, column=0, sticky="w", pady=(0, 4)) - - self._download_percent = ttk.Label( - frame, - text="", - font=("Segoe UI", 9, "bold"), - anchor="e", - width=6, - ) - self._download_percent.grid(row=1, column=1, sticky="e", pady=(0, 4)) - - self._download_detail = ttk.Label( - frame, - text="Please wait while files are downloaded and installed.", - anchor="w", - foreground="#666666", - ) - self._download_detail.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 10)) - - self._download_bar = ttk.Progressbar( - frame, - orient="horizontal", - mode="indeterminate", - length=460, - ) - self._download_bar.grid(row=3, column=0, columnspan=2, sticky="ew") - - self._download_window = win - - win.update_idletasks() - width = max(win.winfo_reqwidth(), 520) - height = max(win.winfo_reqheight(), 155) - x = self.root.winfo_rootx() + max((self.root.winfo_width() - width) // 2, 0) - y = self.root.winfo_rooty() + max((self.root.winfo_height() - height) // 2, 0) - win.geometry(f"{width}x{height}+{x}+{y}") - win.lift() - try: - # Keep checkboxes and folder selection from changing underneath a - # synchronous installation while progress callbacks pump Tk events. - win.grab_set() - except tk.TclError: - pass - - # update(), not only update_idletasks(), is intentional here: downloads - # run synchronously on the main thread, so Windows otherwise paints an - # empty Toplevel until the operation completes. - win.update() - - def _report_download_progress(self, message, current=None, total=None): - self._show_download_progress() - - if self._download_window is None or not self._download_window.winfo_exists(): - return - - self._download_label.configure(text=message) - - if total is not None and total > 0 and current is not None: - if self._download_indeterminate: - self._download_bar.stop() - self._download_indeterminate = False - - self._download_bar.configure(mode="determinate", maximum=total) - self._download_bar["value"] = min(current, total) - percent = int(min(current, total) * 100 / total) - self._download_percent.configure(text=f"{percent}%") - - downloaded_mb = current / (1024 * 1024) - total_mb = total / (1024 * 1024) - self._download_detail.configure( - text=f"{downloaded_mb:.1f} MB / {total_mb:.1f} MB" - ) - else: - self._download_percent.configure(text="") - self._download_detail.configure( - text="Checking, extracting or installing..." - ) - if not self._download_indeterminate: - self._download_bar.configure(mode="indeterminate") - self._download_bar.start(12) - self._download_indeterminate = True - - # Process paint/timer events while the synchronous network operation is - # running so the status text and progress bar remain visible. - try: - self._download_window.update() - except tk.TclError: - pass - - def _close_download_progress(self): - if self._download_bar is not None and self._download_indeterminate: - try: - self._download_bar.stop() - except tk.TclError: - pass - - if self._download_window is not None: - try: - if self._download_window.winfo_exists(): - try: - self._download_window.grab_release() - except tk.TclError: - pass - self._download_window.destroy() - except tk.TclError: - pass - - self._download_window = None - self._download_label = None - self._download_detail = None - self._download_percent = None - self._download_bar = None - self._download_indeterminate = False - - def _plugin_row(self, parent, text, variable, attribution, tooltip, command=None): - row = ttk.Frame(parent) - row.pack(fill="x", padx=10, pady=2) - - cb = ttk.Checkbutton(row, text=text, variable=variable, command=command) - cb.pack(side="left") - ToolTip(cb, tooltip) - - byline = ttk.Label( - row, - text=attribution, - font=("Segoe UI", 7, "italic"), - ) - byline.pack(side="right", padx=(6, 0)) - - return cb - - def _select_no1600(self): - if self.optional_plugins["no1600x1200.dll"].get(): - self.vmmfix_enabled.set(False) - - def _select_vmmfix(self): - if self.vmmfix_enabled.get(): - self.optional_plugins["no1600x1200.dll"].set(False) - - def build_plugins_tab(self, parent): - container = ttk.Frame(parent) - container.pack(fill="both", expand=True, padx=10, pady=10) - - left_frame = ttk.LabelFrame(container, text="Recommended Core") - left_frame.pack(side="left", fill="both", expand=True, padx=(0, 5)) - - ttk.Label( - left_frame, - text="These are highly recommended for performance\nand client stability.", - font=("", 8, "italic"), - ).pack(anchor="w", padx=10, pady=10) - - core_display = { - "nampower.dll": ("Nampower", "by brues-code"), - "UnitXP_SP3.dll": ("UnitXP SP3", "by brues-code"), - "SuperWoWhook.dll": ("SuperWoW", "by balakethelock"), - "transmogfix.dll": ("TransmogFix", "by MarcelineVQ"), - "perf_boost.dll": ("PerfBoost", "by avitasia"), - "weirdperformance.dll": ("WeirdPerformance", "by Dusk-92"), - "VanillaHelpers.dll": ("VanillaHelpers", "by isfir"), - } - - for dll, var in self.core_plugins.items(): - display_name, attribution = core_display.get( - dll, - (os.path.splitext(dll)[0], "") - ) - self._plugin_row( - left_frame, - display_name, - var, - attribution, - self.descriptions.get(dll, ""), - ) - - self._plugin_row( - left_frame, - "ClassicAPI", - self.classicapi_enabled, - "by brues-code", - "Adds newer WoW API functions to the Vanilla client so more modern addons can work.", - ) - - self._plugin_row( - left_frame, - "AuctionQueryThrottle", - self.auction_throttle_enabled, - "by brues-code", - "Makes Auction House searches much faster by removing the fixed 5-second wait between queries.", - ) - - right_frame = ttk.LabelFrame(container, text="Optional") - right_frame.pack(side="right", fill="both", expand=True, padx=(5, 0)) - - ttk.Label( - right_frame, - text="Optional client-side fixes and quality-of-life enhancements.", - font=("", 8, "italic"), - wraplength=260, - ).pack(anchor="w", padx=10, pady=10) - - no1600_var = self.optional_plugins["no1600x1200.dll"] - self._plugin_row( - right_frame, - "No1600x1200", - no1600_var, - "source: RetroCro", - self.descriptions.get("no1600x1200.dll", ""), - command=self._select_no1600, - ) - - self._plugin_row( - right_frame, - "VanillaMultiMonitorFix", - self.vmmfix_enabled, - "by Mates1500", - "Fixes resolution and refresh-rate detection on multi-monitor setups with different display modes. Uses VMMFix_preferred_monitor.txt to select the preferred display.", - command=self._select_vmmfix, - ) - - self._plugin_row( - right_frame, - "Interact", - self.interact_enabled, - "by lookino", - "Adds a modern Interact key for nearby objects, gathering nodes and loot without precise mouse clicking.", - ) - - optional_display = { - "bigcursor.dll": "BigCursor", - "customassets.dll": "CustomAssets", - "logsessions.dll": "LogSessions", - "minimapicons.dll": "MinimapIcons", - "pngscreenshots.dll": "PNG Screenshots", - "worldmarkers.dll": "WorldMarkers", - "WowPresence.dll": "Discord Rich Presence", - } - optional_attribution = { - "WowPresence.dll": "by Dusk-92", - } - - for dll, var in self.optional_plugins.items(): - if dll == "no1600x1200.dll": - continue - - command = ( - self.update_discord_presence_controls - if dll == "WowPresence.dll" - else None - ) - self._plugin_row( - right_frame, - optional_display.get(dll, os.path.splitext(dll)[0]), - var, - optional_attribution.get(dll, "by MarcelineVQ"), - self.descriptions.get(dll, ""), - command=command, - ) - - if dll == "WowPresence.dll": - self._build_discord_presence_details(right_frame) - - def clean_unselected_files(self, target): - super().clean_unselected_files(target) - - managed = { - "ClassicAPI.dll": self.classicapi_enabled, - "AuctionQueryThrottle.dll": self.auction_throttle_enabled, - "VanillaMultiMonitorFix.dll": self.vmmfix_enabled, - "Interact.dll": self.interact_enabled, - } - - # Remove each live core DLL independently when its checkbox is off. - for filename, var in managed.items(): - if not var.get(): - path = os.path.join(target, filename) - if os.path.exists(path): - try: - os.remove(path) - except OSError as exc: - raise RuntimeError( - f"Could not remove {filename}. Close WoW and any tool using the file, then try again." - ) from exc - - if not self.interact_enabled.get(): - addon_path = os.path.join(target, "Interface", "AddOns", "Interact") - if os.path.exists(addon_path): - shutil.rmtree(addon_path, ignore_errors=True) - - def _warn_offline(self, component, action, error): - self._close_download_progress() - messagebox.showwarning( - "Online update unavailable", - f"Could not download the latest {component}.\n\n" - f"{action}\n\nDetails: {error}", - ) - - def _valid_x86_dll(self, path, label): - if not os.path.isfile(path): - return False - try: - remote_packages._verify_x86_pe(path, label) - return True - except Exception: - return False - - def _prepare_wowpresence_managed_state(self, target): - """Force legacy tool-managed installs without hashes through a safe refresh.""" - trust_state = remote_packages.wowpresence_install_trust_state(target) - if trust_state == "managed_unverified": - remote_packages._set_managed_manifest_values( - target, - remote_packages.WOWPRESENCE_MANAGED_ID, - revision="__legacy_unverified__", - ) - return trust_state - - def _wowpresence_source_failure(self, error): - """Distinguish unusable remote packages from local install/write failures.""" - messages = [] - current = error - seen = set() - while current is not None and id(current) not in seen: - seen.add(id(current)) - messages.append(str(current)) - current = ( - current.__cause__ - if current.__cause__ is not None - else current.__context__ - ) - - text = "\n".join(messages) - source_markers = ( - "GitHub request failed:", - "latest release is not a stable release", - "Could not find WowPresence.zip in release", - "Release asset WowPresence.zip has no download URL", - "Download failed for WowPresence.zip:", - "SHA-256 mismatch for downloaded file", - "Unsafe ZIP path:", - "WowPresence.dll was not found in downloaded archive.", - "WowPresence.exe was not found in downloaded archive.", - "WowPresence.dll is ", - "WowPresence.dll has ", - "WowPresence.exe is ", - "WowPresence.exe has ", - "File is not a zip file", - "Bad magic number for file header", - ) - return any(marker in text for marker in source_markers) - - def _install_wowpresence_with_fallback(self, target): - """Use offline recovery only for remote-source failures, never local ones.""" - self._prepare_wowpresence_managed_state(target) - try: - return remote_packages.install_wowpresence( - target, - progress=self._report_download_progress, - ) - except Exception as exc: - if not self._wowpresence_source_failure(exc): - raise RuntimeError( - f"WowPresence installation failed locally:\n{exc}" - ) from exc - self._recover_wowpresence_offline(target, exc) - return None - - def _recover_wowpresence_offline(self, target, error): - """Preserve only trusted/manual-valid WowPresence; repair modified managed files.""" - trust_state = remote_packages.wowpresence_install_trust_state(target) - existing_dll = os.path.join(target, "WowPresence.dll") - existing_exe = os.path.join(target, "WowPresence.exe") - existing_pair_valid = ( - self._valid_x86_dll(existing_dll, "WowPresence.dll") - and self._valid_x86_dll(existing_exe, "WowPresence.exe") - ) - - if trust_state == "managed_verified": - remote_packages.ensure_wowpresence_config(target) - remote_packages.cache_installed_wowpresence(target) - self._warn_offline( - "WowPresence", - "Keeping the hash-verified WowPresence binaries already managed by the Tool.", - error, - ) - return - - if trust_state == "unmanaged" and existing_pair_valid: - remote_packages.ensure_wowpresence_config(target) - self._warn_offline( - "WowPresence", - ( - "Keeping the existing valid WowPresence binaries unchanged. " - "They were not promoted into the validated fallback cache." - ), - error, - ) - return - - try: - remote_packages.install_cached_wowpresence( - target, - progress=self._report_download_progress, - ) - fallback_action = ( - "The last validated cached WowPresence package was installed instead." - ) - except Exception: - try: - remote_packages.install_bundled_wowpresence( - target, - progress=self._report_download_progress, - ) - fallback_action = ( - "The bundled known-good WowPresence fallback was installed instead." - ) - except Exception as bundled_exc: - raise RuntimeError( - "Could not install WowPresence from its online source, " - "local cache or bundled fallback." - ) from bundled_exc - - self._warn_offline( - "WowPresence", - fallback_action, - error, - ) - - def _valid_superapi_addon(self, addon_path): - """Reject empty/partial SuperAPI folders before treating them as usable.""" - required = ( - "SuperAPI.toc", - "SuperAPI.lua", - "SuperAPIOptions.lua", - ) - if not os.path.isdir(addon_path): - return False - if any(not os.path.isfile(os.path.join(addon_path, name)) for name in required): - return False - - toc_path = os.path.join(addon_path, "SuperAPI.toc") - try: - with open(toc_path, "r", encoding="utf-8", errors="ignore") as handle: - lines = handle.readlines() - except OSError: - return False - - for raw in lines: - line = raw.strip() - if not line or line.startswith("#"): - continue - normalized = line.replace("\\", os.sep).replace("/", os.sep) - if normalized.startswith(".." + os.sep) or os.path.isabs(normalized): - return False - extension = os.path.splitext(normalized)[1].lower() - if extension in (".lua", ".xml") and not os.path.isfile( - os.path.join(addon_path, normalized) - ): - return False - return True - - def _fallback_core_dll(self, payload_base, target, dll_name, error): - source_dll = os.path.join(payload_base, dll_name) - target_dll = os.path.join(target, dll_name) - addon_folder = self.addon_dependencies.get(dll_name) - target_addon = ( - os.path.join(target, "Interface", "AddOns", addon_folder) - if addon_folder - else None - ) - - # Never downgrade a complete existing install because an upstream link - # happens to be unavailable during an update. - addon_complete = ( - target_addon is None - or ( - self._valid_superapi_addon(target_addon) - if addon_folder == "SuperAPI" - else os.path.isdir(target_addon) - ) - ) - existing_complete = self._valid_x86_dll( - target_dll, - f"installed {dll_name}", - ) and addon_complete - if existing_complete: - self._warn_offline( - dll_name, - "The version already installed in your WoW folder was kept unchanged.", - error, - ) - return - - if not os.path.isfile(source_dll): - raise RuntimeError( - f"Could not download the latest {dll_name}, and no bundled fallback exists.\n\n{error}" - ) from error - - self._verified_bundled_file( - f"Payload/{dll_name}", - f"{dll_name} fallback", - ) - - source_addon = None - if addon_folder: - source_addon = os.path.join(payload_base, "Interface", "Addons", addon_folder) - if not os.path.isdir(source_addon): - raise RuntimeError( - f"Could not download the latest {dll_name}, and its bundled fallback addon " - f"{addon_folder} is missing.\n\n{error}" - ) from error - if addon_folder == "SuperAPI": - source_addon = self._verified_bundled_tree( - "SuperWoW fallback", - "superapi_files", - "Payload/Interface/Addons/SuperAPI", - "SuperAPI fallback", - ) - - if addon_folder and source_addon: - remote_packages._transactional_replace_bundle( - [ - ("file", source_dll, target_dll), - ("dir", source_addon, target_addon), - ], - label=f"{dll_name} bundled fallback", - ) - else: - remote_packages._atomic_replace_file(source_dll, target_dll) - - self._warn_offline( - dll_name, - "The bundled known-good backup was installed instead.", - error, - ) - - def _fallback_simple_dll(self, target, dll_name, error): - target_dll = os.path.join(target, dll_name) - if self._valid_x86_dll(target_dll, f"installed {dll_name}"): - self._warn_offline( - dll_name, - "The version already installed in your WoW folder was kept unchanged.", - error, - ) - return - - fallback = os.path.join(get_base_path(), "Payload", "Fallback", dll_name) - if not os.path.isfile(fallback): - raise RuntimeError( - f"Could not download the latest {dll_name}, and its bundled backup is missing.\n\n{error}" - ) from error - - remote_packages._verify_x86_pe( - fallback, - f"bundled fallback {dll_name}", - ) - remote_packages._atomic_replace_file(fallback, target_dll) - self._warn_offline( - dll_name, - "The bundled known-good backup was installed instead.", - error, - ) - - def _fallback_vmmfix(self, target, error): - target_dll = os.path.join(target, "VanillaMultiMonitorFix.dll") - if self._valid_x86_dll( - target_dll, - "installed VanillaMultiMonitorFix.dll", - ): - self._warn_offline( - "VanillaMultiMonitorFix", - "The version already installed in your WoW folder was kept unchanged.", - error, - ) - return - - fallback_dir = os.path.join( - get_base_path(), "Payload", "Fallback", "VanillaMultiMonitorFix" - ) - fallback_dll = os.path.join(fallback_dir, "VanillaMultiMonitorFix.dll") - fallback_config = os.path.join(fallback_dir, "VMMFix_preferred_monitor.txt") - if not os.path.isfile(fallback_dll): - raise RuntimeError( - f"VanillaMultiMonitorFix update failed and its bundled backup is missing.\n\n{error}" - ) from error - - remote_packages._verify_x86_pe( - fallback_dll, - "bundled fallback VanillaMultiMonitorFix.dll", - ) - remote_packages._atomic_replace_file(fallback_dll, target_dll) - target_config = os.path.join(target, "VMMFix_preferred_monitor.txt") - if not os.path.exists(target_config) and os.path.isfile(fallback_config): - shutil.copy2(fallback_config, target_config) - - self._warn_offline( - "VanillaMultiMonitorFix", - "The bundled known-good backup was installed instead.", - error, - ) - - def _fallback_interact(self, target, error): - target_dll = os.path.join(target, "Interact.dll") - target_addon = os.path.join(target, "Interface", "AddOns", "Interact") - if ( - self._valid_x86_dll(target_dll, "installed Interact.dll") - and os.path.isdir(target_addon) - ): - self._warn_offline( - "Interact", - "The version already installed in your WoW folder was kept unchanged.", - error, - ) - return - - fallback_dir = os.path.join(get_base_path(), "Payload", "Fallback", "Interact") - fallback_dll = os.path.join(fallback_dir, "Interact.dll") - fallback_addon = os.path.join(fallback_dir, "Addon") - if not os.path.isfile(fallback_dll) or not os.path.isdir(fallback_addon): - raise RuntimeError( - f"Interact update failed and its bundled backup is incomplete.\n\n{error}" - ) from error - - remote_packages._verify_x86_pe( - fallback_dll, - "bundled fallback Interact.dll", - ) - remote_packages._transactional_replace_bundle( - [ - ("file", fallback_dll, target_dll), - ("dir", fallback_addon, target_addon), - ], - label="Interact bundled fallback", - ) - self._warn_offline( - "Interact", - "The bundled known-good backup was installed instead.", - error, - ) +# Keep the feature-branch implementation intact and layer only the executable +# normalization policy here. This makes the B-total policy easy to audit and +# keeps every unrelated remote/fallback behavior byte-for-byte unchanged. +from setup_tool_dynamic_core import * # noqa: F401,F403 +from setup_tool_dynamic_core import ModernWowSetupTool as _ModernWowSetupToolCore + + +_CAMERA_REGIONS = ( + ( + 0x02CCD0, + bytes.fromhex( + "55 8b ec 83 ec 10 8d 45 f0 50 33 c9 e8 4f 8f 00 " + "00 50 ff 15 64 f6 7f 00 8b 45 f8 99 2b c2 8b c8 " + "8b 45 fc 99 2b c2 d1 f8 d1 f9 50 51 89 0d 38 4e " + "88 00 a3 3c 4e 88 00 ff 15 5c f6 7f 00 8b e5 5d " + "c3 90 90" + ), + bytes.fromhex( + "55 8b 05 48 4e 88 00 8b 0d 44 4e 88 00 e9 33 90 " + "32 00 83 c0 32 83 c1 32 3b 0d a8 eb c4 00 7e 03 " + "83 e9 01 3b 05 ac eb c4 00 7e 03 83 e8 01 83 e9 " + "32 83 e8 32 89 05 48 4e 88 00 89 0d 44 4e 88 00 " + "5d eb 0d" + ), + ), + ( + 0x02D326, + bytes.fromhex("8b 45 f0 8b 15"), + bytes.fromhex("e9 b1 8a 32 00"), + ), + ( + 0x02D334, + bytes.fromhex("8b 35 3c 4e 88 00"), + bytes.fromhex("8b 35 48 4e 88 00"), + ), + ( + 0x355D15, + bytes.fromhex( + "cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc " + "cc cc cc cc cc" + ), + bytes.fromhex( + "83 f8 32 7d 03 83 c0 01 83 f9 32 7d 03 83 c1 01 " + "e9 b8 6f cd ff" + ), + ), + ( + 0x355DDC, + bytes.fromhex( + "cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc " + "cc cc cc cc cc cc cc cc cc cc cc cc cc cc" + ), + bytes.fromhex( + "8d 4d f0 51 ff 35 00 4e 88 00 ff 15 50 f6 7f 00 " + "8b 45 f0 8b 15 44 4e 88 00 e9 35 75 cd ff" + ), + ), +) + +_CUSTOM_GLUES_SITES = ( + (0x2F113A, 0x5F, 0xEB), + (0x2F113B, 0x5E, 0x19), + (0x2F1158, 0x01, 0x03), + (0x2F11A7, 0x01, 0x03), + (0x2F11F0, 0x5F, 0xEB), + (0x2F11F1, 0x5E, 0xB2), +) + + +class ModernWowSetupTool(_ModernWowSetupToolCore): + """Remote-fallback tool with authoritative, fail-safe Vanilla Tweaks output.""" def _vanilla_tweaks_signature(self): - """Version output normalization independently from the SuperWoW checkbox.""" signature = super()._vanilla_tweaks_signature() - signature["selected_patch_normalization"] = 1 + # v2 expands normalization from the original selected subset to every + # executable tweak exposed by the Tool except the two intentionally + # legacy upstream-only patches (Blue Moon and Cross-faction Res). + signature["selected_patch_normalization"] = 2 return signature + @staticmethod + def _validate_float_field(data, offset, label, minimum, maximum): + current = struct.unpack_from(" 4: + raise RuntimeError("Sound Channels value is outside the supported WoW 1.12.1 range.") + + # All validation passed. Apply the exact Tool selections in memory. + desired_quickloot_opcode = 0x75 if self.vt_quickloot.get() else 0x74 + for offset, displacement in quickloot_sites: + data[offset:offset + 2] = bytes( + (desired_quickloot_opcode, displacement) + ) + data[0x3A4869] = 0x27 if self.vt_bg_sound.get() else 0x14 + data[0x126:0x128] = b"\x2F\x01" if self.vt_laa.get() else b"\x0F\x01" - data[0x4089B4:0x4089B8] = struct.pack(" 4: - raise RuntimeError("Sound Channels value is too large for the WoW 1.12.1 field.") + desired_custom_patched = bool(self.vt_custom_glues.get()) + for offset, original, patched in _CUSTOM_GLUES_SITES: + data[offset] = patched if desired_custom_patched else original + + struct.pack_into(" 63 + and (mask & remote_packages.WOWPRESENCE_SHARE_ALL) + == remote_packages.WOWPRESENCE_SHARE_ALL + ) + self.discord_show_character_details.set(legacy_all or current_all) + if self.discord_show_character_details.get(): + for var in self.discord_detail_vars.values(): + var.set(True) + + def _load_legacy_install_state(self, target_dir): + super()._load_legacy_install_state(target_dir) + self._load_wowpresence_broadcast_preferences(target_dir) + + def load_settings(self, target_dir): + settings_path = self._settings_path(target_dir) + has_saved_discord_preferences = False + try: + with open(settings_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + has_saved_discord_preferences = isinstance( + saved.get("discord_presence") if isinstance(saved, dict) else None, + dict, + ) + except (OSError, json.JSONDecodeError, ValueError, TypeError): + pass + + loaded = super().load_settings(target_dir) + + # Existing v2.1 settings.json files predate the detailed controls. Read + # their actual WowPresence mask once so manual choices are not lost. + if os.path.isfile(settings_path) and not has_saved_discord_preferences: + self._load_wowpresence_broadcast_preferences(target_dir) + + self.update_discord_presence_controls() + return loaded + + def _discord_broadcast_mask(self): + if self.discord_show_character_details.get(): + return remote_packages.WOWPRESENCE_SHARE_ALL + + bits = { + "name": remote_packages.WOWPRESENCE_SHARE_NAME, + "guild": remote_packages.WOWPRESENCE_SHARE_GUILD, + "race": remote_packages.WOWPRESENCE_SHARE_RACE, + "faction": remote_packages.WOWPRESENCE_SHARE_FACTION, + "class": remote_packages.WOWPRESENCE_SHARE_CLASS, + "level": remote_packages.WOWPRESENCE_SHARE_LEVEL, + "zone": remote_packages.WOWPRESENCE_SHARE_ZONE, + } + mask = 0 + for name, bit in bits.items(): + var = self.discord_detail_vars.get(name) + if var is not None and var.get(): + mask |= bit + return mask + + def _toggle_discord_all_details(self): + show_all = bool(self.discord_show_character_details.get()) + if show_all: + for var in self.discord_detail_vars.values(): + var.set(True) + self.update_discord_detail_states() + + def update_discord_detail_states(self): + state = "disabled" if self.discord_show_character_details.get() else "normal" + for checkbox in getattr(self, "discord_detail_checkbuttons", {}).values(): + try: + checkbox.configure(state=state) + except tk.TclError: + pass + + def update_discord_presence_controls(self): + frame = getattr(self, "discord_presence_details_frame", None) + if frame is None: + return + + var = self.optional_plugins.get("WowPresence.dll") + visible = bool(var is not None and var.get()) + manager = frame.winfo_manager() + + if visible and not manager: + frame.pack(fill="x", padx=(22, 8), pady=(0, 4)) + elif not visible and manager: + frame.pack_forget() + + if visible: + self.update_discord_detail_states() + + def _build_discord_presence_details(self, parent): + frame = ttk.Frame(parent) + self.discord_presence_details_frame = frame + + show_cb = ttk.Checkbutton( + frame, + text="Show character details", + variable=self.discord_show_character_details, + command=self._toggle_discord_all_details, + ) + show_cb.pack(anchor="w", padx=6, pady=(1, 2)) + ToolTip( + show_cb, + "When enabled, all character details are shown and the individual choices " + "below are locked. Uncheck it to choose each detail separately.", + ) + + labels = { + "name": "Character Name", + "guild": "Guild", + "race": "Race", + "faction": "Faction", + "class": "Class", + "level": "Level", + "zone": "Zone", + } + for name, label in labels.items(): + cb = ttk.Checkbutton( + frame, + text=label, + variable=self.discord_detail_vars[name], + ) + cb.pack(anchor="w", padx=24, pady=1) + self.discord_detail_checkbuttons[name] = cb + ToolTip( + cb, + f"Choose whether WowPresence may publish your {label.lower()} on Discord.", + ) + + self.update_discord_presence_controls() + + def _show_download_progress(self): + if self._download_window is not None and self._download_window.winfo_exists(): + return + + win = tk.Toplevel(self.root) + win.title("Updating components") + win.resizable(False, False) + win.transient(self.root) + win.protocol("WM_DELETE_WINDOW", lambda: None) + + frame = ttk.Frame(win, padding=18) + frame.pack(fill="both", expand=True) + frame.columnconfigure(0, weight=1) + + title = ttk.Label( + frame, + text="Updating selected components", + font=("Segoe UI", 11, "bold"), + anchor="w", + ) + title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + + self._download_label = ttk.Label( + frame, + text="Preparing updates...", + font=("Segoe UI", 9, "bold"), + anchor="w", + ) + self._download_label.grid(row=1, column=0, sticky="w", pady=(0, 4)) + + self._download_percent = ttk.Label( + frame, + text="", + font=("Segoe UI", 9, "bold"), + anchor="e", + width=6, + ) + self._download_percent.grid(row=1, column=1, sticky="e", pady=(0, 4)) + + self._download_detail = ttk.Label( + frame, + text="Please wait while files are downloaded and installed.", + anchor="w", + foreground="#666666", + ) + self._download_detail.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 10)) + + self._download_bar = ttk.Progressbar( + frame, + orient="horizontal", + mode="indeterminate", + length=460, + ) + self._download_bar.grid(row=3, column=0, columnspan=2, sticky="ew") + + self._download_window = win + + win.update_idletasks() + width = max(win.winfo_reqwidth(), 520) + height = max(win.winfo_reqheight(), 155) + x = self.root.winfo_rootx() + max((self.root.winfo_width() - width) // 2, 0) + y = self.root.winfo_rooty() + max((self.root.winfo_height() - height) // 2, 0) + win.geometry(f"{width}x{height}+{x}+{y}") + win.lift() + try: + # Keep checkboxes and folder selection from changing underneath a + # synchronous installation while progress callbacks pump Tk events. + win.grab_set() + except tk.TclError: + pass + + # update(), not only update_idletasks(), is intentional here: downloads + # run synchronously on the main thread, so Windows otherwise paints an + # empty Toplevel until the operation completes. + win.update() + + def _report_download_progress(self, message, current=None, total=None): + self._show_download_progress() + + if self._download_window is None or not self._download_window.winfo_exists(): + return + + self._download_label.configure(text=message) + + if total is not None and total > 0 and current is not None: + if self._download_indeterminate: + self._download_bar.stop() + self._download_indeterminate = False + + self._download_bar.configure(mode="determinate", maximum=total) + self._download_bar["value"] = min(current, total) + percent = int(min(current, total) * 100 / total) + self._download_percent.configure(text=f"{percent}%") + + downloaded_mb = current / (1024 * 1024) + total_mb = total / (1024 * 1024) + self._download_detail.configure( + text=f"{downloaded_mb:.1f} MB / {total_mb:.1f} MB" + ) + else: + self._download_percent.configure(text="") + self._download_detail.configure( + text="Checking, extracting or installing..." + ) + if not self._download_indeterminate: + self._download_bar.configure(mode="indeterminate") + self._download_bar.start(12) + self._download_indeterminate = True + + # Process paint/timer events while the synchronous network operation is + # running so the status text and progress bar remain visible. + try: + self._download_window.update() + except tk.TclError: + pass + + def _close_download_progress(self): + if self._download_bar is not None and self._download_indeterminate: + try: + self._download_bar.stop() + except tk.TclError: + pass + + if self._download_window is not None: + try: + if self._download_window.winfo_exists(): + try: + self._download_window.grab_release() + except tk.TclError: + pass + self._download_window.destroy() + except tk.TclError: + pass + + self._download_window = None + self._download_label = None + self._download_detail = None + self._download_percent = None + self._download_bar = None + self._download_indeterminate = False + + def _plugin_row(self, parent, text, variable, attribution, tooltip, command=None): + row = ttk.Frame(parent) + row.pack(fill="x", padx=10, pady=2) + + cb = ttk.Checkbutton(row, text=text, variable=variable, command=command) + cb.pack(side="left") + ToolTip(cb, tooltip) + + byline = ttk.Label( + row, + text=attribution, + font=("Segoe UI", 7, "italic"), + ) + byline.pack(side="right", padx=(6, 0)) + + return cb + + def _select_no1600(self): + if self.optional_plugins["no1600x1200.dll"].get(): + self.vmmfix_enabled.set(False) + + def _select_vmmfix(self): + if self.vmmfix_enabled.get(): + self.optional_plugins["no1600x1200.dll"].set(False) + + def build_plugins_tab(self, parent): + container = ttk.Frame(parent) + container.pack(fill="both", expand=True, padx=10, pady=10) + + left_frame = ttk.LabelFrame(container, text="Recommended Core") + left_frame.pack(side="left", fill="both", expand=True, padx=(0, 5)) + + ttk.Label( + left_frame, + text="These are highly recommended for performance\nand client stability.", + font=("", 8, "italic"), + ).pack(anchor="w", padx=10, pady=10) + + core_display = { + "nampower.dll": ("Nampower", "by brues-code"), + "UnitXP_SP3.dll": ("UnitXP SP3", "by brues-code"), + "SuperWoWhook.dll": ("SuperWoW", "by balakethelock"), + "transmogfix.dll": ("TransmogFix", "by MarcelineVQ"), + "perf_boost.dll": ("PerfBoost", "by avitasia"), + "weirdperformance.dll": ("WeirdPerformance", "by Dusk-92"), + "VanillaHelpers.dll": ("VanillaHelpers", "by isfir"), + } + + for dll, var in self.core_plugins.items(): + display_name, attribution = core_display.get( + dll, + (os.path.splitext(dll)[0], "") + ) + self._plugin_row( + left_frame, + display_name, + var, + attribution, + self.descriptions.get(dll, ""), + ) + + self._plugin_row( + left_frame, + "ClassicAPI", + self.classicapi_enabled, + "by brues-code", + "Adds newer WoW API functions to the Vanilla client so more modern addons can work.", + ) + + self._plugin_row( + left_frame, + "AuctionQueryThrottle", + self.auction_throttle_enabled, + "by brues-code", + "Makes Auction House searches much faster by removing the fixed 5-second wait between queries.", + ) + + right_frame = ttk.LabelFrame(container, text="Optional") + right_frame.pack(side="right", fill="both", expand=True, padx=(5, 0)) + + ttk.Label( + right_frame, + text="Optional client-side fixes and quality-of-life enhancements.", + font=("", 8, "italic"), + wraplength=260, + ).pack(anchor="w", padx=10, pady=10) + + no1600_var = self.optional_plugins["no1600x1200.dll"] + self._plugin_row( + right_frame, + "No1600x1200", + no1600_var, + "source: RetroCro", + self.descriptions.get("no1600x1200.dll", ""), + command=self._select_no1600, + ) + + self._plugin_row( + right_frame, + "VanillaMultiMonitorFix", + self.vmmfix_enabled, + "by Mates1500", + "Fixes resolution and refresh-rate detection on multi-monitor setups with different display modes. Uses VMMFix_preferred_monitor.txt to select the preferred display.", + command=self._select_vmmfix, + ) + + self._plugin_row( + right_frame, + "Interact", + self.interact_enabled, + "by lookino", + "Adds a modern Interact key for nearby objects, gathering nodes and loot without precise mouse clicking.", + ) + + optional_display = { + "bigcursor.dll": "BigCursor", + "customassets.dll": "CustomAssets", + "logsessions.dll": "LogSessions", + "minimapicons.dll": "MinimapIcons", + "pngscreenshots.dll": "PNG Screenshots", + "worldmarkers.dll": "WorldMarkers", + "WowPresence.dll": "Discord Rich Presence", + } + optional_attribution = { + "WowPresence.dll": "by Dusk-92", + } + + for dll, var in self.optional_plugins.items(): + if dll == "no1600x1200.dll": + continue + + command = ( + self.update_discord_presence_controls + if dll == "WowPresence.dll" + else None + ) + self._plugin_row( + right_frame, + optional_display.get(dll, os.path.splitext(dll)[0]), + var, + optional_attribution.get(dll, "by MarcelineVQ"), + self.descriptions.get(dll, ""), + command=command, + ) + + if dll == "WowPresence.dll": + self._build_discord_presence_details(right_frame) + + def clean_unselected_files(self, target): + super().clean_unselected_files(target) + + managed = { + "ClassicAPI.dll": self.classicapi_enabled, + "AuctionQueryThrottle.dll": self.auction_throttle_enabled, + "VanillaMultiMonitorFix.dll": self.vmmfix_enabled, + "Interact.dll": self.interact_enabled, + } + + # Remove each live core DLL independently when its checkbox is off. + for filename, var in managed.items(): + if not var.get(): + path = os.path.join(target, filename) + if os.path.exists(path): + try: + os.remove(path) + except OSError as exc: + raise RuntimeError( + f"Could not remove {filename}. Close WoW and any tool using the file, then try again." + ) from exc + + if not self.interact_enabled.get(): + addon_path = os.path.join(target, "Interface", "AddOns", "Interact") + if os.path.exists(addon_path): + shutil.rmtree(addon_path, ignore_errors=True) + + def _warn_offline(self, component, action, error): + self._close_download_progress() + messagebox.showwarning( + "Online update unavailable", + f"Could not download the latest {component}.\n\n" + f"{action}\n\nDetails: {error}", + ) + + def _valid_x86_dll(self, path, label): + if not os.path.isfile(path): + return False + try: + remote_packages._verify_x86_pe(path, label) + return True + except Exception: + return False + + def _prepare_wowpresence_managed_state(self, target): + """Force legacy tool-managed installs without hashes through a safe refresh.""" + trust_state = remote_packages.wowpresence_install_trust_state(target) + if trust_state == "managed_unverified": + remote_packages._set_managed_manifest_values( + target, + remote_packages.WOWPRESENCE_MANAGED_ID, + revision="__legacy_unverified__", + ) + return trust_state + + def _wowpresence_source_failure(self, error): + """Distinguish unusable remote packages from local install/write failures.""" + messages = [] + current = error + seen = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + messages.append(str(current)) + current = ( + current.__cause__ + if current.__cause__ is not None + else current.__context__ + ) + + text = "\n".join(messages) + source_markers = ( + "GitHub request failed:", + "latest release is not a stable release", + "Could not find WowPresence.zip in release", + "Release asset WowPresence.zip has no download URL", + "Download failed for WowPresence.zip:", + "SHA-256 mismatch for downloaded file", + "Unsafe ZIP path:", + "WowPresence.dll was not found in downloaded archive.", + "WowPresence.exe was not found in downloaded archive.", + "WowPresence.dll is ", + "WowPresence.dll has ", + "WowPresence.exe is ", + "WowPresence.exe has ", + "File is not a zip file", + "Bad magic number for file header", + ) + return any(marker in text for marker in source_markers) + + def _install_wowpresence_with_fallback(self, target): + """Use offline recovery only for remote-source failures, never local ones.""" + self._prepare_wowpresence_managed_state(target) + try: + return remote_packages.install_wowpresence( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + if not self._wowpresence_source_failure(exc): + raise RuntimeError( + f"WowPresence installation failed locally:\n{exc}" + ) from exc + self._recover_wowpresence_offline(target, exc) + return None + + def _recover_wowpresence_offline(self, target, error): + """Preserve only trusted/manual-valid WowPresence; repair modified managed files.""" + trust_state = remote_packages.wowpresence_install_trust_state(target) + existing_dll = os.path.join(target, "WowPresence.dll") + existing_exe = os.path.join(target, "WowPresence.exe") + existing_pair_valid = ( + self._valid_x86_dll(existing_dll, "WowPresence.dll") + and self._valid_x86_dll(existing_exe, "WowPresence.exe") + ) + + if trust_state == "managed_verified": + remote_packages.ensure_wowpresence_config(target) + remote_packages.cache_installed_wowpresence(target) + self._warn_offline( + "WowPresence", + "Keeping the hash-verified WowPresence binaries already managed by the Tool.", + error, + ) + return + + if trust_state == "unmanaged" and existing_pair_valid: + remote_packages.ensure_wowpresence_config(target) + self._warn_offline( + "WowPresence", + ( + "Keeping the existing valid WowPresence binaries unchanged. " + "They were not promoted into the validated fallback cache." + ), + error, + ) + return + + try: + remote_packages.install_cached_wowpresence( + target, + progress=self._report_download_progress, + ) + fallback_action = ( + "The last validated cached WowPresence package was installed instead." + ) + except Exception: + try: + remote_packages.install_bundled_wowpresence( + target, + progress=self._report_download_progress, + ) + fallback_action = ( + "The bundled known-good WowPresence fallback was installed instead." + ) + except Exception as bundled_exc: + raise RuntimeError( + "Could not install WowPresence from its online source, " + "local cache or bundled fallback." + ) from bundled_exc + + self._warn_offline( + "WowPresence", + fallback_action, + error, + ) + + def _valid_superapi_addon(self, addon_path): + """Reject empty/partial SuperAPI folders before treating them as usable.""" + required = ( + "SuperAPI.toc", + "SuperAPI.lua", + "SuperAPIOptions.lua", + ) + if not os.path.isdir(addon_path): + return False + if any(not os.path.isfile(os.path.join(addon_path, name)) for name in required): + return False + + toc_path = os.path.join(addon_path, "SuperAPI.toc") + try: + with open(toc_path, "r", encoding="utf-8", errors="ignore") as handle: + lines = handle.readlines() + except OSError: + return False + + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + normalized = line.replace("\\", os.sep).replace("/", os.sep) + if normalized.startswith(".." + os.sep) or os.path.isabs(normalized): + return False + extension = os.path.splitext(normalized)[1].lower() + if extension in (".lua", ".xml") and not os.path.isfile( + os.path.join(addon_path, normalized) + ): + return False + return True + + def _fallback_core_dll(self, payload_base, target, dll_name, error): + source_dll = os.path.join(payload_base, dll_name) + target_dll = os.path.join(target, dll_name) + addon_folder = self.addon_dependencies.get(dll_name) + target_addon = ( + os.path.join(target, "Interface", "AddOns", addon_folder) + if addon_folder + else None + ) + + # Never downgrade a complete existing install because an upstream link + # happens to be unavailable during an update. + addon_complete = ( + target_addon is None + or ( + self._valid_superapi_addon(target_addon) + if addon_folder == "SuperAPI" + else os.path.isdir(target_addon) + ) + ) + existing_complete = self._valid_x86_dll( + target_dll, + f"installed {dll_name}", + ) and addon_complete + if existing_complete: + self._warn_offline( + dll_name, + "The version already installed in your WoW folder was kept unchanged.", + error, + ) + return + + if not os.path.isfile(source_dll): + raise RuntimeError( + f"Could not download the latest {dll_name}, and no bundled fallback exists.\n\n{error}" + ) from error + + self._verified_bundled_file( + f"Payload/{dll_name}", + f"{dll_name} fallback", + ) + + source_addon = None + if addon_folder: + source_addon = os.path.join(payload_base, "Interface", "Addons", addon_folder) + if not os.path.isdir(source_addon): + raise RuntimeError( + f"Could not download the latest {dll_name}, and its bundled fallback addon " + f"{addon_folder} is missing.\n\n{error}" + ) from error + if addon_folder == "SuperAPI": + source_addon = self._verified_bundled_tree( + "SuperWoW fallback", + "superapi_files", + "Payload/Interface/Addons/SuperAPI", + "SuperAPI fallback", + ) + + if addon_folder and source_addon: + remote_packages._transactional_replace_bundle( + [ + ("file", source_dll, target_dll), + ("dir", source_addon, target_addon), + ], + label=f"{dll_name} bundled fallback", + ) + else: + remote_packages._atomic_replace_file(source_dll, target_dll) + + self._warn_offline( + dll_name, + "The bundled known-good backup was installed instead.", + error, + ) + + def _fallback_simple_dll(self, target, dll_name, error): + target_dll = os.path.join(target, dll_name) + if self._valid_x86_dll(target_dll, f"installed {dll_name}"): + self._warn_offline( + dll_name, + "The version already installed in your WoW folder was kept unchanged.", + error, + ) + return + + fallback = os.path.join(get_base_path(), "Payload", "Fallback", dll_name) + if not os.path.isfile(fallback): + raise RuntimeError( + f"Could not download the latest {dll_name}, and its bundled backup is missing.\n\n{error}" + ) from error + + remote_packages._verify_x86_pe( + fallback, + f"bundled fallback {dll_name}", + ) + remote_packages._atomic_replace_file(fallback, target_dll) + self._warn_offline( + dll_name, + "The bundled known-good backup was installed instead.", + error, + ) + + def _fallback_vmmfix(self, target, error): + target_dll = os.path.join(target, "VanillaMultiMonitorFix.dll") + if self._valid_x86_dll( + target_dll, + "installed VanillaMultiMonitorFix.dll", + ): + self._warn_offline( + "VanillaMultiMonitorFix", + "The version already installed in your WoW folder was kept unchanged.", + error, + ) + return + + fallback_dir = os.path.join( + get_base_path(), "Payload", "Fallback", "VanillaMultiMonitorFix" + ) + fallback_dll = os.path.join(fallback_dir, "VanillaMultiMonitorFix.dll") + fallback_config = os.path.join(fallback_dir, "VMMFix_preferred_monitor.txt") + if not os.path.isfile(fallback_dll): + raise RuntimeError( + f"VanillaMultiMonitorFix update failed and its bundled backup is missing.\n\n{error}" + ) from error + + remote_packages._verify_x86_pe( + fallback_dll, + "bundled fallback VanillaMultiMonitorFix.dll", + ) + remote_packages._atomic_replace_file(fallback_dll, target_dll) + target_config = os.path.join(target, "VMMFix_preferred_monitor.txt") + if not os.path.exists(target_config) and os.path.isfile(fallback_config): + shutil.copy2(fallback_config, target_config) + + self._warn_offline( + "VanillaMultiMonitorFix", + "The bundled known-good backup was installed instead.", + error, + ) + + def _fallback_interact(self, target, error): + target_dll = os.path.join(target, "Interact.dll") + target_addon = os.path.join(target, "Interface", "AddOns", "Interact") + if ( + self._valid_x86_dll(target_dll, "installed Interact.dll") + and os.path.isdir(target_addon) + ): + self._warn_offline( + "Interact", + "The version already installed in your WoW folder was kept unchanged.", + error, + ) + return + + fallback_dir = os.path.join(get_base_path(), "Payload", "Fallback", "Interact") + fallback_dll = os.path.join(fallback_dir, "Interact.dll") + fallback_addon = os.path.join(fallback_dir, "Addon") + if not os.path.isfile(fallback_dll) or not os.path.isdir(fallback_addon): + raise RuntimeError( + f"Interact update failed and its bundled backup is incomplete.\n\n{error}" + ) from error + + remote_packages._verify_x86_pe( + fallback_dll, + "bundled fallback Interact.dll", + ) + remote_packages._transactional_replace_bundle( + [ + ("file", fallback_dll, target_dll), + ("dir", fallback_addon, target_addon), + ], + label="Interact bundled fallback", + ) + self._warn_offline( + "Interact", + "The bundled known-good backup was installed instead.", + error, + ) + + def _vanilla_tweaks_signature(self): + """Version output normalization independently from the SuperWoW checkbox.""" + signature = super()._vanilla_tweaks_signature() + signature["selected_patch_normalization"] = 1 + return signature + + def _normalize_selected_vanilla_tweaks_output(self, output_exe): + """Make selected FoV/sound/loot settings authoritative on pre-patched inputs.""" + try: + with open(output_exe, "rb") as handle: + data = bytearray(handle.read()) + except OSError as exc: + raise RuntimeError( + "Could not inspect WoW_Modernized.exe for Vanilla Tweaks normalization." + ) from exc + + required_size = 0x435D3C + if len(data) < required_size: + raise RuntimeError( + "WoW_Modernized.exe is too small for Vanilla Tweaks normalization." + ) + + quickloot_sites = ( + (0x0C1ECF, 0x10), + (0x0C2B25, 0x0B), + ) + desired_quickloot_opcode = 0x75 if self.vt_quickloot.get() else 0x74 + for offset, displacement in quickloot_sites: + current = bytes(data[offset:offset + 2]) + vanilla = bytes((0x74, displacement)) + reversed_loot = bytes((0x75, displacement)) + if current not in (vanilla, reversed_loot, b"\x90\x90"): + raise RuntimeError( + f"Unexpected QuickLoot bytes at 0x{offset:X}; refusing to alter an unknown client." + ) + data[offset:offset + 2] = bytes( + (desired_quickloot_opcode, displacement) + ) + + if data[0x3A4869] not in (0x14, 0x27): + raise RuntimeError( + "Unexpected Background Sound byte; refusing to alter an unknown client." + ) + data[0x3A4869] = 0x27 if self.vt_bg_sound.get() else 0x14 + + data[0x4089B4:0x4089B8] = struct.pack(" 4: + raise RuntimeError("Sound Channels value is too large for the WoW 1.12.1 field.") + data[0x435D38:0x435D3C] = sound_channels.ljust(4, b"\x00") + + staged = output_exe + ".modernization-normalized" + try: + with open(staged, "wb") as handle: + handle.write(data) + os.replace(staged, output_exe) + except OSError as exc: + raise RuntimeError( + "Could not write the normalized WoW_Modernized.exe." + ) from exc + finally: + if os.path.exists(staged): + try: + os.remove(staged) + except OSError: + pass + + def _run_vanilla_tweaks_transactional( + self, + target, + tweaks_exe, + modern_cli=True, + ): + """Patch, normalize and validate away from the live executable, then commit once.""" + wow_exe = os.path.join(target, "WoW.exe") + output_exe = os.path.join(target, "WoW_Modernized.exe") + final_staged = output_exe + ".modernization-new" + + if not os.path.isfile(wow_exe): + raise RuntimeError("WoW.exe is missing, so Vanilla Tweaks cannot be applied.") + + try: + staging_root = tempfile.mkdtemp( + prefix=".modernization-vt-", + dir=target, + ) + except OSError as exc: + raise RuntimeError( + "Could not create the temporary Vanilla Tweaks staging directory." + ) from exc + + try: + staged_wow = os.path.join(staging_root, "WoW.exe") + try: + shutil.copy2(wow_exe, staged_wow) + except OSError as exc: + raise RuntimeError( + "Could not stage WoW.exe for Vanilla Tweaks." + ) from exc + + staged_output = super().run_vanilla_tweaks( + staging_root, + tweaks_exe=tweaks_exe, + modern_cli=modern_cli, + ) + + # The Tool-owned selections are normalized before the live executable + # is touched. Unknown patch signatures therefore fail with the old + # WoW_Modernized.exe still completely intact. + self._normalize_selected_vanilla_tweaks_output(staged_output) + + valid_pe, reason = self._inspect_wow_executable(staged_output) + if not valid_pe: + raise RuntimeError( + "Normalized Vanilla Tweaks output is not a valid WoW executable. " + f"{reason}" + ) + if os.path.getsize(staged_output) < 1024 * 1024: + raise RuntimeError( + "Normalized Vanilla Tweaks output is unexpectedly small." + ) + + if os.path.exists(final_staged): + try: + os.remove(final_staged) + except OSError as exc: + raise RuntimeError( + "Could not prepare the final Vanilla Tweaks staging path." + ) from exc + + try: + os.replace(staged_output, final_staged) + except OSError as exc: + raise RuntimeError( + "Could not move the validated Vanilla Tweaks output into final staging." + ) from exc + + # Revalidate at the exact same-directory staging path used for the + # final atomic replacement. This is the last gate before commit. + valid_pe, reason = self._inspect_wow_executable(final_staged) + if not valid_pe: + raise RuntimeError( + "Final staged Vanilla Tweaks output is not a valid WoW executable. " + f"{reason}" + ) + if os.path.getsize(final_staged) < 1024 * 1024: + raise RuntimeError( + "Final staged Vanilla Tweaks output is unexpectedly small." + ) + + try: + os.replace(final_staged, output_exe) + except OSError as exc: + raise RuntimeError( + "Could not replace WoW_Modernized.exe with the validated staged output." + ) from exc + + return output_exe + finally: + if os.path.exists(final_staged): + try: + os.remove(final_staged) + except OSError: + pass + shutil.rmtree(staging_root, ignore_errors=True) + + def _vanilla_tweaks_marker_path(self, target): + return os.path.join( + target, + ".modernization_tool", + "vanilla_tweaks.json", + ) + + def _bundled_vanilla_tweaks_info(self): + info = { + "version": "bundled", + "source": "bundled vanilla-tweaks.exe", + } + metadata_path = os.path.join( + get_base_path(), + "Payload", + "Fallback", + "versions.json", + ) + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + component = data.get("components", {}).get("vanilla-tweaks fallback", {}) + if isinstance(component, dict): + if component.get("version"): + info["version"] = str(component["version"]) + if component.get("source"): + info["source"] = str(component["source"]) + except (OSError, json.JSONDecodeError, ValueError, TypeError): + pass + return info + + def _write_vanilla_tweaks_marker( + self, + target, + patcher_source, + patcher_version, + patcher_path, + patcher_revision=None, + ): + wow_exe = os.path.join(target, "WoW.exe") + output_exe = os.path.join(target, "WoW_Modernized.exe") + if not os.path.isfile(wow_exe) or not os.path.isfile(output_exe): + raise RuntimeError( + "Cannot record vanilla-tweaks state because a required WoW executable is missing." + ) + + marker_path = self._vanilla_tweaks_marker_path(target) + os.makedirs(os.path.dirname(marker_path), exist_ok=True) + temp_path = marker_path + ".new" + payload = { + "schema": 2, + "patcher_source": patcher_source, + "patcher_version": str(patcher_version), + "patcher_revision": ( + str(patcher_revision) if patcher_revision is not None else None + ), + "patcher_sha256": ( + self._file_sha256(patcher_path) + if patcher_path and os.path.isfile(patcher_path) + else None + ), + "input_wow_sha256": self._file_sha256(wow_exe), + "output_sha256": self._file_sha256(output_exe), + "settings_signature": self._vanilla_tweaks_signature(), + } + + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + os.replace(temp_path, marker_path) + + def _existing_vanilla_tweaks_output_matches(self, target): + marker_path = self._vanilla_tweaks_marker_path(target) + wow_exe = os.path.join(target, "WoW.exe") + output_exe = os.path.join(target, "WoW_Modernized.exe") + + if not ( + os.path.isfile(marker_path) + and os.path.isfile(wow_exe) + and os.path.isfile(output_exe) + ): + return False, None + + try: + with open(marker_path, "r", encoding="utf-8") as handle: + marker = json.load(handle) + if not isinstance(marker, dict): + return False, None + + if marker.get("settings_signature") != self._vanilla_tweaks_signature(): + return False, marker + + if marker.get("input_wow_sha256") != self._file_sha256(wow_exe): + return False, marker + + if marker.get("output_sha256") != self._file_sha256(output_exe): + return False, marker + + valid_pe, _ = self._inspect_wow_executable(output_exe) + if not valid_pe: + return False, marker + + return True, marker + except (OSError, json.JSONDecodeError, ValueError, TypeError): + return False, None + + def run_vanilla_tweaks(self, target): + # First compare the small GitHub release metadata with the patcher + # revision already recorded for this exact WoW.exe/settings output. + # The multi-megabyte Windows package is downloaded only when needed. + existing_matches, marker = self._existing_vanilla_tweaks_output_matches( + target + ) + + try: + self._report_download_progress( + "Checking vanilla-tweaks release...", + None, + None, + ) + release_info = remote_packages.vanilla_tweaks_release_info() + except Exception as exc: + self._close_download_progress() + + if existing_matches: + patcher_version = ( + marker.get("patcher_version", "previously installed") + if isinstance(marker, dict) + else "previously installed" + ) + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "Could not check the latest tubtubs/vanilla-tweaks build.\n\n" + "Your existing WoW_Modernized.exe matches the current WoW.exe " + "and all executable patch settings, so it was kept unchanged.\n\n" + f"Existing patcher version: {patcher_version}\n\n" + f"Details: {exc}", + ) + return os.path.join(target, "WoW_Modernized.exe") + + bundled = self._bundled_vanilla_tweaks_info() + bundled_exe = os.path.join(get_base_path(), "vanilla-tweaks.exe") + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "Could not check the latest tubtubs/vanilla-tweaks build.\n\n" + "The current WoW.exe or executable patch settings need to be " + "repatched, so the bundled known-good vanilla-tweaks build will " + f"be used instead ({bundled['version']}).\n\n" + f"Details: {exc}", + ) + + result = self._run_vanilla_tweaks_transactional( + target, + tweaks_exe=bundled_exe, + modern_cli=True, + ) + self._write_vanilla_tweaks_marker( + target, + patcher_source="bundled", + patcher_version=bundled["version"], + patcher_path=bundled_exe, + ) + return result + + remote_revision = release_info["revision"] + remote_version = release_info["version"] + if existing_matches and isinstance(marker, dict): + same_online_patcher = ( + marker.get("patcher_source") == "online" + and ( + str(marker.get("patcher_revision")) == str(remote_revision) + or ( + marker.get("patcher_revision") in (None, "") + and str(marker.get("patcher_version")) == str(remote_version) + ) + ) + ) + if same_online_patcher: + self._report_download_progress( + f"vanilla-tweaks {remote_version} is already current.", + None, + None, + ) + self._close_download_progress() + return os.path.join(target, "WoW_Modernized.exe") + + try: + tweaks_exe, extract_root, version, revision = ( + remote_packages.prepare_vanilla_tweaks( + progress=self._report_download_progress, + release_info=release_info, + ) + ) + except Exception as exc: + self._close_download_progress() + + # A remote update may exist but be temporarily unavailable. Keep a + # valid existing output rather than downgrading or repatching it. + if existing_matches: + patcher_version = ( + marker.get("patcher_version", "previously installed") + if isinstance(marker, dict) + else "previously installed" + ) + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "The latest vanilla-tweaks package could not be downloaded.\n\n" + "Your existing WoW_Modernized.exe still matches the current " + "WoW.exe and all executable patch settings, so it was kept unchanged.\n\n" + f"Existing patcher version: {patcher_version}\n\n" + f"Details: {exc}", + ) + return os.path.join(target, "WoW_Modernized.exe") + + bundled = self._bundled_vanilla_tweaks_info() + bundled_exe = os.path.join(get_base_path(), "vanilla-tweaks.exe") + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "The latest vanilla-tweaks package could not be downloaded.\n\n" + "The current WoW.exe or executable patch settings need to be " + "repatched, so the bundled known-good vanilla-tweaks build will " + f"be used instead ({bundled['version']}).\n\n" + f"Details: {exc}", + ) + + result = self._run_vanilla_tweaks_transactional( + target, + tweaks_exe=bundled_exe, + modern_cli=True, + ) + self._write_vanilla_tweaks_marker( + target, + patcher_source="bundled", + patcher_version=bundled["version"], + patcher_path=bundled_exe, + ) + return result + + try: + self._report_download_progress( + f"Applying vanilla-tweaks {version}...", + None, + None, + ) + result = self._run_vanilla_tweaks_transactional( + target, + tweaks_exe=tweaks_exe, + modern_cli=True, + ) + self._write_vanilla_tweaks_marker( + target, + patcher_source="online", + patcher_version=version, + patcher_path=tweaks_exe, + patcher_revision=revision, + ) + return result + finally: + shutil.rmtree(extract_root, ignore_errors=True) + self._close_download_progress() + + def configure_plugins(self, target): + payload_base = os.path.join(get_base_path(), "Payload") + payload_weirdu = os.path.join(payload_base, "WeirdUtils") + + dlls_text_lines = [] + if self.rendering_mode.get() == "dxvk": + dlls_text_lines.append("dxvk") + + try: + # Core plugins are refreshed from their upstream sources where supported. + # Bundled fallbacks are used only for components whose redistribution + # model allows it. SuperWoW/SuperAPI also have a bundled fallback. + for dll_name, var in self.core_plugins.items(): + if not var.get(): + continue + + if dll_name == "nampower.dll": + try: + remote_packages.install_nampower( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_core_dll(payload_base, target, dll_name, exc) + + elif dll_name == "VanillaHelpers.dll": + try: + remote_packages.install_vanillahelpers( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_core_dll(payload_base, target, dll_name, exc) + + elif dll_name == "UnitXP_SP3.dll": + try: + remote_packages.install_unitxp( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_core_dll(payload_base, target, dll_name, exc) + + elif dll_name == "SuperWoWhook.dll": + try: + remote_packages.install_superwow( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_core_dll(payload_base, target, dll_name, exc) + + elif dll_name == "perf_boost.dll": + source_dll, _ = self._verified_bundled_file( + "Payload/perf_boost.dll", + "PerfBoost", + ) + source_addon = os.path.join( + payload_base, + "Interface", + "Addons", + "perfboostsettings", + ) + if not os.path.isdir(source_addon): + raise RuntimeError( + "Bundled PerfBoost settings addon is missing." + ) + remote_packages._transactional_replace_bundle( + [ + ( + "file", + source_dll, + os.path.join(target, "perf_boost.dll"), + ), + ( + "dir", + source_addon, + os.path.join( + target, + "Interface", + "AddOns", + "perfboostsettings", + ), + ), + ], + label="PerfBoost", + ) + + else: + self._install_verified_bundled_file( + f"Payload/{dll_name}", + os.path.join(target, dll_name), + dll_name, + ) + + dlls_text_lines.append(dll_name) + + # Live core plugins. Both checkboxes are independent and can be + # installed together. + if self.classicapi_enabled.get(): + try: + remote_packages.install_classicapi( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_simple_dll(target, "ClassicAPI.dll", exc) + dlls_text_lines.append("ClassicAPI.dll") + + if self.auction_throttle_enabled.get(): + try: + remote_packages.install_auction_query_throttle( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_simple_dll(target, "AuctionQueryThrottle.dll", exc) + dlls_text_lines.append("AuctionQueryThrottle.dll") + + # Clean dependent addons when the matching core DLL is disabled. + for dll_name, addon_folder in self.addon_dependencies.items(): + core_var = self.core_plugins.get(dll_name) + if core_var is not None and not core_var.get(): + addon_path = os.path.join(target, "Interface", "AddOns", addon_folder) + if os.path.exists(addon_path): + shutil.rmtree(addon_path, ignore_errors=True) + + # Optional client fixes, in the same order as the UI. + no1600_var = self.optional_plugins["no1600x1200.dll"] + if no1600_var.get(): + # No1600x1200 comes from an archive/mirror repository rather + # than a maintained release channel. Use the bundled, + # hash-verified known-good copy for predictable installs. + self._install_verified_bundled_file( + "Payload/no1600x1200.dll", + os.path.join(target, "no1600x1200.dll"), + "no1600x1200.dll", + ) + dlls_text_lines.append("no1600x1200.dll") + + if self.vmmfix_enabled.get(): + try: + remote_packages.install_vanilla_multimonitor_fix( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_vmmfix(target, exc) + dlls_text_lines.append("VanillaMultiMonitorFix.dll") + + if self.interact_enabled.get(): + try: + remote_packages.install_interact( + target, + progress=self._report_download_progress, + ) + except Exception as exc: + self._fallback_interact(target, exc) + dlls_text_lines.append("Interact.dll") + + # Optional WeirdUtils and WowPresence. + for dll_name, var in self.optional_plugins.items(): + if dll_name == "no1600x1200.dll": + continue + if not var.get(): + continue + + if dll_name == "WowPresence.dll": + self._install_wowpresence_with_fallback(target) + + # Remove the old test-branch filenames after a successful + # migration. The legacy config directory is intentionally + # left untouched so the user can remove it manually. + for legacy_name in ("DiscordPresence.dll", "DiscordPresence.exe"): + legacy_path = os.path.join(target, legacy_name) + if os.path.isfile(legacy_path): + try: + os.remove(legacy_path) + except OSError: + pass + + remote_packages.write_wowpresence_broadcast_flags( + target, + self._discord_broadcast_mask(), + ) + else: + self._install_verified_bundled_file( + f"Payload/WeirdUtils/{dll_name}", + os.path.join(target, dll_name), + dll_name, + ) + + dlls_text_lines.append(dll_name) + + # Preserve user-added dlls.txt entries; replace only entries + # managed by Modernization Tool. + self._write_dlls_file(target, dlls_text_lines) + finally: + self._close_download_progress() + + +if __name__ == "__main__": + root = tk.Tk() + app = ModernWowSetupTool(root) + root.mainloop() diff --git a/tests/test_vanilla_tweaks_normalization.py b/tests/test_vanilla_tweaks_normalization.py index 2783fd0..d01ad61 100644 --- a/tests/test_vanilla_tweaks_normalization.py +++ b/tests/test_vanilla_tweaks_normalization.py @@ -4,6 +4,7 @@ import tempfile import unittest from unittest import mock +import setup_tool_dynamic from setup_tool import WowSetupTool from setup_tool_dynamic import ModernWowSetupTool @@ -20,44 +21,109 @@ class FakeVar: class VanillaTweaksNormalizationTests(unittest.TestCase): - def _tool(self, *, fov, sound, quickloot, background): + def _tool( + self, + *, + fov, + sound, + quickloot, + background, + farclip=777, + frill=300, + nameplate=41, + maxcam=100, + laa=True, + camera=True, + custom_glues=True, + crossfaction=False, + bluemoon=False, + ): tool = ModernWowSetupTool.__new__(ModernWowSetupTool) tool.vt_fov = FakeVar(fov) tool.vt_soundchan = FakeVar(sound) tool.vt_quickloot = FakeVar(quickloot) tool.vt_bg_sound = FakeVar(background) - - # Remaining values are required by the executable settings signature. - tool.vt_farclip = FakeVar(777) - tool.vt_frill = FakeVar(300) - tool.vt_nameplate = FakeVar(41) - tool.vt_maxcam = FakeVar(100) - tool.vt_laa = FakeVar(True) - tool.vt_cam_fix = FakeVar(True) - tool.vt_crossfaction_res = FakeVar(False) - tool.vt_custom_glues = FakeVar(True) - tool.vt_bluemoon = FakeVar(False) + tool.vt_farclip = FakeVar(farclip) + tool.vt_frill = FakeVar(frill) + tool.vt_nameplate = FakeVar(nameplate) + tool.vt_maxcam = FakeVar(maxcam) + tool.vt_laa = FakeVar(laa) + tool.vt_cam_fix = FakeVar(camera) + tool.vt_crossfaction_res = FakeVar(crossfaction) + tool.vt_custom_glues = FakeVar(custom_glues) + tool.vt_bluemoon = FakeVar(bluemoon) tool.core_plugins = {"SuperWoWhook.dll": FakeVar(True)} return tool - def _write_exe(self, root, quick1, quick2, background, fov, sound): - data = bytearray(0x435D3C + 16) + def _write_exe( + self, + root, + quick1, + quick2, + background, + fov, + sound, + *, + farclip=777.0, + frill=70.0, + nameplate=20.0, + maxcam=50.0, + laa_patched=False, + camera_patched=False, + custom_patched=False, + crossfaction_byte=0x01, + bluemoon_bytes=None, + ): + data = bytearray(0x46795C + 16) data[0x0C1ECF:0x0C1ED1] = quick1 data[0x0C2B25:0x0C2B27] = quick2 data[0x3A4869] = background - data[0x4089B4:0x4089B8] = struct.pack("