Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcaedf7f6c |
Regular → Executable
@@ -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
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
@@ -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))
|
||||
@@ -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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
+907
@@ -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__", ""))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user