Compare commits

1 Commits

Author SHA1 Message Date
Microchip dcaedf7f6c second refactoring draft 2026-09-19 12:34:33 -04:00
15 changed files with 6727 additions and 6561 deletions
Regular → Executable
View File
+25
View File
@@ -0,0 +1,25 @@
# Architecture
The updater is split by responsibility rather than by framework or class hierarchy.
- `octo_updater.py` — executable entry point only.
- `ui.py` — Tk presentation and user interaction.
- `config.py` — application paths and atomic configuration persistence.
- `net.py` — HTTPS validation and secure URL opening.
- `torrent.py` — torrent parsing, selection, verification, aria2 synchronization, and update workers.
- `client.py` — WoW client inspection, locale/config patching, tweaks, and client-specific transformations.
- `mods.py` — mod and addon catalogs, release lookup, installation, and removal.
- `platform_ops.py` — operating-system operations such as directory links, folder opening, DPI, Defender, process launching, and display APIs.
- `app_log.py` — the small thread-safe application log channel shared by workers and the UI.
## Dependency rules
`ui.py` may depend on the domain modules. Domain modules must not import `ui.py`.
`platform_ops.py` contains OS-specific process/API behavior and must not depend on Tk.
`net.py` contains generic network policy and must not contain WoW, torrent, mod, or UI policy.
`client.py`, `torrent.py`, and `mods.py` expose ordinary functions/data and do not require the Tk application to be running.
No platform class hierarchy, factories, dependency-injection container, repository layer, or plugin framework is used. New abstractions should only be introduced when a concrete requirement requires them.
Regular → Executable
View File
Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Regular → Executable
View File
+7
View File
@@ -0,0 +1,7 @@
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
import queue
LOG_QUEUE: queue.Queue = queue.Queue()
def log(msg: str, tag: str = "") -> None:
LOG_QUEUE.put((msg, tag))
+375
View File
@@ -0,0 +1,375 @@
"""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
# ──────────────────────────────────────────────────────────────────────────────
+122
View File
@@ -0,0 +1,122 @@
"""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
UPDATER_VERSION = "1.3.1"
SERVER = "https://octowow.st"
UA = f"OctoUpdater/{UPDATER_VERSION}"
DOWNLOAD_RETRY = 5
DOWNLOAD_TIMEOUT = 10 # seconds without any data before a transfer aborts
# Where the app lives: next to the .exe when frozen (PyInstaller), otherwise
# next to this script — never the current working directory, which varies with
# how the app was launched. This anchors the default game folder.
if getattr(sys, "frozen", False):
APP_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
APP_DIR = os.path.dirname(os.path.abspath(__file__))
def _default_app_data_dir() -> str:
base = os.environ.get("LOCALAPPDATA")
if base:
path = os.path.join(base, "OctoUpdater")
try:
os.makedirs(path, exist_ok=True)
return path
except OSError:
pass
return APP_DIR
APP_DATA_DIR = _default_app_data_dir()
CONFIG_FILE = os.path.join(APP_DATA_DIR, "config.json")
# First-run default game folder, anchored to the app dir (not the CWD).
DEFAULT_GAME_DIR = os.path.join(APP_DIR, "OctoWoW")
def _relocate_legacy_data():
# Old config (<1.3: octo_updater_config.json beside the app) becomes
# config.json in APP_DATA_DIR. Rename + relocate, only when the old name
# exists and the new path doesn't (idempotent). The legacy game-hash cache
# is no longer used and is deleted by _migrate_1_3.
old_name, new = "octo_updater_config.json", CONFIG_FILE
for old in (os.path.join(APP_DIR, old_name),
os.path.join(APP_DATA_DIR, old_name)):
if old == new or not os.path.exists(old) or os.path.exists(new):
continue
try:
shutil.move(old, new)
break
except OSError:
pass
_CONFIG_LOCK = threading.RLock()
def load_config() -> dict:
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except FileNotFoundError:
return {}
except Exception:
return {}
def _atomic_write(path: str, text: str):
"""Write via a temp file + atomic rename so a crash mid-write can never
leave a truncated/corrupt file at `path`."""
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def save_config(data: dict):
with _CONFIG_LOCK:
try:
_atomic_write(CONFIG_FILE, json.dumps(data, indent=2))
except Exception:
pass
def update_config(mutator):
"""Load the current on-disk config under the lock, apply `mutator(cfg)`,
save atomically, and return the result. Every config change — main thread
or worker — should go through this so no stale in-memory snapshot can
overwrite keys another thread just persisted."""
with _CONFIG_LOCK:
cfg = load_config()
mutator(cfg)
save_config(cfg)
return cfg
def ensure_dir(path):
Path(path).mkdir(parents=True, exist_ok=True)
# ── logging ─────────────────────────────────────────────────────────────────
# One thread-safe log sink for the whole app. Any function — worker thread or
# main — calls log(); the GUI drains _LOG_Q on the main thread (see
# OctoUpdaterApp._poll) and renders each line. This keeps all Tk access on the
+1211
View File
File diff suppressed because one or more lines are too long
+99
View File
@@ -0,0 +1,99 @@
"""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 config import UA
# ──────────────────────────────────────────────────────────────────────────────
# Secure networking
# ──────────────────────────────────────────────────────────────────────────────
# Hardened TLS: verify the server certificate against the system trust store,
# require the hostname to match, and refuse anything below TLS 1.2. This is
# the primary defence against a man-in-the-middle tampering with downloads.
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = True
SSL_CTX.verify_mode = ssl.CERT_REQUIRED
# Trust certifi's curated roots *in addition to* the system store, so a stale
# or incomplete Windows root store (Python's ssl uses a static snapshot and
# never triggers Windows' on-demand root update) can't break verification.
# If certifi isn't bundled, fall back to the system store alone.
try:
import certifi
SSL_CTX.load_verify_locations(certifi.where())
except Exception:
pass
try:
SSL_CTX.minimum_version = ssl.TLSVersion.TLSv1_2
except (AttributeError, ValueError):
pass
# Binaries may only be fetched from these hosts. TLS already stops a MITM from
# impersonating them; this additionally stops a tampered API response from
# redirecting a download (e.g. a mod DLL) to an unexpected host.
ALLOWED_DOWNLOAD_HOSTS = {
"octowow.st",
"dl.octowow.st",
"github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
"codeberg.org",
}
def _check_url(url: str, allowed_hosts):
"""Enforce HTTPS and (optionally) an allowlist on a URL."""
parts = urlsplit(url)
if parts.scheme != "https":
raise RuntimeError(f"Refusing non-HTTPS URL: {url}")
if allowed_hosts is not None:
host = (parts.hostname or "").lower()
if host not in allowed_hosts:
raise RuntimeError(f"Refusing download from unexpected host: {host}")
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Require every redirect target to stay HTTPS (blocks an https→http
downgrade). The host allowlist is deliberately *not* re-applied on
redirects: an allowlisted host controls its own redirects — legitimately
to its CDN (e.g. octowow.st→dl.octowow.st, github.com→codeload) — and TLS
protects wherever it lands. The allowlist's job is to vet the *initial*
URL (against a tampered API response), which secure_urlopen still does."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
_check_url(newurl, None) # HTTPS-only, no host check
return super().redirect_request(req, fp, code, msg, headers, newurl)
# Shared opener with the hardened TLS context and the HTTPS-only redirect
# guard, built once.
_SECURE_OPENER = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=SSL_CTX),
_HttpsOnlyRedirectHandler())
def secure_urlopen(req, timeout, allowed_hosts=None):
"""urlopen wrapper that enforces HTTPS + an optional host allowlist on the
initial URL, keeps redirects on HTTPS, and uses the hardened TLS context.
`req` may be a URL string or a urllib Request."""
url = req.full_url if isinstance(req, urllib.request.Request) else req
_check_url(url, allowed_hosts)
return _SECURE_OPENER.open(req, timeout=timeout)
Regular → Executable
+11 -6561
View File
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
def open_directory(path: str) -> None:
if sys.platform == "win32":
subprocess.Popen(["explorer.exe", path])
else:
opener = shutil.which("xdg-open")
if not opener:
raise RuntimeError("xdg-open is required to open directories")
subprocess.Popen([opener, path])
def create_directory_link(link: str, target: str) -> None:
if sys.platform == "win32":
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(["cmd", "/c", "mklink", "/J", link, target], capture_output=True, text=True, creationflags=flags)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "mklink failed")
return
os.symlink(target, link, target_is_directory=True)
def enable_dpi_awareness() -> None:
if sys.platform != "win32":
return
import ctypes
try:
user32 = ctypes.windll.user32
fn = getattr(user32, "SetProcessDpiAwarenessContext", None)
if fn:
fn(ctypes.c_void_p(-4))
return
except Exception:
pass
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
return
except Exception:
pass
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
def request_defender_exclusion(path: str) -> bool:
"""Request a Windows Defender exclusion. Returns False off Windows."""
if sys.platform != "win32":
return False
import ctypes
escaped = path.replace("'", "''")
command = f"Add-MpPreference -ExclusionPath '{escaped}'"
result = ctypes.windll.shell32.ShellExecuteW(
None, "runas", "powershell.exe",
f'-NoProfile -WindowStyle Hidden -Command "{command}"', None, 0)
return result > 32
def launch_executable(executable: str, cwd: str) -> None:
"""Launch the managed executable using native process semantics."""
if sys.platform != "win32":
raise RuntimeError("native Linux game launching is not configured")
flags = (getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0))
try:
subprocess.Popen([executable], cwd=cwd, creationflags=flags, close_fds=True)
except OSError:
flags &= ~getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0)
subprocess.Popen([executable], cwd=cwd, creationflags=flags, close_fds=True)
def display_info() -> dict:
"""Return primary display dimensions and refresh rate on Windows."""
if sys.platform != "win32":
raise RuntimeError("display information is not implemented for this platform")
import ctypes
class DEVMODE(ctypes.Structure):
_fields_ = [
("dmDeviceName", ctypes.c_wchar * 32), ("dmSpecVersion", ctypes.c_ushort),
("dmDriverVersion", ctypes.c_ushort), ("dmSize", ctypes.c_ushort),
("dmDriverExtra", ctypes.c_ushort), ("dmFields", ctypes.c_ulong),
("dmPositionX", ctypes.c_long), ("dmPositionY", ctypes.c_long),
("dmDisplayOrientation", ctypes.c_ulong), ("dmDisplayFixedOutput", ctypes.c_ulong),
("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short),
("dmYResolution", ctypes.c_short), ("dmTTOption", ctypes.c_short),
("dmCollate", ctypes.c_short), ("dmFormName", ctypes.c_wchar * 32),
("dmLogPixels", ctypes.c_ushort), ("dmBitsPerPel", ctypes.c_ulong),
("dmPelsWidth", ctypes.c_ulong), ("dmPelsHeight", ctypes.c_ulong),
("dmDisplayFlags", ctypes.c_ulong), ("dmDisplayFrequency", ctypes.c_ulong),
]
dm = DEVMODE()
dm.dmSize = ctypes.sizeof(DEVMODE)
ctypes.windll.user32.EnumDisplaySettingsW(None, -1, ctypes.byref(dm))
return {"width": dm.dmPelsWidth, "height": dm.dmPelsHeight,
"refresh_rate": dm.dmDisplayFrequency}
Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+907
View File
@@ -0,0 +1,907 @@
"""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 APP_DATA_DIR, UA, DOWNLOAD_RETRY, DOWNLOAD_TIMEOUT, ensure_dir, load_config, update_config
from net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
from client import locale_patches, write_config_wtf, update_config_wtf
from platform_ops import create_directory_link
# Torrent-based client sync (aria2c)
# ──────────────────────────────────────────────────────────────────────────────
# Client files are synced over BitTorrent with aria2c, fetched on first use.
# The download is differential: only files that are missing or the wrong size
# are pulled.
CLIENT_TORRENT_URL = "https://dl.octowow.st/download/client.torrent"
# Pinned aria2 Windows build. aria2 is GPLv2+, fetched and run unmodified; only
# aria2c.exe is used. The sha256 is of the release .zip (verified once).
ARIA2_ZIP_URL = ("https://github.com/aria2/aria2/releases/download/"
"release-1.37.0/aria2-1.37.0-win-32bit-build1.zip")
ARIA2_ZIP_SHA256 = "35f6514cc5dd7e98a87b3c4c2d25a0754b9b063dbe59bc0f22d483464f61e5b6"
ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe")
# The torrent's top-level folder name: aria2 writes files under <dir>/<name>/…,
# so a junction <staging>/client → the real client dir lands them in place.
TORRENT_NAME = "client"
TORRENT_STAGING_DIR = os.path.join(APP_DATA_DIR, "torrent-root")
PRISTINE_WOW_PATH = os.path.join(APP_DATA_DIR, "base-WoW.exe")
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def ensure_aria2c(log_fn=log) -> str:
"""Return the path to aria2c.exe, downloading + checksum-verifying it into
APP_DATA_DIR on first use. Raises on failure."""
if os.path.exists(ARIA2C_PATH):
return ARIA2C_PATH
log_fn("Fetching aria2c (one-time, ~2.5 MB)…", "acct")
req = urllib.request.Request(ARIA2_ZIP_URL, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=60, allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
data = r.read()
digest = hashlib.sha256(data).hexdigest()
if digest != ARIA2_ZIP_SHA256:
raise RuntimeError(
f"aria2 checksum mismatch (got {digest[:12]}…); refusing to run it")
import zipfile, io
with zipfile.ZipFile(io.BytesIO(data)) as zf:
name = next((n for n in zf.namelist()
if n.lower().endswith("aria2c.exe")), None)
if not name:
raise RuntimeError("aria2c.exe not found in the aria2 archive")
exe = zf.read(name)
ensure_dir(APP_DATA_DIR)
tmp = ARIA2C_PATH + ".part"
with open(tmp, "wb") as f:
f.write(exe)
os.replace(tmp, ARIA2C_PATH)
log_fn("aria2c ready.", "ok")
return ARIA2C_PATH
def _bdecode(buf: bytes, pos: int = 0):
"""Minimal bencode decoder → (value, next_pos). Strings stay bytes."""
ch = buf[pos]
if ch == 0x69: # i<int>e
end = buf.index(b"e", pos)
return int(buf[pos + 1:end]), end + 1
if ch == 0x6c: # l<items>e
lst, p = [], pos + 1
while buf[p] != 0x65:
v, p = _bdecode(buf, p)
lst.append(v)
return lst, p + 1
if ch == 0x64: # d<pairs>e
d, p = {}, pos + 1
while buf[p] != 0x65:
k, p = _bdecode(buf, p)
v, p = _bdecode(buf, p)
d[k] = v
return d, p + 1
colon = buf.index(b":", pos) # <len>:<bytes>
n = int(buf[pos:colon])
start = colon + 1
return buf[start:start + n], start + n
def fetch_torrent(url: str = CLIENT_TORRENT_URL) -> tuple:
"""Download the .torrent → (raw_bytes, files). `files` is a list of
(path_parts, length). The raw bytes' SHA-1 is the client 'version'."""
req = urllib.request.Request(url, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=DOWNLOAD_TIMEOUT,
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
raw = r.read()
decoded, _ = _bdecode(raw)
info = decoded.get(b"info", {})
files = [([p.decode("latin1") for p in f.get(b"path", [])],
int(f.get(b"length", 0)))
for f in info.get(b"files", [])]
return raw, files
def torrent_version(raw: bytes) -> str:
"""SHA-1 of the whole .torrent file — the client 'version'. Changes whenever
the torrent is re-rolled, so it's what we compare to detect a new build and
decide whether aria2's resume state is stale. An occasional re-check when
only the announce/date changed (content unchanged) is harmless."""
return hashlib.sha1(raw).hexdigest()
@cache
def _torrent_excluded_files() -> frozenset:
"""Lower-cased basenames of every file the Mods tab installs, across all
mods — the client-root files the torrent sync leaves to the mod system.
The torrent ships several of them (VfPatcher.dll, nampower.dll, d3d9.dll,
…), but the Mods tab is the single source of truth for installing /
disabling / versioning them, so the sync must never fetch, re-add, or flag
them. Computed once and memoized (MODS_REGISTRY is static at import)."""
return frozenset(
os.path.basename(f).lower()
for mod in MODS_REGISTRY
for f in mod.get("installed_files", [])
)
def _is_torrent_excluded(parts) -> bool:
return len(parts) == 1 and parts[0].lower() in _torrent_excluded_files()
def _torrent_skip(parts, ignore_speech: bool, client_dir: str) -> bool:
"""Files the sync must leave alone: mod-owned client-root files, plus a
speech.MPQ the user opted to keep (Settings) — but only when it already
exists on disk. A missing speech.MPQ is still downloaded so a clean client
can launch; 'ignore' protects a custom copy, it doesn't skip a required file
that isn't there."""
if _is_torrent_excluded(parts):
return True
if ignore_speech and parts and parts[-1].lower() == "speech.mpq":
return os.path.exists(os.path.join(client_dir, *parts))
return False
# Files the sync must not rewrite (mod-owned client-root files, plus a kept
# custom speech.MPQ). Excluding them from --select-file stops aria2 fetching
# them on their own, but a torrent piece can straddle a file boundary, so
# repairing a selected neighbour re-downloads the shared piece and rewrites the
# excluded file's bytes too. We move them aside (rename) for the sync and put
# them back — instant and RAM-free, unlike copying a large speech.MPQ.
_SHIELD_SUFFIX = ".octobak"
def shield_protected_files(client_dir: str, files, ignore_speech: bool) -> list:
"""Prepare each sync-protected file (see _torrent_skip) for the sync. An
existing one is moved aside via a same-dir rename; an absent one is recorded
with backup=None, because the sync must never create it (only the Mods tab
installs these) yet aria2 may write a partial stub for it through a shared
piece. Returns [(orig, backup_or_None)] for unshielding."""
shielded = []
for parts, _length in files:
if not _torrent_skip(parts, ignore_speech, client_dir):
continue
p = os.path.join(client_dir, *parts)
if os.path.exists(p):
bak = p + _SHIELD_SUFFIX
try:
os.replace(p, bak) # same-fs, instant; aria2 sees p missing
shielded.append((p, bak))
except OSError:
pass
else:
shielded.append((p, None)) # must stay absent afterwards
return shielded
def unshield_protected_files(shielded) -> list:
"""Move each shielded file back, or delete the stub the sync created for a
file that was absent before. Returns the basenames restored."""
restored = []
for p, bak in shielded:
if bak is None:
try:
os.remove(p) # drop aria2's partial stub; stay absent
except OSError:
pass
continue
try:
os.replace(bak, p) # our version wins over aria2's partial
restored.append(os.path.basename(p))
except OSError:
pass
return restored
def recover_protected_files(client_dir: str, files):
"""Undo a shield interrupted by a crash: an orphaned '.octobak' beside a
torrent file is the real file — move it back into place."""
for parts, _length in files:
p = os.path.join(client_dir, *parts)
bak = p + _SHIELD_SUFFIX
if os.path.exists(bak):
try:
os.replace(bak, p)
except OSError:
pass
def torrent_selection(client_dir: str, files, drop_mismatched=False,
ignore_speech=False):
"""1-indexed list of torrent files that are missing or the wrong size on
disk (aria2 --select-file), plus whether any were entirely missing."""
need, missing = [], False
for i, (parts, length) in enumerate(files):
if _torrent_skip(parts, ignore_speech, client_dir):
continue
dest = os.path.join(client_dir, *parts)
try:
size = os.path.getsize(dest)
except OSError:
missing = True
need.append(i + 1)
continue
if size != length:
# oversized/corrupt file poisons resume — drop it; a short file is
# kept so aria2 can resume it
if drop_mismatched or size > length:
try:
os.remove(dest)
except OSError:
pass
need.append(i + 1)
return need, missing
def torrent_all_selection(client_dir: str, files, ignore_speech=False) -> list:
"""1-indexed list of every non-mod file in the torrent. Used for an
integrity pass: aria2 --check-integrity verifies each selected file's piece
hashes and re-downloads only the bad/missing pieces — catching same-size but
corrupted files that the size-based torrent_selection can't."""
return [i + 1 for i, (parts, _) in enumerate(files)
if not _torrent_skip(parts, ignore_speech, client_dir)]
def torrent_tree_intact(client_dir: str, files, ignore_speech=False) -> bool:
for parts, length in files:
if _torrent_skip(parts, ignore_speech, client_dir):
continue
try:
if os.path.getsize(os.path.join(client_dir, *parts)) != length:
return False
except OSError:
return False
return True
# Legacy leftovers the current client no longer ships. Locale data folders are
# matched by name; the old patch archives by name AND exact size, so a player
# mod that reused one of these names is never deleted.
_LOCALE_DATA_DIRS = {
"enus", "engb", "encn", "entw", "kokr", "frfr", "dede", "zhcn",
"zhtw", "eses", "esmx", "ruru", "ptbr", "ptpt", "itit",
}
_LEGACY_ARCHIVES = {
"patch-6.mpq": 451195806,
"patch-7.mpq": 175256564,
"patch-8.mpq": 484649870,
"patch-9.mpq": 506808141,
"patch-a.mpq": 241751337,
}
def prune_stale_client_files(client_dir: str, files) -> list:
"""Remove legacy Data/<locale>/ folders and known old-client patch MPQs the
current torrent no longer ships. Folders are removed by name (unless the torrent
still uses them); archives only when the name AND the exact size match a known
legacy one. Returns removed names."""
data_dir = os.path.join(client_dir, "Data")
if not os.path.isdir(data_dir):
return []
# what the current torrent puts directly in Data/ (.mpq files and subdirs)
expected, used_dirs = set(), set()
for parts, _length in files:
if not parts or parts[0] != "Data":
continue
if len(parts) == 2 and parts[1].lower().endswith(".mpq"):
expected.add(parts[1].lower())
elif len(parts) >= 3:
used_dirs.add(parts[1].lower())
removed = []
for name in os.listdir(data_dir):
lc, full = name.lower(), os.path.join(data_dir, name)
if os.path.isdir(full):
if lc in _LOCALE_DATA_DIRS and lc not in used_dirs:
try:
shutil.rmtree(full)
removed.append(name + "/")
except OSError:
pass
continue
if not lc.endswith(".mpq") or lc in expected:
continue
try:
size = os.path.getsize(full)
except OSError:
continue
if _LEGACY_ARCHIVES.get(lc) == size:
try:
os.remove(full)
removed.append(name)
except OSError:
pass
return removed
# Downloadable MPQ content patches (not shipped with the client). Each is
# a single HTTP file with a `<url>.sha256` sidecar; an update is available when
# that published sha differs from the on-disk file's.
MPQ_PATCHES = [
{
"file": "patch-O.mpq",
"name": "Octo Raid Visuals",
"description": "Adds ground markers and sounds for boss abilities in raids.",
"url": "https://dl.octowow.st/client/latest/Data/patch-O.mpq",
},
]
def mpq_patch_for(filename: str):
"""The registry entry whose file matches `filename` (case-insensitive)."""
lc = filename.lower()
return next((e for e in MPQ_PATCHES if e["file"].lower() == lc), None)
def sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def fetch_mpq_sha256(url: str) -> str:
"""The published SHA256 of an MPQ patch, from its `<url>.sha256` sidecar."""
req = urllib.request.Request(url + ".sha256", headers={"User-Agent": UA})
with secure_urlopen(req, timeout=NEWS_TIMEOUT,
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
return r.read().decode("ascii", "ignore").strip().split()[0].lower()
def download_mpq_patch(entry: dict, data_dir: str, on_progress=None):
"""Download the patch into <data_dir>/<file>, verifying its published
SHA256. Writes to a .part file and renames on success. on_progress(done,
total) is called as bytes arrive."""
url = entry["url"]
want = fetch_mpq_sha256(url)
ensure_dir(data_dir)
tmp = os.path.join(data_dir, entry["file"] + ".part")
h = hashlib.sha256()
req = urllib.request.Request(url, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=DOWNLOAD_TIMEOUT,
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
total = int(r.headers.get("Content-Length") or 0)
done = 0
with open(tmp, "wb") as f:
while True:
chunk = r.read(256 * 1024)
if not chunk:
break
f.write(chunk)
h.update(chunk)
done += len(chunk)
if on_progress:
on_progress(done, total)
if h.hexdigest().lower() != want:
try:
os.remove(tmp)
except OSError:
pass
raise RuntimeError("checksum verification failed")
os.replace(tmp, os.path.join(data_dir, entry["file"]))
def clear_torrent_resume_state():
"""Delete aria2's saved control (.aria2) and metadata (.torrent) files in
the staging dir. They pin a specific torrent revision (info-hash) and record
which pieces are 'done', so a stale one causes an 'info hash mismatch' error
after the client torrent is re-rolled, or blocks re-downloading a file the
user deleted. Safe to call when nothing is there."""
try:
for name in os.listdir(TORRENT_STAGING_DIR):
if name.endswith((".aria2", ".torrent")):
try:
os.remove(os.path.join(TORRENT_STAGING_DIR, name))
except OSError:
pass
except OSError:
pass
def refresh_pristine_wow(client_dir: str):
"""Cache the freshly-synced (unpatched) WoW.exe as the pristine base, so a
later re-patch always starts from clean bytes."""
exe = os.path.join(client_dir, "WoW.exe")
try:
with open(exe, "rb") as f:
f.seek(_LOCALE_ASSERT_OFFSET)
pristine = f.read(1) == b"\xa1"
if pristine:
shutil.copyfile(exe, PRISTINE_WOW_PATH)
log("Cached pristine WoW.exe base.", "dim")
except OSError:
pass
def read_pristine_wow(client_dir: str) -> bytes:
"""The clean base to patch from: the cached pristine exe if present, else
the on-disk WoW.exe."""
if os.path.exists(PRISTINE_WOW_PATH):
with open(PRISTINE_WOW_PATH, "rb") as f:
return f.read()
with open(os.path.join(client_dir, "WoW.exe"), "rb") as f:
return f.read()
def _ensure_torrent_link(client_dir: str) -> str:
"""Point <staging>/client at client_dir at client_dir via a platform-appropriate directory link so
aria2 writes the torrent's files straight into the real client dir. Returns
the staging dir to pass as aria2 --dir."""
staging = TORRENT_STAGING_DIR
ensure_dir(staging)
link = os.path.join(staging, TORRENT_NAME)
target = os.path.abspath(client_dir)
try:
if os.path.isdir(link) and \
os.path.abspath(os.path.realpath(link)) == target:
return staging
except OSError:
pass
# remove a stale junction/link (rmdir drops the reparse point, not its
# target's contents) then recreate it
try:
os.rmdir(link)
except OSError:
try:
os.remove(link)
except OSError:
pass
create_directory_link(link, target)
if not os.path.isdir(link):
raise RuntimeError("could not create torrent download link")
return staging
_SIZE_UNITS = {"B": 1, "KiB": 1024, "MiB": 1024 ** 2,
"GiB": 1024 ** 3, "TiB": 1024 ** 4}
_UNIT = "|".join(_SIZE_UNITS) # B|KiB|MiB|GiB|TiB
_SIZE = rf"[\d.]+(?:{_UNIT})" # e.g. 8.8GiB
_FRAC = rf"({_SIZE})/({_SIZE})\((\d+)%\)" # done/total(percent%)
_ARIA_FRAC = re.compile(_FRAC)
_ARIA_DL = re.compile(rf"DL:({_SIZE})")
# During --check-integrity aria2 prints the hash-check progress in a separate
# field, e.g. '… [Checksum:#f66a3e 236MiB/1.5GiB(15%)]', while the leading
# completed/total stays at 0%/0B (a complete client downloads nothing). Read the
# Checksum fraction so the bar tracks the check instead of freezing at 0.
_ARIA_CHK = re.compile(rf"Checksum:#\w+\s+{_FRAC}")
def _to_bytes(s: str) -> float:
m = re.match(rf"^([\d.]+)({_UNIT})$", s.strip())
return float(m.group(1)) * _SIZE_UNITS[m.group(2)] if m else 0.0
def parse_aria_progress(line: str):
"""Parse an aria2 summary line like '1.2GiB/8.8GiB(13%) … DL:5.0MiB'
{progress, done, total, bps, checking}, or None for non-progress lines.
A checksum-check line is preferred over the (idle) download fraction."""
chk = _ARIA_CHK.search(line)
m = chk or _ARIA_FRAC.search(line)
if not m:
return None
dl = _ARIA_DL.search(line)
return {"progress": int(m.group(3)) / 100.0,
"done": _to_bytes(m.group(1)),
"total": _to_bytes(m.group(2)),
"bps": _to_bytes(dl.group(1)) if dl else 0.0,
"checking": chk is not None}
# The currently-running aria2c child, so it can be killed when the app quits
# (a daemon worker thread dying would otherwise orphan it, still downloading
# headless). --stop-with-process is aria2's own belt-and-suspenders for this,
# but it's unreliable on Windows — hence the explicit kill too.
_active_aria2: "subprocess.Popen | None" = None
_active_aria2_lock = threading.Lock()
def stop_aria2c():
"""Terminate the running aria2c child, if any. Safe to call from any thread
(e.g. the app's close handler)."""
global _active_aria2
with _active_aria2_lock:
proc, _active_aria2 = _active_aria2, None
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
def run_aria2c(client_dir, select_files=None, check_integrity=False,
on_progress=None, should_cancel=None, log_fn=log):
"""Sync the client torrent into client_dir with aria2c (leech-only). Blocks
until aria2c exits; raises on a non-zero exit or cancellation. Calls
on_progress(dict) per update and should_cancel()->bool to abort.
aria2 is handed the .torrent URL (not a local copy), so it always fetches
the server's current torrent at download time — no chance of running a stale
local .torrent if the user starts the update long after the verify."""
global _active_aria2
exe = ensure_aria2c(log_fn)
staging = _ensure_torrent_link(client_dir)
args = [
exe,
f"--dir={staging}",
# aria2 exits when this PID (the updater) does — stops an orphaned
# download if we're killed before the explicit stop_aria2c() runs.
f"--stop-with-process={os.getpid()}",
"--seed-time=0",
f"--check-integrity={'true' if check_integrity else 'false'}",
"--bt-remove-unselected-file=false",
"--continue=true",
"--allow-overwrite=true",
"--auto-file-renaming=false",
"--file-allocation=none",
"--disk-cache=128M",
"--stream-piece-selector=inorder",
"--max-tries=0",
"--retry-wait=5",
"--bt-stop-timeout=120",
"--auto-save-interval=15",
"--summary-interval=1",
"--human-readable=false",
"--truncate-console-readout=false",
"--console-log-level=warn",
"--enable-dht=true",
"--bt-enable-lpd=true",
"--max-connection-per-server=8",
"--split=16",
"--min-split-size=1M",
]
if select_files:
args.append("--select-file=" + ",".join(str(i) for i in select_files))
args.append(CLIENT_TORRENT_URL)
proc = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True,
bufsize=1, creationflags=_NO_WINDOW)
with _active_aria2_lock:
_active_aria2 = proc
try:
for line in proc.stdout:
if should_cancel and should_cancel():
proc.terminate()
raise RuntimeError("Cancelled")
line = line.strip()
if not line:
continue
p = parse_aria_progress(line)
if p:
# aria2's console readout floods idle '0B/total(0%)' lines (no
# bytes, no checksum) between the once-a-second summary blocks
# that carry the real figure. Drop the idle ones so they can't
# stomp progress back to 0 under the UI's latest-wins draining.
informative = (p["done"] or p["progress"]
or p["bps"] or p["checking"])
if on_progress and informative:
on_progress(p)
else:
log_fn(f"[aria2] {line}", "dim")
finally:
try:
proc.stdout.close()
except Exception:
pass
with _active_aria2_lock:
if _active_aria2 is proc:
_active_aria2 = None
code = proc.wait()
if code != 0:
raise RuntimeError(f"aria2c exited with code {code}")
class VerifyWorker:
def __init__(self, out_dir: str, log_q: queue.Queue, prog_q: queue.Queue):
self.out_dir = out_dir
self.log_q = log_q
self.prog_q = prog_q
self._cancel = False
def cancel(self):
self._cancel = True
def log(self, msg, tag=""):
self.log_q.put((msg, tag))
def progress(self, value, label=""):
self.prog_q.put((value, label))
def run(self):
try:
self.log("Checking for updates…", "acct")
raw, files = fetch_torrent()
self.log("Checking game files…", "acct")
# aria2 selects by size (a patched WoW.exe keeps the torrent's size,
# so it's never flagged); need == files missing or wrong-sized.
ignore_speech = bool(load_config().get("ignore_speech", False))
need, _missing = torrent_selection(self.out_dir, files,
ignore_speech=ignore_speech)
have_exe = os.path.exists(os.path.join(self.out_dir, "WoW.exe"))
if have_exe and not need:
self.log("Everything is up to date!", "ok")
self.log_q.put(("__UP_TO_DATE__", ""))
else:
self.log("Update available.", "acct")
self.log_q.put(("__UPDATE_NEEDED__", ""))
except Exception as e:
self.log(f"Verification failed: {e}", "err")
self.log_q.put(("__UPDATE_NEEDED__", ""))
class UpdateWorker:
def __init__(self, out_dir: str, log_q: queue.Queue, prog_q: queue.Queue,
check_integrity: bool = False, overwrite_config: bool = False):
self.out_dir = out_dir
self.log_q = log_q
self.prog_q = prog_q
self._cancel = False
# Integrity mode: aria2 hash-checks every file's pieces and repairs
# them (catches same-size corruption size-based selection misses).
self.check_integrity = check_integrity
# Write a fresh Config.wtf on a reconcile.
self.overwrite_config = overwrite_config
def cancel(self):
self._cancel = True
def log(self, msg: str, tag: str = ""):
self.log_q.put((msg, tag))
def progress(self, value: float, label: str = "", status: str | None = None):
# status (when given) updates the big status line; None leaves it as-is.
self.prog_q.put((value, label, status))
def build_tweaks(self, buf, tweaks: dict | None = None):
if tweaks is None:
tweaks = load_tweaks_config()
fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"])
fov = fov_deg * (math.pi / 180.0)
flags = struct.unpack_from("<H", buf, 0x126)[0] | 0x20
nameplate = float(tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"]))
far_clip = float(tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"]))
frill = float(tweaks.get("frillDistance", TWEAKS_DEFAULTS["frillDistance"]))
cam_dist = float(tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"]))
snd_bg = 0x27 if tweaks.get("soundInBackground", TWEAKS_DEFAULTS["soundInBackground"]) else 0x14
always_loot = tweaks.get("alwaysAutoLoot", TWEAKS_DEFAULTS["alwaysAutoLoot"])
locale = tweaks.get("locale", TWEAKS_DEFAULTS["locale"])
# fmt: off
return [
("gameLanguage", "bytes", None, locale_patches(locale)),
("largeAddress", "uint16", 0x126, flags),
("fieldOfView", "float", 0x4089b4, fov),
("cameraDistance", "float", 0x4089a4, cam_dist),
("farClip", "float", 0x40fed8, far_clip),
("frillDistance", "float", 0x467958, frill),
("nameplateRange", "float", 0x40c448, nameplate),
("soundInBackground", "int8", 0x3a4869, snd_bg),
("alwaysAutoLoot", "bytes", None, [
(0x0c1ecf, bytes([0x75 if always_loot else 0x74])),
(0x0c2b25, bytes([0x75 if always_loot else 0x74])),
]),
# cameraSkipFix is baked into the torrent's WoW.exe, so we don't
# apply it. skillUiGateHijack and octowowUrlAllowlist below are
# baked in too, but the official launcher still applies these 2
# specific patches, so we mirror it in case the Octo devs drop them
# from WoW.exe again.
("octowowUrlAllowlist", "bytes", None, [
(0x45ccd8, bytes([
0x6f,0x63,0x74,0x6f,0x77,0x6f,0x77,0x2e,0x73,0x74,
0x00,0x00,0x00,0x00,0x00,0x00,
])),
]),
("skillUiGateHijack", "bytes", None, [
(0x002ddf90, bytes([
0x55,0x8b,0xec,0x83,0xec,0x08,0x53,0x56,0x57,0x8b,0x3d,0x60,0xab,0xce,0x00,0x83,
0xff,0xff,0x89,0x55,0xfc,0x89,0x4d,0xf8,0x74,0x79,0x8b,0x75,0x08,0x8b,0x15,0x58,
0xab,0xce,0x00,0x8b,0xc7,0x23,0xc6,0x8d,0x04,0x40,0x8b,0x4c,0x82,0x08,0xf6,0xc1,
0x01,0x8d,0x44,0x82,0x04,0x75,0x04,0x85,0xc9,0x75,0x05,0x33,0xc9,0x8d,0x49,0x00,
0xf6,0xc1,0x01,0x75,0x4e,0x85,0xc9,0x74,0x4a,0x39,0x31,0x74,0x13,0x8b,0xc7,0x23,
0xc6,0x8d,0x04,0x40,0x8d,0x04,0x82,0x8b,0x00,0x03,0xc1,0x8b,0x48,0x04,0xeb,0xe0,
0x8b,0x59,0x1c,0x8b,0x71,0x18,0x33,0xff,0x85,0xdb,0x7e,0x27,0x8d,0x64,0x24,0x00,
0x8b,0x4e,0x0c,0x8b,0x56,0x08,0x6a,0x00,0x6a,0x00,0x51,0x8b,0x4d,0xf8,0x52,0x8b,
0x55,0xfc,0xe8,0xb9,0xfd,0xff,0xff,0x84,0xc0,0x75,0x13,0x47,0x83,0xc6,0x20,0x3b,
0xfb,0x7c,0xdd,0x5f,0x5e,0x33,0xc0,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x5f,0x8b,
0xc6,0x5e,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x90,0x90,0x90,0x90,0x90,0x90,0x90,
])),
]),
]
# fmt: on
def patch_exe(self, tweaks: dict | None = None):
exe = os.path.join(self.out_dir, "WoW.exe")
if not os.path.exists(exe):
raise RuntimeError(f"WoW.exe not found in {self.out_dir}")
self.log("\nApplying binary tweaks to WoW.exe…")
# Patch the pristine (unpatched) base rather than the on-disk exe, so a
# re-patch (tweak or language change) never stacks on patched bytes.
buf = bytearray(read_pristine_wow(self.out_dir))
for label, kind, offset, value in self.build_tweaks(buf, tweaks):
self.log(f" {label}", "dim")
if kind == "float":
struct.pack_into("<f", buf, offset, value)
elif kind == "int8":
struct.pack_into("<b", buf, offset, value)
elif kind == "uint16":
struct.pack_into("<H", buf, offset, value)
elif kind == "bytes":
for off, data in value:
buf[off: off + len(data)] = data
with open(exe, "wb") as f:
f.write(buf)
self.log("WoW.exe patched.", "ok")
def run(self):
# The selection is recomputed here from the live torrent so an
# interrupted sync always resumes against the current file set.
try:
self.log("\nStarting client sync…\n", "acct")
self.progress(0.0, "Preparing…")
raw, files = fetch_torrent()
version = torrent_version(raw)
# aria2's saved control state pins a torrent revision and its
# completed pieces. When the torrent was re-rolled (new identity) or
# the folder changed, that state is stale — an 'info hash mismatch'
# error, or skipped re-downloads. Clear it and (for a new revision)
# drop wrong-sized files so they re-fetch clean.
cfg = load_config()
stale = (cfg.get("active_torrent_hash") != version or
cfg.get("active_client_dir") != os.path.abspath(self.out_dir))
if stale:
clear_torrent_resume_state()
update_config(lambda c: c.update({
"active_torrent_hash": version,
"active_client_dir": os.path.abspath(self.out_dir)}))
# Config.wtf is user game config, not in the torrent — (re)write it
# on a reconcile (overwrite_config), or when missing.
cfg_wtf = os.path.join(self.out_dir, "WTF", "Config.wtf")
if self.overwrite_config or not os.path.exists(cfg_wtf):
write_config_wtf(self.out_dir)
# WoW.exe on disk is patched, so an integrity check always flags it
# and re-fetches its pieces over the network. Restore the cached
# pristine base first: the check then passes with no re-download when
# the client is unchanged (aria2 still repairs it if the torrent's
# WoW.exe genuinely changed). It's re-patched after the check.
wow_path = os.path.join(self.out_dir, "WoW.exe")
if (self.check_integrity and os.path.exists(PRISTINE_WOW_PATH)
and os.path.exists(wow_path)):
shutil.copyfile(PRISTINE_WOW_PATH, wow_path)
# Put back any file a prior run shielded but couldn't restore (crash
# mid-sync), so its version isn't stranded as a .octobak.
recover_protected_files(self.out_dir, files)
# speech.MPQ is left unverified/un-updated when the user keeps a
# custom one (Settings → Ignore speech.mpq).
ignore_speech = bool(load_config().get("ignore_speech", False))
if self.check_integrity:
# Full piece-hash verify + repair of every non-mod file — aria2
# re-hashes them and re-fetches only the bad/missing pieces.
need, missing = torrent_all_selection(
self.out_dir, files, ignore_speech=ignore_speech), False
else:
need, missing = torrent_selection(
self.out_dir, files, drop_mismatched=stale,
ignore_speech=ignore_speech)
# A file the user deleted leaves its pieces marked done in the
# control file, so aria2 skips it forever — clear resume state.
if missing:
clear_torrent_resume_state()
wow_downloaded = any(files[i - 1][0] == ["WoW.exe"] for i in need)
if need:
self.log(
(f"Verifying {len(need)} file(s) via torrent…"
if self.check_integrity
else f"Syncing {len(need)} file(s) via torrent…"), "acct")
# A reconcile runs two aria2 phases: it hash-checks every file
# (p["checking"]), then fetches the bad/missing pieces. Flip the
# status from "Verifying" to "Updating" between them so it doesn't
# read "Verifying" while files are actually being updated. Only
# sent on a phase change.
_phase = {"status": None}
def _prog(p):
label = f"{fmt_size(p['done'])} / {fmt_size(p['total'])}"
if p["bps"]:
label += "" + fmt_speed(p["bps"])
frac = p["done"] / p["total"] if p["total"] else 0.0
status = None
if self.check_integrity:
want = ("Verifying game files…" if p["checking"]
else "Updating game files…")
if want != _phase["status"]:
_phase["status"] = status = want
self.progress(min(frac, 1.0), label, status)
# Excluded files (mod-owned + kept speech.MPQ) can still be
# rewritten by aria2 through a shared torrent piece. Move them
# aside for the sync and put them back after, so the Mods tab /
# custom speech stays authoritative.
shielded = shield_protected_files(self.out_dir, files,
ignore_speech)
try:
run_aria2c(self.out_dir, select_files=need,
check_integrity=self.check_integrity,
on_progress=_prog,
should_cancel=lambda: self._cancel,
log_fn=self.log)
finally:
for name in unshield_protected_files(shielded):
self.log(f" kept mod file: {name}", "dim")
else:
self.log("All game files already present.", "dim")
if self._cancel:
self.log("\nUpdate cancelled.", "err")
self.progress(0.0, "Cancelled")
self.log_q.put(("__ERROR__", ""))
return
self.progress(1.0, "Verifying…")
if not torrent_tree_intact(self.out_dir, files,
ignore_speech=ignore_speech):
self.log("\n✗ Download incomplete — click Update to finish.", "err")
self.log_q.put(("__ERROR__", ""))
return
self.log("\nDownload complete.", "ok")
remove_wdb(self.out_dir)
# Drop legacy leftovers the current torrent no longer ships
for gone in prune_stale_client_files(self.out_dir, files):
self.log(f"Removed legacy file: {gone}", "dim")
# Cache the fresh pristine exe, then patch WoW.exe from that
# clean base — but only when the sync actually (re)downloaded it.
refresh_pristine_wow(self.out_dir)
if wow_downloaded:
self.progress(1.0, "Patching…")
self.patch_exe()
else:
self.log("\nWoW.exe unchanged — skipping patch.", "dim")
self.progress(1.0, "")
self.log("\n✓ Everything is up to date!", "ok")
client_ver = get_client_version(self.out_dir)
if client_ver:
self.log(f"Client version: {client_ver}", "dim")
self.log_q.put((f"__VERSION__{client_ver}", ""))
else:
self.log("Could not read client version from WoW.exe", "dim")
self.log_q.put(("__DONE__", ""))
except Exception as e:
self.log(f"\n{e}", "err")
self.progress(0.0, "")
self.log_q.put(("__ERROR__", ""))
+3873
View File
File diff suppressed because it is too large Load Diff