diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 642e6df..8c3e8ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: Get-Item $zip | Format-List Name,Length,LastWriteTime - name: Upload test artifact - if: ${{ !startsWith(github.ref, 'refs/tags/') && !(github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.1') }} + if: ${{ !startsWith(github.ref, 'refs/tags/') && !(github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.2') }} uses: actions/upload-artifact@v4 with: name: WoW_Modernization_Tool-test @@ -90,20 +90,20 @@ jobs: dist/WoW_Modernization_Tool.zip if-no-files-found: error - # One-time production promotion for v2.1. The exact commit message keeps - # normal main pushes from publishing or modifying the v2.1 release. - - name: Create GitHub Release v2.1 - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.1' }} + # One-time production promotion for v2.2. The exact commit message keeps + # normal main pushes from publishing or modifying the v2.2 release. + - name: Create GitHub Release v2.2 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.2' }} uses: softprops/action-gh-release@v3 with: - tag_name: v2.1 + tag_name: v2.2 target_commitish: ${{ github.sha }} files: | dist/WoW_Modernization_Tool.exe dist/WoW_Modernization_Tool.zip fail_on_unmatched_files: true overwrite_files: true - name: "WoW Modernization Tool v2.1" + name: "WoW Modernization Tool v2.2" body_path: RELEASE_NOTES.md generate_release_notes: false make_latest: true diff --git a/README.md b/README.md index 8e79771..354a0a0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Download the latest version from: **[GitHub Releases](https://github.com/Dusk-92/Modernization-Tool/releases/latest)** -Current major release: **v2.1** +Current major release: **v2.2** No installation is required. Download and run: @@ -93,7 +93,7 @@ Optional client-side improvements include: - **MinimapIcons** — adds TBC/WotLK-style minimap tracking icons for NPCs and game objects, with a combined tracking menu and saved preferences. - **PNG Screenshots** — saves screenshots as compressed PNG files instead of the default uncompressed TGA format, using background processing to minimize frame-time impact. - **WorldMarkers** — lets party or raid leaders place up to five animated, Cataclysm-style colored world markers for positioning and tactical planning. -- **Discord Rich Presence** — installs and keeps **WowPresence** up to date from [Dusk-92/WowPresence](https://github.com/Dusk-92/WowPresence). The Tool preconfigures the OctoWoW Discord Application ID in `.modernization_tool\WowPresence\discord_application_id`; custom IDs are preserved across updates. +- **Discord Rich Presence** — installs and keeps **WowPresence** up to date from [Dusk-92/WowPresence](https://github.com/Dusk-92/WowPresence). The Tool preconfigures the OctoWoW Discord Application ID and lets you choose which character details are shown on Discord, including **Name, Guild, Race, Faction, Class, Level and Zone**. Custom Discord Application IDs are preserved across updates. --- @@ -247,7 +247,7 @@ Always download Modernization Tool from the official GitHub Releases page. # 🔗 Useful Links - **[Latest Release](https://github.com/Dusk-92/Modernization-Tool/releases/latest)** -- **[v2.1 Release Notes](https://github.com/Dusk-92/Modernization-Tool/releases/tag/v2.1)** +- **[v2.2 Release Notes](https://github.com/Dusk-92/Modernization-Tool/releases/tag/v2.2)** - **[OctoWoW Installation & Modernization Guide](https://octowow.st/forum/viewtopic.php?t=831)** --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cfc2dba..4e145af 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,35 +1,34 @@ -# 🛠️ WoW Modernization Tool v2.1 - -This update adds **Discord Rich Presence** and includes several compatibility, maintenance and documentation improvements. +# 🛠️ WoW Modernization Tool v2.2 ## 🎮 Discord Rich Presence -- Added optional **WowPresence** integration with automatic installation and updates from its GitHub Releases. -- Preconfigures the **OctoWoW Discord Application ID** while preserving custom IDs and settings. -- Includes improved zone detection from WowPresence v1.2 and safer handling of standalone/manual WowPresence installations. +- Added detailed Discord Rich Presence privacy controls. +- Choose individually whether Discord can display: + - Character Name + - Guild + - Race + - Faction + - Class + - Level + - Zone +- Added a `Show character details` option to quickly enable all Discord details. +- Updated WowPresence integration for the new privacy system. -## 🌿 Pink Herbs +## 🔄 Updates & Downloads -- Moved **Pink Herbs** safely from `patch-H.mpq` to `patch-V.mpq`. -- Existing custom `patch-H.mpq` files are protected from accidental replacement or removal. +- Added smart update checks for remote components. +- Unchanged components are no longer downloaded again on every Apply. +- Modified or missing managed files are automatically detected and repaired. +- Release assets are detected even when an upstream project replaces a file under the same tag. +- Improved vanilla-tweaks update detection to avoid unnecessary downloads and repatching. +- Improved update checks for GitHub branch-based components. -## 🖥️ Compatibility Fixes +## 📚 Documentation -- Fixed elevated **DEP configuration** when the WoW installation path contains spaces. -- Improved cleanup and migration of older managed files. +- Updated Discord Rich Presence documentation. -## 📜 Licensing & Provenance - -- Added expanded licensing, binary provenance and asset provenance documentation. -- Improved third-party attribution and project identity information. -- Updated Credits and source links. - -## ✅ Updating from v2.0 +## ✅ Updating from v2.1 Download the new executable, select your existing WoW folder and click **Apply Setup & Tweaks**. A complete WoW reinstall is not required. - ---- - -The detailed commit history since **v2.0** is available on GitHub. diff --git a/remote_packages.py b/remote_packages.py index 7504265..afd239b 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -5,6 +5,7 @@ import shutil import stat import struct import tempfile +import time import urllib.error import urllib.request import uuid @@ -18,6 +19,15 @@ WOWPRESENCE_MANAGED_ID = "wowpresence" WOWPRESENCE_DEFAULT_APPLICATION_ID = "1544072796098011176" WOWPRESENCE_APPLICATION_ID_PLACEHOLDER = "PASTE_YOUR_DISCORD_APPLICATION_ID_HERE" +WOWPRESENCE_SHARE_NAME = 1 +WOWPRESENCE_SHARE_GUILD = 2 +WOWPRESENCE_SHARE_FACTION = 4 +WOWPRESENCE_SHARE_CLASS = 8 +WOWPRESENCE_SHARE_LEVEL = 16 +WOWPRESENCE_SHARE_ZONE = 32 +WOWPRESENCE_SHARE_RACE = 64 +WOWPRESENCE_SHARE_ALL = 127 + class RemotePackageError(RuntimeError): pass @@ -40,13 +50,25 @@ def _request(url, accept=None): return urllib.request.Request(url, headers=headers) +JSON_CACHE_TTL = 300 +_JSON_CACHE = {} + + def _get_json(url): + now = time.monotonic() + cached = _JSON_CACHE.get(url) + if cached and now - cached[0] < JSON_CACHE_TTL: + return cached[1] + try: with urllib.request.urlopen(_request(url, "application/vnd.github+json"), timeout=NETWORK_TIMEOUT) as response: - return json.loads(response.read().decode("utf-8")) + data = json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as exc: raise RemotePackageError(f"GitHub request failed: {exc}") from exc + _JSON_CACHE[url] = (now, data) + return data + def _latest_release(repo): data = _get_json(f"{GITHUB_API}/repos/{repo}/releases/latest") @@ -55,6 +77,41 @@ def _latest_release(repo): return data +def _branch_head_sha(repo, branch): + data = _get_json(f"{GITHUB_API}/repos/{repo}/commits/{branch}") + sha = data.get("sha") if isinstance(data, dict) else None + if not isinstance(sha, str) or len(sha) < 7: + raise RemotePackageError(f"{repo}@{branch}: could not resolve branch revision.") + return sha + + +def _release_revision(release): + tag = release.get("tag_name") if isinstance(release, dict) else None + if isinstance(tag, str) and tag.strip(): + return tag.strip() + release_id = release.get("id") if isinstance(release, dict) else None + if release_id is not None: + return f"release:{release_id}" + return "latest" + + +def _release_asset_revision(release, asset): + """Track the actual release asset, even when an upstream reuses one tag.""" + base = _release_revision(release) + digest = _asset_sha256(asset) + if digest: + return f"{base}|sha256:{digest}" + + asset_id = asset.get("id") if isinstance(asset, dict) else None + updated = asset.get("updated_at") if isinstance(asset, dict) else None + size = asset.get("size") if isinstance(asset, dict) else None + name = asset.get("name") if isinstance(asset, dict) else None + return ( + f"{base}|asset:{asset_id or name or '?'}" + f"|updated:{updated or '?'}|size:{size or '?'}" + ) + + def _find_asset(release, exact_name=None, predicate=None): for asset in release.get("assets", []): name = asset.get("name", "") @@ -469,9 +526,7 @@ def _find_directory_with_file(root, filename): return None -def prepare_vanilla_tweaks(progress=None): - """Download and extract the latest stable tubtubs vanilla-tweaks Windows build.""" - _emit_progress(progress, "Checking vanilla-tweaks release...", None, None) +def vanilla_tweaks_release_info(): release = _latest_release("tubtubs/vanilla-tweaks") asset = _find_asset( release, @@ -481,6 +536,20 @@ def prepare_vanilla_tweaks(progress=None): and not name.lower().endswith(".sha256sum") ), ) + return { + "release": release, + "asset": asset, + "revision": _release_asset_revision(release, asset), + "version": release.get("name") or _release_revision(release), + } + + +def prepare_vanilla_tweaks(progress=None, release_info=None): + """Download and extract the latest stable tubtubs vanilla-tweaks Windows build.""" + _emit_progress(progress, "Checking vanilla-tweaks release...", None, None) + info = release_info or vanilla_tweaks_release_info() + release = info["release"] + asset = info["asset"] zip_path = _download_asset( asset, progress=progress, @@ -500,7 +569,7 @@ def prepare_vanilla_tweaks(progress=None): except OSError: pass - return exe_path, extract_root, release.get("name") or release.get("tag_name", "latest") + return exe_path, extract_root, info["version"], info["revision"] def _write_text_if_missing(path, text): @@ -556,17 +625,64 @@ def ensure_wowpresence_config(target_dir): _write_text_if_missing( os.path.join(data_dir, "discord_broadcast_flags"), - "63\n", + "127\n", ) return data_dir +def read_wowpresence_broadcast_flags(target_dir): + """Read the user-visible WowPresence detail mask, or None when invalid.""" + path = os.path.join( + target_dir, + ".modernization_tool", + "WowPresence", + "discord_broadcast_flags", + ) + try: + with open(path, "r", encoding="ascii", errors="ignore") as handle: + text = handle.read().strip() + value = int(text, 10) + except (OSError, ValueError): + return None + + if value < 0: + return None + return value & WOWPRESENCE_SHARE_ALL + + +def write_wowpresence_broadcast_flags(target_dir, value): + """Atomically persist the WowPresence detail mask without touching other config.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("WowPresence broadcast flags must be an integer.") + if value < 0 or value > WOWPRESENCE_SHARE_ALL: + raise ValueError( + f"WowPresence broadcast flags must be between 0 and {WOWPRESENCE_SHARE_ALL}." + ) + + data_dir = ensure_wowpresence_config(target_dir) + path = os.path.join(data_dir, "discord_broadcast_flags") + temp_path = path + ".new" + try: + with open(temp_path, "w", encoding="ascii", newline="\n") as handle: + handle.write(f"{value}\n") + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + return path + + + def install_wowpresence(target_dir, progress=None): """Install or update WowPresence from its latest stable GitHub release ZIP.""" _emit_progress(progress, "Checking WowPresence release...", None, None) release = _latest_release(WOWPRESENCE_REPO) - revision = release.get("tag_name", "latest") + revision = _release_revision(release) package_asset = _find_asset(release, exact_name="WowPresence.zip") + package_revision = _release_asset_revision(release, package_asset) # Preserve whether dlls.txt already belonged to a standalone/manual # WowPresence install before this tool first takes ownership. @@ -591,7 +707,7 @@ def install_wowpresence(target_dir, progress=None): "WowPresence.dll", ) - # The directory itself is the signal used by WowPresence v1.2 to select + # The directory itself is the signal used by WowPresence to select # the Modernization Tool managed data location instead of the standalone # one. User-editable config is created only when missing. ensure_wowpresence_config(target_dir) @@ -602,6 +718,19 @@ def install_wowpresence(target_dir, progress=None): if not isinstance(saved_hashes, dict): saved_hashes = {} + saved_package_revision = manifest.get("package_revision") + current_digest = package_asset.get("digest") + saved_digest = manifest.get("package_digest") + package_matches = ( + str(saved_package_revision) == str(package_revision) + or ( + saved_package_revision in (None, "") + and isinstance(saved_digest, str) + and isinstance(current_digest, str) + and saved_digest == current_digest + ) + ) + def installed_file_ok(filename): path = os.path.join(target_dir, filename) expected = saved_hashes.get(filename) @@ -620,11 +749,13 @@ def install_wowpresence(target_dir, progress=None): dll_ok = installed_file_ok("WowPresence.dll") exe_ok = installed_file_ok("WowPresence.exe") - if dll_ok and exe_ok: + if dll_ok and exe_ok and package_matches: _set_managed_manifest_values( target_dir, WOWPRESENCE_MANAGED_ID, dlls_entry_preexisting=bool(dlls_entry_preexisting), + package_revision=package_revision, + package_digest=current_digest, ) _emit_progress( progress, @@ -676,6 +807,7 @@ def install_wowpresence(target_dir, progress=None): "WowPresence.exe": _file_sha256(exe_path), }, package_digest=package_asset.get("digest"), + package_revision=package_revision, ) finally: if zip_path and os.path.exists(zip_path): @@ -691,10 +823,19 @@ def install_wowpresence(target_dir, progress=None): ensure_wowpresence_config(target_dir) return revision + def install_interact(target_dir, progress=None): _emit_progress(progress, "Checking Interact release...", None, None) release = _latest_release("lookino/Interact") asset = _find_asset(release, exact_name="Interact.zip") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "interact" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Interact {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading Interact package") extract_root = tempfile.mkdtemp(prefix="modernization_interact_") try: @@ -718,6 +859,15 @@ def install_interact(target_dir, progress=None): ], label="Interact", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "Interact.dll", + os.path.join("Interface", "AddOns", "Interact"), + ], + ) finally: try: os.remove(zip_path) @@ -725,13 +875,27 @@ def install_interact(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return version + def install_vanilla_multimonitor_fix(target_dir, progress=None): _emit_progress(progress, "Checking VanillaMultiMonitorFix release...", None, None) release = _latest_release("Mates1500/VanillaMultiMonitorFix") asset = _find_asset(release, exact_name="release.zip") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "vanilla_multimonitor_fix" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"VanillaMultiMonitorFix {version} is already current.", + None, + None, + ) + return version + zip_path = _download_asset( asset, progress=progress, @@ -756,6 +920,15 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): if not os.path.exists(target_config): _emit_progress(progress, "Installing monitor preference file...", None, None) _atomic_replace_file(config_path, target_config) + + # The preference file is intentionally excluded from integrity tracking + # because it is user-editable. + _record_package_state_safely( + target_dir, + package_id, + revision, + ["VanillaMultiMonitorFix.dll"], + ) finally: try: os.remove(zip_path) @@ -763,9 +936,7 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") - - + return version MANAGED_ROOT = ".modernization_tool" @@ -880,6 +1051,156 @@ def _file_sha256(path): return digest.hexdigest().lower() +PACKAGE_STATE_DIR = "package_state" + + +def _package_state_path(target_dir, package_id): + safe_id = "".join( + ch if ch.isalnum() or ch in ("-", "_", ".") else "_" + for ch in str(package_id) + ) + return os.path.join( + target_dir, + MANAGED_ROOT, + PACKAGE_STATE_DIR, + safe_id + ".json", + ) + + +def _hash_directory(path): + digest = hashlib.sha256() + if not os.path.isdir(path): + raise OSError(f"Directory does not exist: {path}") + + found_file = False + for current_root, dirs, files in os.walk(path): + dirs.sort(key=str.casefold) + files.sort(key=str.casefold) + for filename in files: + found_file = True + full_path = os.path.join(current_root, filename) + rel = os.path.relpath(full_path, path).replace(os.sep, "/") + digest.update(b"F\0") + digest.update(rel.encode("utf-8", "surrogatepass")) + digest.update(b"\0") + digest.update(_file_sha256(full_path).encode("ascii")) + digest.update(b"\0") + + if not found_file: + digest.update(b"EMPTY\0") + return digest.hexdigest().lower() + + +def _snapshot_package_paths(target_dir, relative_paths): + entries = {} + for relative in relative_paths: + rel = _safe_relative_path(relative) + full_path = os.path.join(target_dir, rel) + key = rel.replace(os.sep, "/") + + if os.path.isfile(full_path): + entries[key] = { + "type": "file", + "sha256": _file_sha256(full_path), + } + elif os.path.isdir(full_path): + entries[key] = { + "type": "dir", + "sha256": _hash_directory(full_path), + } + else: + raise OSError(f"Package path is missing: {full_path}") + return entries + + +def _load_package_state(target_dir, package_id): + path = _package_state_path(target_dir, package_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError, ValueError, TypeError): + return {} + + +def _record_package_state(target_dir, package_id, revision, relative_paths): + entries = _snapshot_package_paths(target_dir, relative_paths) + path = _package_state_path(target_dir, package_id) + os.makedirs(os.path.dirname(path), exist_ok=True) + temp_path = path + ".new" + payload = { + "schema": 1, + "package_id": str(package_id), + "revision": str(revision), + "paths": entries, + } + try: + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + return payload + + +def _package_state_is_current(target_dir, package_id, revision): + data = _load_package_state(target_dir, package_id) + if str(data.get("revision")) != str(revision): + return False + + paths = data.get("paths") + if not isinstance(paths, dict) or not paths: + return False + + try: + current = _snapshot_package_paths(target_dir, paths.keys()) + except OSError: + return False + return current == paths + + +def _record_package_state_safely(target_dir, package_id, revision, relative_paths): + try: + _record_package_state(target_dir, package_id, revision, relative_paths) + return True + except OSError: + # Update metadata is an optimization only. A successful component + # install must remain usable even if its cache state cannot be saved. + return False + + +def _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, +): + """Migrate an existing direct release asset without downloading it again.""" + expected_sha = _asset_sha256(asset) + if not expected_sha: + return False + + rel = _safe_relative_path(relative_path) + path = os.path.join(target_dir, rel) + if not os.path.isfile(path): + return False + + try: + if _file_sha256(path) != expected_sha: + return False + _record_package_state(target_dir, package_id, revision, [rel]) + return True + except OSError: + return False + + def _installed_asset_is_current(path, asset, label): """Validate an installed release asset without trusting existence alone.""" if not os.path.isfile(path): @@ -1389,9 +1710,21 @@ def install_fog_pushback(target_dir, progress=None): return "RetroCro mirror" + def install_pink_herbs(target_dir, progress=None): mod_id = "visual_pink_herbs" destination = os.path.join("Data", "patch-V.mpq") + revision = _branch_head_sha("seacrabsam/patch-herb", "main") + + if _package_state_is_current(target_dir, mod_id, revision): + _emit_progress( + progress, + f"Pink Herbs {revision[:7]} is already current.", + None, + None, + ) + return f"seacrabsam/patch-herb main@{revision[:7]}" + temp_path = _download( "https://raw.githubusercontent.com/seacrabsam/patch-herb/main/patch-H.mpq", suffix=".mpq", @@ -1414,13 +1747,19 @@ def install_pink_herbs(target_dir, progress=None): [(temp_path, destination)], revision=VISUAL_MOD_REVISIONS[mod_id], ) + _record_package_state_safely( + target_dir, + mod_id, + revision, + [destination], + ) finally: try: os.remove(temp_path) except OSError: pass - return "seacrabsam/patch-herb main" + return f"seacrabsam/patch-herb main@{revision[:7]}" def _download_github_branch_archive(repo, branch, progress=None, label="Downloading sound mod"): @@ -1456,7 +1795,18 @@ def _collect_tree_files(source_dir, destination_prefix): return mappings + def _install_github_sound_pack(target_dir, mod_id, repo, branch, source_folder, destination_prefix, progress=None, label="Downloading sound mod"): + revision = _branch_head_sha(repo, branch) + if _package_state_is_current(target_dir, mod_id, revision): + _emit_progress( + progress, + f"{label.replace('Downloading ', '')} is already current.", + None, + None, + ) + return revision + zip_path = _download_github_branch_archive( repo, branch, @@ -1471,12 +1821,19 @@ def _install_github_sound_pack(target_dir, mod_id, repo, branch, source_folder, mappings = _collect_tree_files(source_dir, destination_prefix) _emit_progress(progress, f"Installing {label.replace('Downloading ', '')}...", None, None) _install_managed_files_transactional(target_dir, mod_id, mappings) + _record_package_state_safely( + target_dir, + mod_id, + revision, + _load_managed_manifest(target_dir, mod_id), + ) finally: try: os.remove(zip_path) except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) + return revision def install_no_error_sounds(target_dir, progress=None): @@ -1521,6 +1878,7 @@ def install_warlock_muted_demons(target_dir, progress=None): return "spzilyk/Warlock-Muted-Demons main" + def install_nampower(target_dir, progress=None): _emit_progress(progress, "Checking Nampower release...", None, None) release = _latest_release("brues-code/nampower") @@ -1528,6 +1886,14 @@ def install_nampower(target_dir, progress=None): release, predicate=lambda name: name.lower().startswith("nampower-") and name.lower().endswith(".zip"), ) + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "nampower" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Nampower {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading Nampower package") extract_root = tempfile.mkdtemp(prefix="modernization_nampower_") try: @@ -1549,19 +1915,46 @@ def install_nampower(target_dir, progress=None): ], label="Nampower", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "nampower.dll", + os.path.join("Interface", "AddOns", "nampowersettings"), + ], + ) finally: try: os.remove(zip_path) except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return version def install_vanillahelpers(target_dir, progress=None): _emit_progress(progress, "Checking VanillaHelpers release...", None, None) release = _latest_release("isfir/VanillaHelpers") asset = _find_asset(release, exact_name="VanillaHelpers.dll") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "vanillahelpers" + relative_path = "VanillaHelpers.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress(progress, f"VanillaHelpers {version} is already current.", None, None) + return version + temp_path = _download_asset( asset, progress=progress, @@ -1570,14 +1963,33 @@ def install_vanillahelpers(target_dir, progress=None): try: _verify_x86_pe(temp_path, "VanillaHelpers.dll") _emit_progress(progress, "Installing VanillaHelpers.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "VanillaHelpers.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version def install_no1600x1200(target_dir, progress=None): _emit_progress(progress, "Checking no1600x1200 source...", None, None) + revision = _branch_head_sha("RetroCro/TurtleWoW-Mods", "main") + package_id = "no1600x1200" + relative_path = "no1600x1200.dll" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"no1600x1200 {revision[:7]} is already current.", + None, + None, + ) + return f"RetroCro/TurtleWoW-Mods main@{revision[:7]}" + url = ( "https://raw.githubusercontent.com/RetroCro/TurtleWoW-Mods/" "refs/heads/main/Archive/DLL%20BACKUP/no1600x1200.dll" @@ -1591,30 +2003,84 @@ def install_no1600x1200(target_dir, progress=None): try: _verify_x86_pe(temp_path, "no1600x1200.dll") _emit_progress(progress, "Installing no1600x1200.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "no1600x1200.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return "RetroCro/TurtleWoW-Mods main" + return f"RetroCro/TurtleWoW-Mods main@{revision[:7]}" + def install_classicapi(target_dir, progress=None): _emit_progress(progress, "Checking ClassicAPI release...", None, None) release = _latest_release("brues-code/ClassicAPI") asset = _find_asset(release, exact_name="ClassicAPI.dll") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "classicapi" + relative_path = "ClassicAPI.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress(progress, f"ClassicAPI {version} is already current.", None, None) + return version + temp_path = _download_asset(asset, progress=progress, label="Downloading ClassicAPI.dll") try: _verify_x86_pe(temp_path, "ClassicAPI.dll") _emit_progress(progress, "Installing ClassicAPI.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "ClassicAPI.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version def install_auction_query_throttle(target_dir, progress=None): _emit_progress(progress, "Checking AuctionQueryThrottle release...", None, None) release = _latest_release("brues-code/AuctionQueryThrottle") asset = _find_asset(release, exact_name="AuctionQueryThrottle.dll") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "auction_query_throttle" + relative_path = "AuctionQueryThrottle.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress( + progress, + f"AuctionQueryThrottle {version} is already current.", + None, + None, + ) + return version + temp_path = _download_asset( asset, progress=progress, @@ -1623,10 +2089,16 @@ def install_auction_query_throttle(target_dir, progress=None): try: _verify_x86_pe(temp_path, "AuctionQueryThrottle.dll") _emit_progress(progress, "Installing AuctionQueryThrottle.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "AuctionQueryThrottle.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version def install_unitxp(target_dir, progress=None): @@ -1636,6 +2108,14 @@ def install_unitxp(target_dir, progress=None): release, predicate=lambda name: name.lower().startswith("unitxp_sp3") and name.lower().endswith(".zip"), ) + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "unitxp_sp3" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"UnitXP_SP3 {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_") try: @@ -1657,22 +2137,47 @@ def install_unitxp(target_dir, progress=None): ], label="UnitXP_SP3", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "UnitXP_SP3.dll", + os.path.join("Interface", "AddOns", "UnitXP_SP3_Addon"), + ], + ) finally: try: os.remove(zip_path) except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return version def install_superwow(target_dir, progress=None): _emit_progress(progress, "Checking SuperWoW release...", None, None) release = _latest_release("balakethelock/SuperWoW") + release_version = release.get("name") or _release_revision(release) asset = _find_asset( release, predicate=lambda name: name.lower().startswith("superwow") and name.lower().endswith(".zip"), ) + release_asset_revision = _release_asset_revision(release, asset) + + _emit_progress(progress, "Checking SuperAPI revision...", None, None) + superapi_revision = _branch_head_sha("balakethelock/SuperAPI", "master") + revision = f"{release_asset_revision}|superapi:{superapi_revision}" + package_id = "superwow" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"{release_version} + SuperAPI {superapi_revision[:7]} are already current.", + None, + None, + ) + return release_version wow_zip = None superapi_zip = None @@ -1725,6 +2230,15 @@ def install_superwow(target_dir, progress=None): ], label="SuperWoW + SuperAPI", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "SuperWoWhook.dll", + os.path.join("Interface", "AddOns", "SuperAPI"), + ], + ) finally: for path in (wow_zip, superapi_zip): if path: @@ -1735,4 +2249,6 @@ def install_superwow(target_dir, progress=None): shutil.rmtree(wow_root, ignore_errors=True) shutil.rmtree(superapi_root, ignore_errors=True) - return release.get("name") or release.get("tag_name", "latest") + return release_version + + diff --git a/setup_tool.py b/setup_tool.py index 420fc37..7d58e64 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -1725,13 +1725,19 @@ class WowSetupTool: # is enough to trigger one fresh download when a source/version changes. for key, managed_id, display_name, installer in visual_defs: if self.visual_mods[key].get(): - revision = remote_packages.VISUAL_MOD_REVISIONS[managed_id] - if remote_packages.managed_mpq_is_current( - target, - managed_id, - revision, - ): - continue + # Hosted static mirrors use explicit revisions and can be + # skipped without any network request. Pink Herbs follows a + # GitHub branch, so its installer performs a lightweight + # branch-SHA check before deciding whether a download is + # needed. + if key != "pink_herbs": + revision = remote_packages.VISUAL_MOD_REVISIONS[managed_id] + if remote_packages.managed_mpq_is_current( + target, + managed_id, + revision, + ): + continue try: installer(target, progress=progress) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 60d4479..2e9b8d4 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -18,6 +18,23 @@ class ModernWowSetupTool(WowSetupTool): 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 @@ -26,6 +43,199 @@ class ModernWowSetupTool(WowSetupTool): self._download_indeterminate = False super().__init__(root) + 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 @@ -303,14 +513,24 @@ class ModernWowSetupTool(WowSetupTool): 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) @@ -558,6 +778,7 @@ class ModernWowSetupTool(WowSetupTool): 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") @@ -570,9 +791,12 @@ class ModernWowSetupTool(WowSetupTool): os.makedirs(os.path.dirname(marker_path), exist_ok=True) temp_path = marker_path + ".new" payload = { - "schema": 1, + "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) @@ -623,19 +847,23 @@ class ModernWowSetupTool(WowSetupTool): return False, None def run_vanilla_tweaks(self, target): - # Prefer the latest stable tubtubs build on every Apply. If the network - # is unavailable, preserve an already valid/current WoW_Modernized.exe - # instead of unnecessarily repatching it with an older bundled build. + # 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: - tweaks_exe, extract_root, version = remote_packages.prepare_vanilla_tweaks( - progress=self._report_download_progress, + 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() - existing_matches, marker = self._existing_vanilla_tweaks_output_matches( - target - ) if existing_matches: patcher_version = ( marker.get("patcher_version", "previously installed") @@ -644,10 +872,9 @@ class ModernWowSetupTool(WowSetupTool): ) messagebox.showwarning( "Latest vanilla-tweaks unavailable", - "Could not download the latest tubtubs/vanilla-tweaks build.\n\n" + "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 " - f"instead of being repatched with the bundled fallback.\n\n" + "and all executable patch settings, so it was kept unchanged.\n\n" f"Existing patcher version: {patcher_version}\n\n" f"Details: {exc}", ) @@ -657,7 +884,81 @@ class ModernWowSetupTool(WowSetupTool): bundled_exe = os.path.join(get_base_path(), "vanilla-tweaks.exe") messagebox.showwarning( "Latest vanilla-tweaks unavailable", - "Could not download the latest tubtubs/vanilla-tweaks build.\n\n" + "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 = super().run_vanilla_tweaks( + 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" @@ -693,6 +994,7 @@ class ModernWowSetupTool(WowSetupTool): patcher_source="online", patcher_version=version, patcher_path=tweaks_exe, + patcher_revision=revision, ) return result finally: @@ -867,6 +1169,11 @@ class ModernWowSetupTool(WowSetupTool): os.remove(legacy_path) except OSError: pass + + remote_packages.write_wowpresence_broadcast_flags( + target, + self._discord_broadcast_mask(), + ) else: source_dll = os.path.join(payload_weirdu, dll_name) if os.path.exists(source_dll): diff --git a/tests/test_safety.py b/tests/test_safety.py index c642215..c1d360f 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -7,7 +7,9 @@ from unittest import mock import remote_packages import setup_tool +import setup_tool_dynamic from setup_tool import WowSetupTool +from setup_tool_dynamic import ModernWowSetupTool class FakeVar: @@ -612,6 +614,183 @@ class PeValidationTests(unittest.TestCase): remote_packages._verify_x86_pe(bad, "bad.dll") +class SmartUpdateTests(unittest.TestCase): + def test_package_state_tracks_revision_and_local_integrity(self): + with tempfile.TemporaryDirectory() as root: + dll = os.path.join(root, "Example.dll") + addon = os.path.join(root, "Interface", "AddOns", "Example") + os.makedirs(addon) + with open(dll, "wb") as handle: + handle.write(b"dll-v1") + with open(os.path.join(addon, "Example.toc"), "wb") as handle: + handle.write(b"addon-v1") + + remote_packages._record_package_state( + root, + "example", + "v1", + ["Example.dll", os.path.join("Interface", "AddOns", "Example")], + ) + + self.assertTrue( + remote_packages._package_state_is_current(root, "example", "v1") + ) + self.assertFalse( + remote_packages._package_state_is_current(root, "example", "v2") + ) + + with open(os.path.join(addon, "Example.toc"), "wb") as handle: + handle.write(b"modified") + self.assertFalse( + remote_packages._package_state_is_current(root, "example", "v1") + ) + + def test_release_asset_revision_detects_replaced_asset_under_same_tag(self): + release_a = { + "tag_name": "Release", + "assets": [ + { + "id": 1, + "name": "package.zip", + "updated_at": "2026-09-01T10:00:00Z", + "size": 100, + } + ], + } + release_b = { + "tag_name": "Release", + "assets": [ + { + "id": 2, + "name": "package.zip", + "updated_at": "2026-09-02T10:00:00Z", + "size": 120, + } + ], + } + + self.assertNotEqual( + remote_packages._release_asset_revision( + release_a, + release_a["assets"][0], + ), + remote_packages._release_asset_revision( + release_b, + release_b["assets"][0], + ), + ) + + def test_release_component_skips_download_when_revision_and_hashes_match(self): + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, "ClassicAPI.dll") + with open(target, "wb") as handle: + handle.write(b"already-installed") + + release = { + "tag_name": "v-test", + "assets": [ + { + "name": "ClassicAPI.dll", + "browser_download_url": "https://example.invalid/ClassicAPI.dll", + } + ], + } + revision = remote_packages._release_asset_revision( + release, + release["assets"][0], + ) + remote_packages._record_package_state( + root, + "classicapi", + revision, + ["ClassicAPI.dll"], + ) + + with mock.patch( + "remote_packages._latest_release", + return_value=release, + ), mock.patch( + "remote_packages._download_asset", + side_effect=AssertionError("unchanged component was downloaded"), + ) as download: + self.assertEqual( + remote_packages.install_classicapi(root), + "v-test", + ) + + download.assert_not_called() + + def test_branch_component_skips_archive_when_commit_and_files_match(self): + with tempfile.TemporaryDirectory() as root: + sound = os.path.join(root, "Sound", "example.wav") + os.makedirs(os.path.dirname(sound)) + with open(sound, "wb") as handle: + handle.write(b"sound") + + remote_packages._record_package_state( + root, + "audio_example", + "abcdef1234567890", + [os.path.join("Sound", "example.wav")], + ) + + with mock.patch( + "remote_packages._branch_head_sha", + return_value="abcdef1234567890", + ), mock.patch( + "remote_packages._download_github_branch_archive", + side_effect=AssertionError("unchanged branch archive was downloaded"), + ) as download: + revision = remote_packages._install_github_sound_pack( + root, + "audio_example", + "owner/repo", + "main", + "Sound", + "Sound", + ) + + self.assertEqual(revision, "abcdef1234567890") + download.assert_not_called() + + def test_vanilla_tweaks_skips_package_when_output_and_revision_match(self): + tool = setup_tool_dynamic.ModernWowSetupTool.__new__( + setup_tool_dynamic.ModernWowSetupTool + ) + tool._existing_vanilla_tweaks_output_matches = mock.Mock( + return_value=( + True, + { + "patcher_source": "online", + "patcher_version": "Vanilla Tweaks v1", + "patcher_revision": "v1", + }, + ) + ) + tool._report_download_progress = mock.Mock() + tool._close_download_progress = mock.Mock() + + release_info = { + "release": {}, + "asset": {}, + "revision": "v1", + "version": "Vanilla Tweaks v1", + } + + with tempfile.TemporaryDirectory() as root, mock.patch( + "setup_tool_dynamic.remote_packages.vanilla_tweaks_release_info", + return_value=release_info, + ), mock.patch( + "setup_tool_dynamic.remote_packages.prepare_vanilla_tweaks", + side_effect=AssertionError("unchanged vanilla-tweaks was downloaded"), + ) as prepare: + result = tool.run_vanilla_tweaks(root) + + self.assertEqual(result, os.path.join(root, "WoW_Modernized.exe")) + prepare.assert_not_called() + tool._close_download_progress.assert_called_once() + + class WowPresenceIntegrationTests(unittest.TestCase): def make_tool(self, selected=False): tool = WowSetupTool.__new__(WowSetupTool) @@ -635,7 +814,7 @@ class WowPresenceIntegrationTests(unittest.TestCase): remote_packages.WOWPRESENCE_DEFAULT_APPLICATION_ID, ) with open(flags, "r", encoding="ascii") as handle: - self.assertEqual(handle.read().strip(), "63") + self.assertEqual(handle.read().strip(), "127") with open(app_id, "w", encoding="ascii") as handle: handle.write( @@ -696,7 +875,7 @@ class WowPresenceIntegrationTests(unittest.TestCase): with mock.patch( "remote_packages._latest_release", return_value=release, - ), mock.patch( + ) as latest_release, mock.patch( "remote_packages._download_asset", return_value=zip_path, ) as download: @@ -705,6 +884,9 @@ class WowPresenceIntegrationTests(unittest.TestCase): "v-test", ) + latest_release.assert_called_once_with( + remote_packages.WOWPRESENCE_REPO, + ) download.assert_called_once() remote_packages._verify_x86_pe( os.path.join(root, "WowPresence.dll"), @@ -865,6 +1047,101 @@ class WowPresenceIntegrationTests(unittest.TestCase): ) +class WowPresenceDetailPreferenceTests(unittest.TestCase): + def make_tool(self): + tool = ModernWowSetupTool.__new__(ModernWowSetupTool) + tool.discord_show_character_details = FakeVar(True) + tool.discord_detail_vars = { + "name": FakeVar(True), + "guild": FakeVar(True), + "race": FakeVar(True), + "faction": FakeVar(True), + "class": FakeVar(True), + "level": FakeVar(True), + "zone": FakeVar(True), + } + return tool + + def test_detail_mask_supports_race_and_all_details_switch(self): + tool = self.make_tool() + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + + # The master switch always means "show all" even if a variable is + # changed programmatically while its checkbox would be disabled. + tool.discord_detail_vars["race"].set(False) + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + + # Once the master is off, the individual choices become authoritative. + tool.discord_show_character_details.set(False) + self.assertEqual(tool._discord_broadcast_mask(), 63) + self.assertFalse(tool.discord_detail_vars["race"].get()) + self.assertTrue(tool.discord_detail_vars["zone"].get()) + + def test_legacy_six_bit_mask_keeps_race_enabled(self): + tool = self.make_tool() + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + flags = os.path.join(data_dir, "discord_broadcast_flags") + with open(flags, "w", encoding="ascii") as handle: + handle.write("31\n") + + tool._load_wowpresence_broadcast_preferences(root) + + self.assertFalse(tool.discord_show_character_details.get()) + self.assertTrue(tool.discord_detail_vars["name"].get()) + self.assertTrue(tool.discord_detail_vars["guild"].get()) + self.assertTrue(tool.discord_detail_vars["faction"].get()) + self.assertTrue(tool.discord_detail_vars["class"].get()) + self.assertTrue(tool.discord_detail_vars["level"].get()) + self.assertFalse(tool.discord_detail_vars["zone"].get()) + self.assertTrue(tool.discord_detail_vars["race"].get()) + + def test_legacy_default_mask_selects_all_details(self): + tool = self.make_tool() + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + flags = os.path.join(data_dir, "discord_broadcast_flags") + with open(flags, "w", encoding="ascii") as handle: + handle.write("63\n") + + tool._load_wowpresence_broadcast_preferences(root) + + self.assertTrue(tool.discord_show_character_details.get()) + self.assertTrue(all(var.get() for var in tool.discord_detail_vars.values())) + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + + def test_broadcast_flag_writer_preserves_other_config(self): + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + app_id = os.path.join(data_dir, "discord_application_id") + with open(app_id, "w", encoding="ascii") as handle: + handle.write("123456789012345678\n") + + path = remote_packages.write_wowpresence_broadcast_flags( + root, + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + self.assertEqual( + remote_packages.read_wowpresence_broadcast_flags(root), + 127, + ) + with open(path, "r", encoding="ascii") as handle: + self.assertEqual(handle.read().strip(), "127") + with open(app_id, "r", encoding="ascii") as handle: + self.assertEqual(handle.read().strip(), "123456789012345678") + + with self.assertRaises(ValueError): + remote_packages.write_wowpresence_broadcast_flags(root, 128) + class SettingsRecoveryTests(unittest.TestCase): def test_corrupt_settings_are_left_untouched_and_legacy_state_is_recovered(self): tool = WowSetupTool.__new__(WowSetupTool)