import math import os import re import stat import struct import sys import tkinter as tk import types from tkinter import messagebox import remote_packages import setup_tool_dynamic_core as _dynamic_core # 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), ) # Numeric fields may already contain legitimate Turtle/Octo/community values. # Preflight only sanity-checks those fixed-offset fields; normalization later # applies the exact Tool policy without fingerprinting community numeric values. _NUMERIC_SITES = ( ("fov", 0x4089B4, "FoV", 0.5, 3.5), ("farclip", 0x40FED8, "Farclip", 777.0, 10000.0), ("frill", 0x467958, "Frill Distance", 0.0, 1000.0), ("nameplate", 0x40C448, "Nameplate Distance", 0.0, 150.0), ("maxcam", 0x4089A4, "Max Camera Distance", 1.0, 250.0), ) _SOUND_SITE = ("sound", 0x435D38, "Sound Channels", 1, 128) _CLIENT_BUILD_OFFSET = 0x437BFC _CLIENT_VERSION_OFFSET = 0x437C04 _CLIENT_BUILD = b"5875" _CLIENT_VERSION = b"1.12.1" _FARCLIP_EXE_CEILING = 3000.0 def _strict_verify_mpq(path): """Validate classic MPQ headers/table bounds, including user-data wrappers.""" try: size = os.path.getsize(path) if size < 32: raise remote_packages.RemotePackageError( "Downloaded MPQ is too small to contain a valid header." ) with open(path, "rb") as handle: prefix = handle.read(16) archive_base = 0 if prefix[:4] == b"MPQ\x1B": if len(prefix) != 16 or size < 48: raise remote_packages.RemotePackageError( "Downloaded MPQ has a truncated user-data header." ) try: _user_data_size, header_offset, user_header_size = ( struct.unpack_from(" size - 32 ): raise remote_packages.RemotePackageError( "Downloaded MPQ has an invalid nested archive offset." ) if ( _user_data_size < user_header_size or _user_data_size > header_offset ): raise remote_packages.RemotePackageError( "Downloaded MPQ has an invalid user-data size." ) archive_base = header_offset handle.seek(archive_base) header = handle.read(32) elif prefix[:4] == b"MPQ\x1A": handle.seek(0) header = handle.read(32) else: raise remote_packages.RemotePackageError( "Downloaded file is not a valid MPQ archive." ) except remote_packages.RemotePackageError: raise except OSError as exc: raise remote_packages.RemotePackageError( f"Could not inspect downloaded MPQ: {exc}" ) from exc if len(header) != 32 or header[:4] != b"MPQ\x1A": raise remote_packages.RemotePackageError( "Downloaded file is not a valid MPQ archive." ) try: ( header_size, archive_size, format_version, sector_size_shift, hash_table_offset, block_table_offset, hash_table_entries, block_table_entries, ) = struct.unpack_from(" size: raise remote_packages.RemotePackageError( "Downloaded MPQ has an invalid header size." ) if archive_size < header_size or archive_base + archive_size > size: raise remote_packages.RemotePackageError( "Downloaded MPQ has an invalid archive size." ) if format_version not in (0, 1): raise remote_packages.RemotePackageError( f"Downloaded MPQ uses unsupported format version {format_version}." ) if sector_size_shift == 0 or sector_size_shift > 16: raise remote_packages.RemotePackageError( "Downloaded MPQ has an invalid sector-size shift." ) tables = ( ("hash", hash_table_offset, hash_table_entries), ("block", block_table_offset, block_table_entries), ) for table_name, table_offset, entry_count in tables: if entry_count <= 0: raise remote_packages.RemotePackageError( f"Downloaded MPQ has an empty {table_name} table." ) table_size = entry_count * 16 if ( table_offset < header_size or table_offset > archive_size or table_size > archive_size - table_offset ): raise remote_packages.RemotePackageError( f"Downloaded MPQ has an out-of-bounds {table_name} table." ) def _strict_managed_mpq_is_current(target_dir, mod_id, revision): if not remote_packages.managed_mod_is_current(target_dir, mod_id, revision): return False files = remote_packages._load_managed_manifest(target_dir, mod_id) if len(files) != 1: return False path = os.path.join(target_dir, files[0]) try: _strict_verify_mpq(path) return True except remote_packages.RemotePackageError: return False def _strict_managed_mpq_is_usable(target_dir, mod_id): if not remote_packages.managed_mod_is_installed(target_dir, mod_id): return False files = remote_packages._load_managed_manifest(target_dir, mod_id) if len(files) != 1: return False path = os.path.join(target_dir, files[0]) try: _strict_verify_mpq(path) return True except remote_packages.RemotePackageError: return False def _install_strict_mpq_runtime_hooks(): """Enable strict MPQ checks only while the real visual installer is running. Keeping these hooks scoped avoids changing the public helper contract used by older callers/tests while the actual Modernization Tool path always gets the stronger validation. """ originals = { "_verify_mpq": remote_packages._verify_mpq, "_download_remote_mpq": remote_packages._download_remote_mpq, "managed_mpq_is_current": remote_packages.managed_mpq_is_current, "managed_mpq_is_usable": remote_packages.managed_mpq_is_usable, } original_download_remote_mpq = originals["_download_remote_mpq"] def strict_download_remote_mpq(*args, **kwargs): temp_path = original_download_remote_mpq(*args, **kwargs) try: _strict_verify_mpq(temp_path) except remote_packages.RemotePackageError as exc: try: os.remove(temp_path) except OSError: pass label = kwargs.get("label") or "Downloading visual mod" raise remote_packages.RemoteSourceUnavailable( f"{label}: remote source returned an invalid MPQ package ({exc})." ) from exc return temp_path remote_packages._verify_mpq = _strict_verify_mpq remote_packages._download_remote_mpq = strict_download_remote_mpq remote_packages.managed_mpq_is_current = _strict_managed_mpq_is_current remote_packages.managed_mpq_is_usable = _strict_managed_mpq_is_usable return originals def _restore_mpq_runtime_hooks(originals): for name, value in originals.items(): setattr(remote_packages, name, value) class ModernWowSetupTool(_ModernWowSetupToolCore): """Remote-fallback tool with authoritative, fail-safe Vanilla Tweaks output.""" def _vanilla_tweaks_signature(self): signature = super()._vanilla_tweaks_signature() # Runtime Farclip lives in Config.wtf; the executable ceiling is fixed. # Keep the executable signature stable when only Render Distance changes. signature["farclip"] = int(_FARCLIP_EXE_CEILING) # Bump when normalization changes so existing managed installs get one # clean WoW_Modernized.exe rebuild under the new policy. signature["selected_patch_normalization"] = 4 signature["source_fingerprint_policy"] = 1 return signature def _desired_normalized_values(self): selected_farclip = float(self.vt_farclip.get()) desired = { "fov": float(self.vt_fov.get()), "farclip": selected_farclip, "frill": float(self.vt_frill.get()), "nameplate": float(self.vt_nameplate.get()), "maxcam": float(self.vt_maxcam.get()), "sound": int(self.vt_soundchan.get()), } ranges = ( ("FoV", desired["fov"], 0.5, 3.5), ("Farclip", selected_farclip, 100.0, _FARCLIP_EXE_CEILING), ("Frill Distance", desired["frill"], 0.0, 10000.0), ("Nameplate Distance", desired["nameplate"], 1.0, 500.0), ("Max Camera Distance", desired["maxcam"], 1.0, 1000.0), ) for label, value, minimum, maximum in ranges: if not math.isfinite(value) or not minimum <= value <= maximum: raise RuntimeError( f"{label} value is outside the supported WoW 1.12.1 range." ) sound_channels = str(desired["sound"]).encode("ascii") + b"\x00" if not 1 <= desired["sound"] <= 256 or len(sound_channels) > 4: raise RuntimeError( "Sound Channels value is outside the supported WoW 1.12.1 range." ) desired["sound_bytes"] = sound_channels.ljust(4, b"\x00") # The EXE field is the maximum allowed Farclip, not the active distance. # Keep it fixed at 3000. The selected runtime value is synchronized to # Config.wtf separately and may never exceed this ceiling. desired["farclip"] = _FARCLIP_EXE_CEILING return desired def validate_limits(self): if not super().validate_limits(): return False try: farclip = float(self.vt_farclip.get()) except (tk.TclError, TypeError, ValueError): messagebox.showerror( "Input Error", "Render Distance (Farclip) must contain a valid number.", ) return False if not math.isfinite(farclip) or not 100.0 <= farclip <= _FARCLIP_EXE_CEILING: messagebox.showerror( "Limit Exceeded", "Render distance (Farclip) must stay between 100 and 3000. " "The 3000 hard maximum also applies when Safety Limits are disabled.", ) return False return True @staticmethod def _validate_client_identity(data): """Compatibility no-op: Turtle/Octo may move build/version strings.""" del data return None def _validate_source_numeric_values(self, data): """Sanity-check fixed-offset numeric source values without fingerprinting them.""" for _key, offset, label, minimum, maximum in _NUMERIC_SITES: raw = bytes(data[offset:offset + 4]) value = struct.unpack("