diff --git a/src/MIGRATION.txt b/src/MIGRATION.txt new file mode 100644 index 0000000..fcaed57 --- /dev/null +++ b/src/MIGRATION.txt @@ -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. diff --git a/src/client_patch.py b/src/client_patch.py new file mode 100644 index 0000000..2958773 --- /dev/null +++ b/src/client_patch.py @@ -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(" 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// 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__", "")) diff --git a/src/config.py b/src/config.py index 9fd6d1e..99d6122 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/mpq_patches.py b/src/mpq_patches.py new file mode 100644 index 0000000..bd4a45c --- /dev/null +++ b/src/mpq_patches.py @@ -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 `.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 `.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 /, 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"])) diff --git a/src/net.py b/src/net.py index 5586896..be3844a 100644 --- a/src/net.py +++ b/src/net.py @@ -18,7 +18,6 @@ import queue from functools import cache from pathlib import Path -from .config import UA diff --git a/src/platform_ops.py b/src/platform_ops.py index 6200360..d5bc782 100644 --- a/src/platform_ops.py +++ b/src/platform_ops.py @@ -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 + + + + diff --git a/src/tests/README.txt b/src/tests/README.txt new file mode 100644 index 0000000..1be0aef --- /dev/null +++ b/src/tests/README.txt @@ -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. diff --git a/src/tests/test_client_patch.py b/src/tests/test_client_patch.py new file mode 100644 index 0000000..92418cd --- /dev/null +++ b/src/tests/test_client_patch.py @@ -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("//…, -# so a junction /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// 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 `.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 `.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 /, 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 /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("