Files
2026-09-19 12:34:33 -04:00

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__", ""))