"""Extracted from octo_updater.py. Keep this module focused on its named responsibility.""" import json import hashlib import os import sys import ssl import re import subprocess import urllib.request from urllib.parse import urlsplit import shutil import stat import struct import time import math import threading import queue from functools import cache from pathlib import Path from app_log import log from config import load_config, update_config from platform_ops import display_info def remove_wdb(client_dir: str): """Delete the client's WDB folder (server-data cache, safe to drop).""" wdb = os.path.join(client_dir, "WDB") if not os.path.isdir(wdb): return try: shutil.rmtree(wdb) log("WDB cache cleared.", "dim") except Exception as e: log(f"Could not clear WDB: {e}", "err") def get_client_version(out_dir: str) -> str: """Read version + build from fixed offsets in the client's WoW.exe.""" exe_path = os.path.join(out_dir, "WoW.exe") if not os.path.exists(exe_path): return "" try: # Read only the two small fields, not the whole ~5 MB binary. with open(exe_path, "rb") as f: f.seek(0x00437bfc) build = f.read(4).decode("utf-8", errors="replace").rstrip("\x00") f.seek(0x00437c04) version = f.read(6).decode("utf-8", errors="replace").rstrip("\x00") except Exception: return "" # Unexpected build, or an exe half-written mid-update, reads junk there - # show nothing rather than garbage. A real read looks like version "1.12.1", # build "5875". if not re.fullmatch(r"\d+(?:\.\d+)+", version) or not build.isdigit(): return "" return f"{version} ({build})" def fmt_size(num_bytes: float) -> str: """Human-readable size: KB, MB, GB. Uses binary (1024) steps but the familiar KB/MB/GB labels. Truncated, not rounded, so 8.7998 GiB reads as '8.79 GB' instead of rounding up to 8.80 and overstating the size.""" if num_bytes < 1024 ** 2: return f"{int(num_bytes / 1024)} KB" if num_bytes < 1024 ** 3: return f"{int(num_bytes / 1024 ** 2 * 10) / 10:.1f} MB" return f"{int(num_bytes / 1024 ** 3 * 100) / 100:.2f} GB" def fmt_speed(bytes_per_sec: float) -> str: if bytes_per_sec < 1024 * 1024: return f"{bytes_per_sec / 1024:.0f} KB/s" return f"{bytes_per_sec / 1024 / 1024:.1f} MB/s" # ────────────────────────────────────────────────────────────────────────────── # The pristine (unpatched) WoW.exe carries 0xa1 at this offset; a patched one # carries 0xb8 (see the locale patch). Used to cache a clean base for re-patch. _LOCALE_ASSERT_OFFSET = 0x1b2115 # Game-language (WoW.exe locale) patch # The language is switched by patching three spots in WoW.exe. # LOCALE_NAMES is the exe's built-in 8-slot locale table (at # 0x45591c - index*8). Selectable languages map to a slot index; ruRU and ptBR # reuse the unused zhTW / xxYY slots and rename them. Offsets/bytes verified # against the pristine base-WoW.exe. _LOCALE_TAG_OFFSET = 0x1b2115 # the 0xa1/0xb8 assert instruction _LOCALE_INDEX_OFFSET = 0x253c _LOCALE_NAME_OFFSET = lambda i: 0x45591c - i * 8 LOCALE_NAMES = ["enUS", "koKR", "frFR", "deDE", "zhCN", "zhTW", "esES", "xxYY"] # selectable code -> (slot index, display label), in menu order LOCALES = { "enUS": (0, "English"), "deDE": (3, "Deutsch"), "ruRU": (5, "Русский"), "zhCN": (4, "中文 (简体)"), "esES": (6, "Español"), "ptBR": (7, "Português (BR)"), } DEFAULT_LOCALE = "enUS" def locale_patches(locale: str): """The three WoW.exe byte edits that force the given game language, as (offset, bytes) tuples applied on top of the pristine base.""" if locale not in LOCALES: locale = DEFAULT_LOCALE idx, _ = LOCALES[locale] carrier = LOCALE_NAMES[idx] # exe slot name at this index return [ # assert instruction: mov eax, (was mov eax,[imm]) (_LOCALE_TAG_OFFSET, bytes([0xb8]) + bytes(carrier, "latin1")[::-1]), # locale index: mov esi, ; jmp +0x1f (_LOCALE_INDEX_OFFSET, bytes([0xbe, idx, 0x00, 0x00, 0x00, 0xeb, 0x1f])), # rename the slot's locale-name string to the selected tag (_LOCALE_NAME_OFFSET(idx), bytes(locale, "latin1")), ] def write_config_wtf(client_dir: str, tweaks: dict | None = None): """Write a fresh Config.wtf from scratch, overwriting any existing one. Never raises — logs the error if the file can't be written (read-only, locked by a running game, or an unwritable folder).""" if tweaks is None: tweaks = load_tweaks_config() far_clip = tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"]) cam_dist = tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"]) nameplate = tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"]) fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"]) fov_rad = round(fov_deg * math.pi / 180.0, 6) bg_sound = 1 if tweaks.get("soundInBackground", TWEAKS_DEFAULTS["soundInBackground"]) else 0 di = _get_display_info_safe() srv = "octowow.st" vars_ = { "realmList": srv, "patchList": srv, "readTOS": 1, "readEULA": 1, "profanityFilter": 0, "gxResolution": f"{di['width']}x{di['height']}", "gxWindow": 1, "gxMaximize": 1, "gxVSync": 0, "gxColorBits": 24, "gxDepthBits": 24, "gxRefresh": di["refresh_rate"], "gxMultisampleQuality": 0, "gxMultisample": 2, "hwDetect": 0, "pixelShaders": 1, "M2UsePixelShaders": 1, "specular": 1, "anisotropic": 16, "trilinear": 1, "lod": 0, "lodDist": 100, "texLodBias": 0, "shadowLevel": 0, "particleDensity": 1, "fullAlpha": 1, "SmallCull": 0.01, "farClip": far_clip, "DistCull": 888.8, "frillDensity": 128, "unitDrawDist": 300, "weatherDensity": 3, "FoV": fov_rad, "NameplateRange": nameplate, "CameraDistanceMax": cam_dist, "cameraDistanceMaxFactor": 1, "scriptMemory": 512000, "uiScale": 1, "mouseSpeed": 1, "autoSelfCast": 1, "movie": 0, "movieSubtitle": 1, "checkAddonVersion": 0, "minimapZoom": 0, "minimapInsideZoom": 0, "EnableErrorSpeech": 0, "SoundZoneMusicNoDelay": 1, "SoundMaxHardwareChannels": 64, "SoundSoftwareChannels": 64, "UncapSounds": 1, "BackgroundSound": bg_sound, "NP_NameplateDistance": nameplate, "NP_SpellQueueWindowMs": 150, "NP_EnableAuraCastEvents": 1, "NP_EnableAutoAttackEvents": 1, "NP_EnableSpellStartEvents": 1, "NP_EnableSpellGoEvents": 1, "NP_EnableSpellHealEvents": 1, "NP_QueueCastTimeSpells": 0, "NP_QueueInstantSpells": 0, "NP_QueueChannelingSpells": 0, "NP_QueueTargetingSpells": 0, "NP_QueueSpellsOnCooldown": 0, "NP_ChatBubbleDistance": 60, "NP_ChatBubblesWhisper": 1, "NP_ChatBubblesRaid": 1, "NP_ChatBubblesBattleground": 1, "ChatBubblesParty": 1, } try: cfg_dir = os.path.join(client_dir, "WTF") ensure_dir(cfg_dir) with open(os.path.join(cfg_dir, "Config.wtf"), "w", encoding="utf-8") as f: for k, v in vars_.items(): f.write(f'SET {k} "{v}"\n') log("Config.wtf written.", "ok") except Exception as e: log(f"Could not write Config.wtf: {e}", "err") def update_config_wtf(client_dir: str, tweaks: dict): cfg_path = os.path.join(client_dir, "WTF", "Config.wtf") if not os.path.exists(cfg_path): write_config_wtf(client_dir, tweaks) return far_clip = tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"]) cam_dist = tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"]) nameplate = tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"]) fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"]) fov_rad = round(fov_deg * math.pi / 180.0, 6) bg_sound = 1 if tweaks.get("soundInBackground", TWEAKS_DEFAULTS["soundInBackground"]) else 0 updates = { "farClip": str(far_clip), "CameraDistanceMax": str(cam_dist), "NP_NameplateDistance": str(nameplate), "FoV": str(fov_rad), "NameplateRange": str(nameplate), "BackgroundSound": str(bg_sound), } with open(cfg_path, "r", encoding="utf-8") as f: lines = f.readlines() updated_keys = set() new_lines = [] for line in lines: matched = False for key, val in updates.items(): if line.strip().lower().startswith(f"set {key.lower()} "): new_lines.append("SET " + key + ' "' + val + '"\n') updated_keys.add(key) matched = True break if not matched: new_lines.append(line) for key, val in updates.items(): if key not in updated_keys: new_lines.append("SET " + key + ' "' + val + '"\n') with open(cfg_path, "w", encoding="utf-8") as f: f.writelines(new_lines) log(f" Config.wtf updated: farClip={far_clip}, CameraDistanceMax={cam_dist}, " f"NameplateRange={nameplate}, NP_NameplateDistance={nameplate}, " f"FoV={fov_rad}", "dim") # ────────────────────────────────────────────────────────────────────────────── # Mods definition & engine # ────────────────────────────────────────────────────────────────────────────── # Registry order == install order: VanillaFixes first (it provides the # loader the other mods rely on). The UI sorts alphabetically for display. TWEAKS_DEFAULTS = { "locale": DEFAULT_LOCALE, "alwaysAutoLoot": True, "nameplateRange": 41, "fieldOfView": 110, "farClip": 777, "frillDistance": 120, "cameraDistance": 50, "soundInBackground": True, } TWEAKS_ITEMS = [ (None, "GENERAL", "section", False, None, None, None, None, None), ("locale", "Game Language", "dropdown", False, None, None, None, None, None), ("alwaysAutoLoot", "Auto Loot", "checkbox", True, None, "Reverses the auto-loot behavior to always auto-loot.", None, None, None), ("nameplateRange", "Nameplate Range", "number", False, None, "Distance at which nameplates are visible. [0 - 41]", 0, 41, 1), (None, "CAMERA", "section", False, None, None, None, None, None), ("fieldOfView", "Field of View", "number", False, None, "Recommended values by aspect ratio: [4:3 = 90] [16:9 = 110] [21:9 = 150] [32:9 = 180]", 90, 180, 5), ("cameraDistance", "Camera Distance", "number", False, None, "Maximum camera zoom-out distance. [50 - 100]", 50, 100, 1), (None, "GRAPHICS", "section", False, None, None, None, None, None), ("farClip", "World Distance", "number", False, None, "Terrain and world objects rendering distance. [100 - 10,000]", 100, 10000, 1), ("frillDistance", "Ground Clutter Distance", "number", False, None, "Grass and small rocks rendering distance. [0 - 300]", 0, 300, 1), (None, "SOUND", "section", False, None, None, None, None, None), ("soundInBackground", "Sound in Background", "checkbox", True, None, "Allows game sounds to play in the background.", None, None, None), ] # {tweak_id: (min, max)} for every numeric tweak — the single source of # truth for clamping, wherever the value is read from the UI. TWEAKS_LIMITS = {t[0]: (t[6], t[7]) for t in TWEAKS_ITEMS if t[0] is not None and t[2] == "number"} _FOV_REFS = [ (4 / 3, 90), (16 / 9, 110), (21 / 9, 150), (32 / 9, 180), ] def fov_default_for_display() -> int: try: info = _get_display_info_safe() ratio = info["width"] / info["height"] if info["height"] else 16 / 9 except Exception: ratio = 16 / 9 if ratio <= _FOV_REFS[0][0]: return _FOV_REFS[0][1] if ratio >= _FOV_REFS[-1][0]: return _FOV_REFS[-1][1] for i in range(len(_FOV_REFS) - 1): r0, f0 = _FOV_REFS[i] r1, f1 = _FOV_REFS[i + 1] if r0 <= ratio <= r1: t = (ratio - r0) / (r1 - r0) raw = f0 + t * (f1 - f0) return round(round(raw / 5) * 5) return 110 def _get_display_info_safe() -> dict: return display_info() def load_tweaks_config() -> dict: cfg = load_config() stored = cfg.get("tweaks", {}) defaults = dict(TWEAKS_DEFAULTS) defaults["fieldOfView"] = fov_default_for_display() return {k: stored.get(k, v) for k, v in defaults.items()} def save_tweaks_config(values: dict): update_config(lambda c: c.__setitem__("tweaks", values)) # ────────────────────────────────────────────────────────────────────────────── # News feed # ──────────────────────────────────────────────────────────────────────────────