more refactoring , more unit tests...sigh

This commit is contained in:
Microchip
2026-09-26 13:59:56 -04:00
parent b5a40903ff
commit 8ed33d52e3
14 changed files with 2092 additions and 516 deletions
+31
View File
@@ -0,0 +1,31 @@
Refactor v2
Copy these four files into src/:
torrent.py
client_sync.py
client_patch.py
mpq_patches.py
UI imports:
from .client_sync import VerifyWorker, UpdateWorker
from .torrent import stop_aria2c
If the UI uses optional MPQ helpers, import them from .mpq_patches.
Validated in this archive:
- all four generated modules compile
- clear_torrent_resume_state exists in torrent.py
- run_aria2c exists in torrent.py
- stop_aria2c and parse_aria_progress exist in torrent.py
- VerifyWorker and UpdateWorker exist in client_sync.py
- patch_exe exists in client_patch.py
- download_mpq_patch exists in mpq_patches.py
The original supplied torrent.py referenced some names without defining/importing
them there. This refactor assigns those existing project symbols as follows:
.mods: MODS_REGISTRY
.config: TWEAKS_DEFAULTS, load_tweaks_config
.client: _LOCALE_ASSERT_OFFSET, get_client_version, remove_wdb
If those names currently live in different sibling modules in your project,
change those import lines to match their actual owners.
+119
View File
@@ -0,0 +1,119 @@
"""Pristine WoW.exe caching and binary tweak application."""
import math
import os
import shutil
import struct
from .app_log import log
from .config import APP_DATA_DIR
from .client import _LOCALE_ASSERT_OFFSET, locale_patches, TWEAKS_DEFAULTS, load_tweaks_config
PRISTINE_WOW_PATH = os.path.join(APP_DATA_DIR, "base-WoW.exe")
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 build_tweaks(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(client_dir: str, tweaks: dict | None = None, log_fn=log):
exe = os.path.join(client_dir, "WoW.exe")
if not os.path.exists(exe):
raise RuntimeError(f"WoW.exe not found in {client_dir}")
log_fn("\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(client_dir))
for label, kind, offset, value in build_tweaks(buf, tweaks):
log_fn(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)
log_fn("WoW.exe patched.", "ok")
+305
View File
@@ -0,0 +1,305 @@
"""High-level client verification and update workers."""
import os
import queue
import shutil
from .client import get_client_version, remove_wdb, write_config_wtf
from .client_patch import PRISTINE_WOW_PATH, patch_exe, refresh_pristine_wow
from .config import load_config, update_config
from .torrent import (
clear_torrent_resume_state,
fetch_torrent,
recover_protected_files,
run_aria2c,
shield_protected_files,
torrent_all_selection,
torrent_selection,
torrent_tree_intact,
torrent_version,
unshield_protected_files,
)
def fmt_size(value: float) -> str:
value = float(value)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(value) < 1024.0 or unit == "TiB":
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
value /= 1024.0
def fmt_speed(value: float) -> str:
return f"{fmt_size(value)}/s"
# 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
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 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…")
patch_exe(self.out_dir, log_fn=self.log)
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__", ""))
+2
View File
@@ -46,6 +46,8 @@ APP_CONFIG_DIR = user_config_dir()
CONFIG_FILE = os.path.join(APP_CONFIG_DIR, "config.json") # TODO: Change this to point to ~/.config/OctoUpdater
# TODO: Create the config directories on a linux host if they don't exist
# First-run default game folder, anchored to the app dir (not the CWD).
DEFAULT_GAME_DIR = os.path.join(APP_DIR, "OctoWoW") # NOTE: May need to change later to point to other dir
+75
View File
@@ -0,0 +1,75 @@
"""Optional MPQ content downloads using HTTPS and SHA-256 sidecars."""
import hashlib
import os
import urllib.request
from .config import UA, DOWNLOAD_TIMEOUT, ensure_dir
from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
# 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=DOWNLOAD_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"]))
-1
View File
@@ -18,7 +18,6 @@ import queue
from functools import cache
from pathlib import Path
from .config import UA
+57 -1
View File
@@ -4,7 +4,10 @@ import shutil
import subprocess
import sys
from pathlib import Path
import hashlib
import platform
#from .config import APP_DATA_DIR, DOWNLOAD_TIMEOUT, ensure_dir
from .app_log import log
# Windows: %LOCALAPPDATA%\octo-updater\
@@ -143,4 +146,57 @@ def display_info() -> dict:
# NOTE: Returns a path to an aria2c executable , otherwise returns none
def find_aria2c() -> str | None:
return shutil.which("aria2c")
# NOTE: Function that installs the exe for aria2c on windows devices, takes path as input , returns
# path to aria2c exe
def install_aria2c_windows( APP_DATA_DIR,
ALLOWED_DOWNLOAD_HOSTS,
log_fn) -> Path:
ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe")
ARIA2_ZIP_SHA256 = "35f6514cc5dd7e98a87b3c4c2d25a0754b9b063dbe59bc0f22d483464f61e5b6"
ARIA2_ZIP_URL = (
"https://github.com/aria2/aria2/releases/download/"
"release-1.37.0/aria2-1.37.0-win-32bit-build1.zip"
)
#req = urllib.request.Request(ARIA2_ZIP_URL, headers={"User-Agent": UA})
req = urllib.request.Request(ARIA2_ZIP_URL)
log_fn("Fetching aria2c (one-time, ~2.5 MB)…", "acct")
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
+8
View File
@@ -0,0 +1,8 @@
Run from the project root:
python3 -m pytest -q
These tests use pytest, tmp_path, and unittest.mock. Network access and aria2c
execution are mocked; no real torrent/MPQ downloads are performed.
Place these four files under your existing src/tests/ directory.
+89
View File
@@ -0,0 +1,89 @@
import struct
from unittest.mock import patch
from src import client_patch
def test_read_pristine_prefers_cached_copy(tmp_path):
cache = tmp_path / "base-WoW.exe"
cache.write_bytes(b"cached")
client = tmp_path / "client"
client.mkdir()
(client / "WoW.exe").write_bytes(b"live")
with patch.object(client_patch, "PRISTINE_WOW_PATH", str(cache)):
assert client_patch.read_pristine_wow(str(client)) == b"cached"
def test_read_pristine_falls_back_to_live_exe(tmp_path):
client = tmp_path / "client"
client.mkdir()
(client / "WoW.exe").write_bytes(b"live")
with patch.object(client_patch, "PRISTINE_WOW_PATH",
str(tmp_path / "missing")):
assert client_patch.read_pristine_wow(str(client)) == b"live"
def test_refresh_pristine_wow_copies_when_assert_byte_matches(tmp_path):
client = tmp_path / "client"
client.mkdir()
exe = client / "WoW.exe"
exe.write_bytes(b"\xa1rest")
cache = tmp_path / "base.exe"
with patch.object(client_patch, "_LOCALE_ASSERT_OFFSET", 0), \
patch.object(client_patch, "PRISTINE_WOW_PATH", str(cache)):
client_patch.refresh_pristine_wow(str(client))
assert cache.read_bytes() == b"\xa1rest"
def test_build_tweaks_contains_expected_core_tweaks():
# Large enough for the uint16 read at 0x126.
buf = bytearray(0x200)
tweaks = {
"fieldOfView": 90,
"nameplateRange": 40,
"farClip": 500,
"frillDistance": 100,
"cameraDistance": 30,
"soundInBackground": True,
"alwaysAutoLoot": True,
"locale": "enUS",
}
with patch.object(client_patch, "locale_patches", return_value=[]):
result = client_patch.build_tweaks(buf, tweaks)
by_name = {item[0]: item for item in result}
assert "fieldOfView" in by_name
assert "largeAddress" in by_name
assert "alwaysAutoLoot" in by_name
assert by_name["largeAddress"][3] & 0x20
def test_patch_exe_writes_supported_patch_types(tmp_path):
client = tmp_path / "client"
client.mkdir()
exe = client / "WoW.exe"
exe.write_bytes(b"\x00" * 32)
fake_tweaks = [
("f", "float", 0, 1.5),
("i8", "int8", 4, 7),
("u16", "uint16", 6, 0x1234),
("raw", "bytes", None, [(8, b"AB")]),
]
with patch.object(client_patch, "read_pristine_wow",
return_value=b"\x00" * 32), \
patch.object(client_patch, "build_tweaks",
return_value=fake_tweaks):
client_patch.patch_exe(str(client), log_fn=lambda *a: None)
data = exe.read_bytes()
assert struct.unpack_from("<f", data, 0)[0] == 1.5
assert struct.unpack_from("<b", data, 4)[0] == 7
assert struct.unpack_from("<H", data, 6)[0] == 0x1234
assert data[8:10] == b"AB"
+79
View File
@@ -0,0 +1,79 @@
import os
import queue
from unittest.mock import Mock, patch
from src import client_sync
def test_fmt_size_and_speed():
assert client_sync.fmt_size(1024) == "1.0KiB"
assert client_sync.fmt_speed(1024) == "1.0KiB/s"
def test_prune_stale_client_files_removes_known_archive(tmp_path):
data = tmp_path / "Data"
data.mkdir()
p = data / "patch-6.mpq"
legacy_size = client_sync._LEGACY_ARCHIVES["patch-6.mpq"]
p.write_bytes(b"x")
# Avoid allocating a 451 MB fixture.
with patch.object(os.path, "getsize", return_value=legacy_size):
removed = client_sync.prune_stale_client_files(str(tmp_path), [])
assert "patch-6.mpq" in removed
assert not p.exists()
def test_prune_keeps_archive_when_current_torrent_contains_it(tmp_path):
data = tmp_path / "Data"
data.mkdir()
p = data / "patch-6.mpq"
p.write_bytes(b"x")
files = [(["Data", "patch-6.mpq"], 1)]
removed = client_sync.prune_stale_client_files(str(tmp_path), files)
assert removed == []
assert p.exists()
def test_verify_worker_reports_up_to_date(tmp_path):
(tmp_path / "WoW.exe").write_bytes(b"x")
log_q, prog_q = queue.Queue(), queue.Queue()
worker = client_sync.VerifyWorker(str(tmp_path), log_q, prog_q)
with patch.object(client_sync, "fetch_torrent",
return_value=(b"raw", [(["WoW.exe"], 1)])), \
patch.object(client_sync, "torrent_selection",
return_value=([], False)), \
patch.object(client_sync, "load_config", return_value={}):
worker.run()
messages = []
while not log_q.empty():
messages.append(log_q.get()[0])
assert "__UP_TO_DATE__" in messages
def test_verify_worker_reports_update_needed(tmp_path):
log_q, prog_q = queue.Queue(), queue.Queue()
worker = client_sync.VerifyWorker(str(tmp_path), log_q, prog_q)
with patch.object(client_sync, "fetch_torrent",
return_value=(b"raw", [(["WoW.exe"], 1)])), \
patch.object(client_sync, "torrent_selection",
return_value=([1], True)), \
patch.object(client_sync, "load_config", return_value={}):
worker.run()
messages = []
while not log_q.empty():
messages.append(log_q.get()[0])
assert "__UPDATE_NEEDED__" in messages
def test_update_worker_cancel_sets_flag(tmp_path):
worker = client_sync.UpdateWorker(
str(tmp_path), queue.Queue(), queue.Queue()
)
assert worker._cancel is False
worker.cancel()
assert worker._cancel is True
+68
View File
@@ -0,0 +1,68 @@
import hashlib
from unittest.mock import Mock, patch
import pytest
from src import mpq_patches
def response_cm(data: bytes, length=None):
response = Mock()
response.read.side_effect = [data, b""] if length is not None else None
if length is None:
response.read.return_value = data
response.headers = {"Content-Length": str(length)} if length is not None else {}
cm = Mock()
cm.__enter__ = Mock(return_value=response)
cm.__exit__ = Mock(return_value=False)
return cm
def test_mpq_patch_for_case_insensitive():
assert mpq_patches.mpq_patch_for("PATCH-o.MPQ")["file"] == "patch-O.mpq"
assert mpq_patches.mpq_patch_for("missing.mpq") is None
def test_sha256_file(tmp_path):
p = tmp_path / "x"
p.write_bytes(b"abc")
assert mpq_patches.sha256_file(str(p)) == hashlib.sha256(b"abc").hexdigest()
def test_fetch_mpq_sha256_parses_sidecar():
digest = "a" * 64
with patch.object(
mpq_patches, "secure_urlopen",
return_value=response_cm((digest + " patch.mpq\n").encode())
):
assert mpq_patches.fetch_mpq_sha256("https://example/x") == digest
def test_download_mpq_patch_success(tmp_path):
data = b"payload"
digest = hashlib.sha256(data).hexdigest()
entry = {"url": "https://example/x", "file": "patch.mpq"}
progress = Mock()
with patch.object(mpq_patches, "fetch_mpq_sha256", return_value=digest), \
patch.object(mpq_patches, "secure_urlopen",
return_value=response_cm(data, len(data))):
mpq_patches.download_mpq_patch(entry, str(tmp_path), progress)
assert (tmp_path / "patch.mpq").read_bytes() == data
assert not (tmp_path / "patch.mpq.part").exists()
progress.assert_called()
def test_download_mpq_patch_bad_checksum_removes_part(tmp_path):
data = b"payload"
entry = {"url": "https://example/x", "file": "patch.mpq"}
with patch.object(mpq_patches, "fetch_mpq_sha256", return_value="0" * 64), \
patch.object(mpq_patches, "secure_urlopen",
return_value=response_cm(data, len(data))):
with pytest.raises(RuntimeError, match="checksum"):
mpq_patches.download_mpq_patch(entry, str(tmp_path))
assert not (tmp_path / "patch.mpq").exists()
assert not (tmp_path / "patch.mpq.part").exists()
+888 -4
View File
@@ -1,7 +1,17 @@
import pytest
from pathlib import Path
from src import platform_ops
import hashlib
import io
import os
import zipfile
from pathlib import Path
from unittest.mock import Mock
import pytest
from src import config
from src import net
def test_user_config_dir_windows(monkeypatch):
@@ -27,7 +37,7 @@ def test_user_config_dir_linux_with_xdg_config_home(monkeypatch):
result = platform_ops.user_config_dir()
assert result == Path("/tmp/test-config") / "octo-updater"
assert result == Path("/tmp/test-config") / "OctoUpdater"
def test_user_config_dir_linux_without_xdg_config_home(monkeypatch):
@@ -41,5 +51,879 @@ def test_user_config_dir_linux_without_xdg_config_home(monkeypatch):
result = platform_ops.user_config_dir()
assert result == Path("/home/testuser/.config/octo-updater")
assert result == Path("/home/testuser/.config/OctoUpdater")
# install_aria2c_windows() currently keeps this checksum inside the function,
# so successful tests mock hashlib.sha256() to return the checksum expected by
# the implementation.
ARIA2_EXPECTED_SHA256 = (
"35f6514cc5dd7e98a87b3c4c2d25a075"
"4b9b063dbe59bc0f22d483464f61e5b6"
)
def make_aria2_zip(
exe_data=b"fake aria2c executable",
exe_name="aria2-1.37.0-win-32bit-build1/aria2c.exe",
):
"""Create an in-memory ZIP containing a fake aria2c.exe."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr(exe_name, exe_data)
return buffer.getvalue()
def make_response(data):
"""Create a mocked context-manager HTTP response."""
response = Mock()
response.read.return_value = data
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
return response
def allow_fake_zip_checksum(monkeypatch):
"""
Make hashlib.sha256(...).hexdigest() return the checksum expected by
install_aria2c_windows().
"""
digest = Mock()
digest.hexdigest.return_value = ARIA2_EXPECTED_SHA256
sha_mock = Mock(return_value=digest)
monkeypatch.setattr(
platform_ops.hashlib,
"sha256",
sha_mock,
)
return sha_mock
class TestInstallAria2cWindows:
def test_downloads_and_installs_aria2c(
self,
monkeypatch,
tmp_path,
):
exe_data = b"fake aria2c executable"
zip_data = make_aria2_zip(exe_data)
response = make_response(zip_data)
urlopen_mock = Mock(return_value=response)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
net,
"secure_urlopen",
urlopen_mock,
)
allow_fake_zip_checksum(monkeypatch)
result = platform_ops.install_aria2c_windows()
expected = tmp_path / "aria2c.exe"
assert Path(result) == expected
assert expected.exists()
assert expected.read_bytes() == exe_data
def test_returns_installed_aria2c_path(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
net,
"secure_urlopen",
Mock(return_value=make_response(zip_data)),
)
allow_fake_zip_checksum(monkeypatch)
result = platform_ops.install_aria2c_windows()
assert Path(result) == tmp_path / "aria2c.exe"
def test_download_request_uses_user_agent(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
urlopen_mock = Mock(
return_value=make_response(zip_data)
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
net,
"secure_urlopen",
urlopen_mock,
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
request = urlopen_mock.call_args.args[0]
assert (
request.get_header("User-agent")
== platform_ops.UA
)
def test_download_request_uses_expected_url(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
urlopen_mock = Mock(
return_value=make_response(zip_data)
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
urlopen_mock,
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
request = urlopen_mock.call_args.args[0]
assert request.full_url == (
"https://github.com/aria2/aria2/releases/download/"
"release-1.37.0/"
"aria2-1.37.0-win-32bit-build1.zip"
)
def test_uses_sixty_second_timeout(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
urlopen_mock = Mock(
return_value=make_response(zip_data)
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
urlopen_mock,
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
assert (
urlopen_mock.call_args.kwargs["timeout"]
== 60
)
def test_restricts_download_hosts(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
urlopen_mock = Mock(
return_value=make_response(zip_data)
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
urlopen_mock,
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
assert (
urlopen_mock.call_args.kwargs["allowed_hosts"]
== platform_ops.ALLOWED_DOWNLOAD_HOSTS
)
def test_hashes_downloaded_archive(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
sha_mock = allow_fake_zip_checksum(
monkeypatch
)
platform_ops.install_aria2c_windows()
sha_mock.assert_called_once_with(zip_data)
def test_rejects_checksum_mismatch(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
bad_digest = Mock()
bad_digest.hexdigest.return_value = "0" * 64
monkeypatch.setattr(
platform_ops.hashlib,
"sha256",
Mock(return_value=bad_digest),
)
with pytest.raises(
RuntimeError,
match="aria2 checksum mismatch",
):
platform_ops.install_aria2c_windows()
assert not (
tmp_path / "aria2c.exe"
).exists()
assert not (
tmp_path / "aria2c.exe.part"
).exists()
def test_checksum_failure_happens_before_extraction(
self,
monkeypatch,
tmp_path,
):
zip_data = b"not even a valid zip"
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
bad_digest = Mock()
bad_digest.hexdigest.return_value = "0" * 64
monkeypatch.setattr(
platform_ops.hashlib,
"sha256",
Mock(return_value=bad_digest),
)
# If checksum validation occurs first, we get RuntimeError
# instead of zipfile.BadZipFile.
with pytest.raises(
RuntimeError,
match="aria2 checksum mismatch",
):
platform_ops.install_aria2c_windows()
def test_rejects_archive_without_aria2c(
self,
monkeypatch,
tmp_path,
):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr(
"README.txt",
b"no executable here",
)
zip_data = buffer.getvalue()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
with pytest.raises(
RuntimeError,
match="aria2c.exe not found",
):
platform_ops.install_aria2c_windows()
assert not (
tmp_path / "aria2c.exe"
).exists()
def test_finds_executable_inside_nested_directory(
self,
monkeypatch,
tmp_path,
):
exe_data = b"nested executable"
zip_data = make_aria2_zip(
exe_data=exe_data,
exe_name=(
"aria2-1.37.0-win-32bit-build1/"
"some/nested/path/aria2c.exe"
),
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
result = platform_ops.install_aria2c_windows()
assert Path(result).read_bytes() == exe_data
def test_finds_exe_case_insensitively(
self,
monkeypatch,
tmp_path,
):
exe_data = b"aria binary"
zip_data = make_aria2_zip(
exe_data=exe_data,
exe_name="aria2-build/ARIA2C.EXE",
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
result = platform_ops.install_aria2c_windows()
assert Path(result).read_bytes() == exe_data
def test_only_extracts_aria2c_executable(
self,
monkeypatch,
tmp_path,
):
exe_data = b"aria executable"
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr(
"aria2-build/aria2c.exe",
exe_data,
)
zf.writestr(
"aria2-build/README.txt",
b"readme",
)
zf.writestr(
"aria2-build/other.dll",
b"dll",
)
zip_data = buffer.getvalue()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
assert (
tmp_path / "aria2c.exe"
).read_bytes() == exe_data
assert not (
tmp_path / "README.txt"
).exists()
assert not (
tmp_path / "other.dll"
).exists()
def test_creates_app_data_directory(
self,
monkeypatch,
tmp_path,
):
app_dir = tmp_path / "new-app-data"
zip_data = make_aria2_zip()
ensure_mock = Mock(
side_effect=lambda path: os.makedirs(
path,
exist_ok=True,
)
)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(app_dir),
)
monkeypatch.setattr(
platform_ops,
"ensure_dir",
ensure_mock,
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
ensure_mock.assert_called_once_with(
str(app_dir)
)
assert (
app_dir / "aria2c.exe"
).exists()
def test_writes_to_part_file_before_replace(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
real_replace = os.replace
replace_mock = Mock(
side_effect=real_replace
)
monkeypatch.setattr(
platform_ops.os,
"replace",
replace_mock,
)
platform_ops.install_aria2c_windows()
replace_mock.assert_called_once_with(
str(tmp_path / "aria2c.exe.part"),
str(tmp_path / "aria2c.exe"),
)
def test_part_file_removed_after_success(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
assert not (
tmp_path / "aria2c.exe.part"
).exists()
assert (
tmp_path / "aria2c.exe"
).exists()
def test_replaces_existing_aria2c(
self,
monkeypatch,
tmp_path,
):
old_exe = tmp_path / "aria2c.exe"
old_exe.write_bytes(b"old executable")
new_data = b"new executable"
zip_data = make_aria2_zip(new_data)
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
platform_ops.install_aria2c_windows()
assert old_exe.read_bytes() == new_data
def test_logs_fetch_and_ready_messages(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
allow_fake_zip_checksum(monkeypatch)
logger = Mock()
platform_ops.install_aria2c_windows(
log_fn=logger,
)
assert logger.call_count == 2
logger.assert_any_call(
"Fetching aria2c (one-time, ~2.5 MB)…",
"acct",
)
logger.assert_any_call(
"aria2c ready.",
"ok",
)
def test_does_not_log_ready_on_checksum_failure(
self,
monkeypatch,
tmp_path,
):
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
bad_digest = Mock()
bad_digest.hexdigest.return_value = "0" * 64
monkeypatch.setattr(
platform_ops.hashlib,
"sha256",
Mock(return_value=bad_digest),
)
logger = Mock()
with pytest.raises(RuntimeError):
platform_ops.install_aria2c_windows(
log_fn=logger,
)
logger.assert_any_call(
"Fetching aria2c (one-time, ~2.5 MB)…",
"acct",
)
assert (
("aria2c ready.", "ok")
not in [
call.args
for call in logger.call_args_list
]
)
def test_propagates_network_failure(
self,
monkeypatch,
tmp_path,
):
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
side_effect=OSError(
"network unavailable"
)
),
)
with pytest.raises(
OSError,
match="network unavailable",
):
platform_ops.install_aria2c_windows()
assert not (
tmp_path / "aria2c.exe"
).exists()
assert not (
tmp_path / "aria2c.exe.part"
).exists()
def test_propagates_invalid_zip_error(
self,
monkeypatch,
tmp_path,
):
invalid_zip = b"not a zip archive"
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(tmp_path),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(
invalid_zip
)
),
)
# Allow the invalid data through checksum verification so
# ZipFile itself gets exercised.
allow_fake_zip_checksum(monkeypatch)
with pytest.raises(
zipfile.BadZipFile
):
platform_ops.install_aria2c_windows()
assert not (
tmp_path / "aria2c.exe"
).exists()
def test_does_not_create_destination_before_validation(
self,
monkeypatch,
tmp_path,
):
app_dir = tmp_path / "app-data"
zip_data = make_aria2_zip()
monkeypatch.setattr(
config,
"APP_DATA_DIR",
str(app_dir),
)
monkeypatch.setattr(
platform_ops,
"secure_urlopen",
Mock(
return_value=make_response(zip_data)
),
)
bad_digest = Mock()
bad_digest.hexdigest.return_value = "0" * 64
monkeypatch.setattr(
platform_ops.hashlib,
"sha256",
Mock(return_value=bad_digest),
)
with pytest.raises(RuntimeError):
platform_ops.install_aria2c_windows()
# ensure_dir() occurs after checksum + ZIP validation in the
# implementation shown, so a rejected download should not
# create the application directory.
assert not app_dir.exists()
+352
View File
@@ -0,0 +1,352 @@
import hashlib
import os
from unittest.mock import Mock, patch
import pytest
from src import torrent
from pathlib import Path
def bencode(value):
if isinstance(value, int):
return b"i" + str(value).encode() + b"e"
if isinstance(value, bytes):
return str(len(value)).encode() + b":" + value
if isinstance(value, list):
return b"l" + b"".join(bencode(v) for v in value) + b"e"
if isinstance(value, dict):
return b"d" + b"".join(bencode(k) + bencode(v)
for k, v in sorted(value.items())) + b"e"
raise TypeError(type(value))
def test_bdecode_nested_values():
raw = bencode({b"a": 3, b"b": [b"x", 7]})
decoded, pos = torrent._bdecode(raw)
assert decoded == {b"a": 3, b"b": [b"x", 7]}
assert pos == len(raw)
def test_torrent_version_is_sha1_of_raw_bytes():
raw = b"torrent bytes"
assert torrent.torrent_version(raw) == hashlib.sha1(raw).hexdigest()
def test_fetch_torrent_extracts_paths_and_lengths():
raw = bencode({
b"info": {
b"files": [
{b"length": 10, b"path": [b"WoW.exe"]},
{b"length": 20, b"path": [b"Data", b"patch.MPQ"]},
]
}
})
response = Mock()
response.read.return_value = raw
cm = Mock()
cm.__enter__ = Mock(return_value=response)
cm.__exit__ = Mock(return_value=False)
with patch.object(torrent, "secure_urlopen", return_value=cm):
got_raw, files = torrent.fetch_torrent()
assert got_raw == raw
assert files == [(["WoW.exe"], 10), (["Data", "patch.MPQ"], 20)]
def test_torrent_selection_finds_missing_and_wrong_size(tmp_path):
files = [(["a.bin"], 3), (["b.bin"], 4), (["c.bin"], 5)]
(tmp_path / "a.bin").write_bytes(b"abc")
(tmp_path / "b.bin").write_bytes(b"xx")
with patch.object(torrent, "_torrent_skip", return_value=False):
need, missing = torrent.torrent_selection(str(tmp_path), files)
assert need == [2, 3]
assert missing is True
def test_torrent_selection_removes_oversized_file(tmp_path):
p = tmp_path / "a.bin"
p.write_bytes(b"too long")
with patch.object(torrent, "_torrent_skip", return_value=False):
need, missing = torrent.torrent_selection(
str(tmp_path), [(["a.bin"], 2)]
)
assert need == [1]
assert missing is False
assert not p.exists()
def test_torrent_all_selection_skips_protected(tmp_path):
files = [(["a"], 1), (["b"], 1), (["c"], 1)]
with patch.object(torrent, "_torrent_skip",
side_effect=[False, True, False]):
assert torrent.torrent_all_selection(str(tmp_path), files) == [1, 3]
def test_torrent_tree_intact(tmp_path):
(tmp_path / "a").write_bytes(b"123")
with patch.object(torrent, "_torrent_skip", return_value=False):
assert torrent.torrent_tree_intact(
str(tmp_path), [(["a"], 3)]
)
assert not torrent.torrent_tree_intact(
str(tmp_path), [(["a"], 4)]
)
def test_shield_and_unshield_existing_file(tmp_path):
original = tmp_path / "mod.dll"
original.write_bytes(b"ours")
files = [(["mod.dll"], 4)]
with patch.object(torrent, "_torrent_skip", return_value=True):
shielded = torrent.shield_protected_files(
str(tmp_path), files, False
)
assert not original.exists()
assert (tmp_path / "mod.dll.octobak").exists()
# Simulate aria2 creating/replacing the original path.
original.write_bytes(b"aria")
restored = torrent.unshield_protected_files(shielded)
assert original.read_bytes() == b"ours"
assert restored == ["mod.dll"]
def test_unshield_removes_stub_for_previously_absent_file(tmp_path):
p = tmp_path / "mod.dll"
shielded = [(str(p), None)]
p.write_bytes(b"stub")
assert torrent.unshield_protected_files(shielded) == []
assert not p.exists()
def test_recover_protected_files(tmp_path):
p = tmp_path / "x.dll"
bak = tmp_path / "x.dll.octobak"
bak.write_bytes(b"saved")
torrent.recover_protected_files(str(tmp_path), [(["x.dll"], 5)])
assert p.read_bytes() == b"saved"
assert not bak.exists()
@pytest.mark.parametrize(
("line", "checking", "progress", "bps"),
[
("1MiB/4MiB(25%) DL:2MiB", False, .25, 2 * 1024**2),
("0B/4MiB(0%) [Checksum:#abc 2MiB/4MiB(50%)]",
True, .50, 0),
],
)
def test_parse_aria_progress(line, checking, progress, bps):
got = torrent.parse_aria_progress(line)
assert got["checking"] is checking
assert got["progress"] == progress
assert got["bps"] == bps
def test_parse_aria_progress_ignores_non_progress_line():
assert torrent.parse_aria_progress("download started") is None
def test_clear_torrent_resume_state(tmp_path):
(tmp_path / "a.aria2").write_text("x")
(tmp_path / "b.torrent").write_text("x")
(tmp_path / "keep.txt").write_text("x")
with patch.object(torrent, "TORRENT_STAGING_DIR", str(tmp_path)):
torrent.clear_torrent_resume_state()
assert not (tmp_path / "a.aria2").exists()
assert not (tmp_path / "b.torrent").exists()
assert (tmp_path / "keep.txt").exists()
def test_ensure_aria2c_returns_existing_binary(tmp_path):
exe = tmp_path / "aria2c.exe"
exe.write_bytes(b"x")
with patch.object(torrent, "ARIA2C_PATH", str(exe)):
assert torrent.ensure_aria2c() == str(exe)
def test_ensure_aria2c_rejects_bad_checksum(tmp_path):
response = Mock()
response.read.return_value = b"not the expected archive"
cm = Mock()
cm.__enter__ = Mock(return_value=response)
cm.__exit__ = Mock(return_value=False)
with patch.object(torrent, "ARIA2C_PATH", str(tmp_path / "aria2c.exe")), \
patch.object(torrent, "secure_urlopen", return_value=cm):
with pytest.raises(RuntimeError, match="checksum mismatch"):
torrent.ensure_aria2c(log_fn=Mock())
class TestEnsureAria2c:
def test_returns_existing_aria2c(self, monkeypatch):
"""An aria2c already discoverable on PATH should be used directly."""
expected = Path("/usr/bin/aria2c")
find_mock = Mock(return_value=expected)
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
result = torrent.ensure_aria2c()
assert result == expected
find_mock.assert_called_once_with()
def test_returns_existing_windows_aria2c(self, monkeypatch):
"""A Windows aria2c.exe discovered on PATH should also be returned."""
expected = Path(r"C:\Program Files\aria2\aria2c.exe")
find_mock = Mock(return_value=expected)
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
result = torrent.ensure_aria2c()
assert result == expected
find_mock.assert_called_once_with()
def test_falls_back_to_windows_installer(self, monkeypatch):
"""Missing aria2c should invoke install_aria2c_windows()."""
expected = Path(r"C:\AppData\OctoUpdater\aria2c.exe")
find_mock = Mock(return_value=None)
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
# ensure_aria2c imports install_aria2c_windows inside the function,
# so patch the source module rather than torrent.
from src import platform_ops
installer = Mock(return_value=expected)
monkeypatch.setattr(
platform_ops,
"install_aria2c_windows",
installer,
)
result = torrent.ensure_aria2c()
assert result == expected
find_mock.assert_called_once_with()
installer.assert_called_once()
def test_passes_custom_logger_to_installer(self, monkeypatch):
"""The caller's logger should be forwarded to the installer."""
monkeypatch.setattr(
torrent,
"find_aria2c",
Mock(return_value=None),
)
from src import platform_ops
logger = Mock()
installer = Mock(return_value=Path("aria2c.exe"))
monkeypatch.setattr(
platform_ops,
"install_aria2c_windows",
installer,
)
torrent.ensure_aria2c(log_fn=logger)
installer.assert_called_once_with(log_fn=logger)
def test_does_not_install_when_aria2c_exists(self, monkeypatch):
"""Finding aria2c should short-circuit before installation."""
expected = Path("/usr/bin/aria2c")
monkeypatch.setattr(
torrent,
"find_aria2c",
Mock(return_value=expected),
)
from src import platform_ops
installer = Mock()
monkeypatch.setattr(
platform_ops,
"install_aria2c_windows",
installer,
)
result = torrent.ensure_aria2c()
assert result == expected
installer.assert_not_called()
def test_propagates_find_aria2c_error(self, monkeypatch):
"""Unexpected errors from executable discovery should propagate."""
find_mock = Mock(
side_effect=OSError("PATH lookup failed")
)
monkeypatch.setattr(
torrent,
"find_aria2c",
find_mock,
)
with pytest.raises(
OSError,
match="PATH lookup failed",
):
torrent.ensure_aria2c()
def test_propagates_installer_error(self, monkeypatch):
"""Installation failures should propagate to the caller."""
monkeypatch.setattr(
torrent,
"find_aria2c",
Mock(return_value=None),
)
from src import platform_ops
installer = Mock(
side_effect=RuntimeError("download failed")
)
monkeypatch.setattr(
platform_ops,
"install_aria2c_windows",
installer,
)
with pytest.raises(
RuntimeError,
match="download failed",
):
torrent.ensure_aria2c()
def test_empty_string_from_find_triggers_install(self, monkeypatch):
"""Any false-y discovery result should trigger installation."""
monkeypatch.setattr(
torrent,
"find_aria2c",
Mock(return_value=""),
)
from src import platform_ops
expected = Path("aria2c.exe")
installer = Mock(return_value=expected)
monkeypatch.setattr(
platform_ops,
"install_aria2c_windows",
installer,
)
result = torrent.ensure_aria2c()
assert result == expected
installer.assert_called_once()
+19 -510
View File
@@ -1,81 +1,43 @@
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
import json
"""Torrent metadata, file selection, protected-file handling, and aria2c execution."""
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
import urllib.request
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 .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT, ensure_dir
from .mods import MODS_REGISTRY
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")
# NOTE: Windows specific ZIP file
# 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"
# NOTE: Windows sha hash for exe
#ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe")
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):
ARIA2C_PATH = find_aria2c()
if 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
return platform_ops.install_aria2c_windows( APP_DATA_DIR=APP_DATA_DIR,
ALLOWED_DOWNLOAD_HOSTS=ALLOWED_DOWNLOAD_HOSTS,
log_fn=log)
def _bdecode(buf: bytes, pos: int = 0):
@@ -270,135 +232,6 @@ def torrent_tree_intact(client_dir: str, files, ignore_speech=False) -> bool:
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
@@ -415,32 +248,6 @@ def clear_torrent_resume_state():
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
@@ -607,301 +414,3 @@ def run_aria2c(client_dir, select_files=None, check_integrity=False,
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__", ""))