Files
2026-09-19 12:34:33 -04:00

376 lines
14 KiB
Python

"""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, <carrier as reversed dword> (was mov eax,[imm])
(_LOCALE_TAG_OFFSET,
bytes([0xb8]) + bytes(carrier, "latin1")[::-1]),
# locale index: mov esi, <index>; 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
# ──────────────────────────────────────────────────────────────────────────────