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