376 lines
14 KiB
Python
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
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|