Files
Modernization-Tool/setup_tool_dynamic.py
T

703 lines
27 KiB
Python

import math
import os
import struct
import sys
import tkinter as tk
import types
from tkinter import messagebox
import remote_packages
import setup_tool_dynamic_core as _dynamic_core
# Keep the feature-branch implementation intact and layer only the executable
# normalization policy here. This makes the B-total policy easy to audit and
# keeps every unrelated remote/fallback behavior byte-for-byte unchanged.
from setup_tool_dynamic_core import * # noqa: F401,F403
from setup_tool_dynamic_core import ModernWowSetupTool as _ModernWowSetupToolCore
_CAMERA_REGIONS = (
(
0x02CCD0,
bytes.fromhex(
"55 8b ec 83 ec 10 8d 45 f0 50 33 c9 e8 4f 8f 00 "
"00 50 ff 15 64 f6 7f 00 8b 45 f8 99 2b c2 8b c8 "
"8b 45 fc 99 2b c2 d1 f8 d1 f9 50 51 89 0d 38 4e "
"88 00 a3 3c 4e 88 00 ff 15 5c f6 7f 00 8b e5 5d "
"c3 90 90"
),
bytes.fromhex(
"55 8b 05 48 4e 88 00 8b 0d 44 4e 88 00 e9 33 90 "
"32 00 83 c0 32 83 c1 32 3b 0d a8 eb c4 00 7e 03 "
"83 e9 01 3b 05 ac eb c4 00 7e 03 83 e8 01 83 e9 "
"32 83 e8 32 89 05 48 4e 88 00 89 0d 44 4e 88 00 "
"5d eb 0d"
),
),
(
0x02D326,
bytes.fromhex("8b 45 f0 8b 15"),
bytes.fromhex("e9 b1 8a 32 00"),
),
(
0x02D334,
bytes.fromhex("8b 35 3c 4e 88 00"),
bytes.fromhex("8b 35 48 4e 88 00"),
),
(
0x355D15,
bytes.fromhex(
"cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc "
"cc cc cc cc cc"
),
bytes.fromhex(
"83 f8 32 7d 03 83 c0 01 83 f9 32 7d 03 83 c1 01 "
"e9 b8 6f cd ff"
),
),
(
0x355DDC,
bytes.fromhex(
"cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc "
"cc cc cc cc cc cc cc cc cc cc cc cc cc cc"
),
bytes.fromhex(
"8d 4d f0 51 ff 35 00 4e 88 00 ff 15 50 f6 7f 00 "
"8b 45 f0 8b 15 44 4e 88 00 e9 35 75 cd ff"
),
),
)
_CUSTOM_GLUES_SITES = (
(0x2F113A, 0x5F, 0xEB),
(0x2F113B, 0x5E, 0x19),
(0x2F1158, 0x01, 0x03),
(0x2F11A7, 0x01, 0x03),
(0x2F11F0, 0x5F, 0xEB),
(0x2F11F1, 0x5E, 0xB2),
)
# Numeric fields are intentionally allowed to keep legitimate values already
# selected by Turtle/Octo/community launchers. Safety comes from validating the
# actual managed patch regions plus tight per-field supported ranges; the exact
# source bytes are then snapshotted so the upstream patcher may only preserve
# them or replace them with the Tool's requested value.
_NUMERIC_SITES = (
("fov", 0x4089B4, "FoV", 0.5, 3.5),
("farclip", 0x40FED8, "Farclip", 777.0, 10000.0),
("frill", 0x467958, "Frill Distance", 0.0, 1000.0),
("nameplate", 0x40C448, "Nameplate Distance", 0.0, 150.0),
("maxcam", 0x4089A4, "Max Camera Distance", 1.0, 250.0),
)
_SOUND_SITE = ("sound", 0x435D38, "Sound Channels", 1, 128)
_CLIENT_BUILD_OFFSET = 0x437BFC
_CLIENT_VERSION_OFFSET = 0x437C04
_CLIENT_BUILD = b"5875"
_CLIENT_VERSION = b"1.12.1"
def _strict_verify_mpq(path):
"""Validate classic MPQ headers/table bounds, including user-data wrappers."""
try:
size = os.path.getsize(path)
if size < 32:
raise remote_packages.RemotePackageError(
"Downloaded MPQ is too small to contain a valid header."
)
with open(path, "rb") as handle:
prefix = handle.read(16)
archive_base = 0
if prefix[:4] == b"MPQ\x1B":
if len(prefix) != 16 or size < 48:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has a truncated user-data header."
)
try:
_user_data_size, header_offset, user_header_size = (
struct.unpack_from("<III", prefix, 4)
)
except struct.error as exc:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has a malformed user-data header."
) from exc
if (
user_header_size < 16
or header_offset < user_header_size
or header_offset > size - 32
):
raise remote_packages.RemotePackageError(
"Downloaded MPQ has an invalid nested archive offset."
)
if (
_user_data_size < user_header_size
or _user_data_size > header_offset
):
raise remote_packages.RemotePackageError(
"Downloaded MPQ has an invalid user-data size."
)
archive_base = header_offset
handle.seek(archive_base)
header = handle.read(32)
elif prefix[:4] == b"MPQ\x1A":
handle.seek(0)
header = handle.read(32)
else:
raise remote_packages.RemotePackageError(
"Downloaded file is not a valid MPQ archive."
)
except remote_packages.RemotePackageError:
raise
except OSError as exc:
raise remote_packages.RemotePackageError(
f"Could not inspect downloaded MPQ: {exc}"
) from exc
if len(header) != 32 or header[:4] != b"MPQ\x1A":
raise remote_packages.RemotePackageError(
"Downloaded file is not a valid MPQ archive."
)
try:
(
header_size,
archive_size,
format_version,
sector_size_shift,
hash_table_offset,
block_table_offset,
hash_table_entries,
block_table_entries,
) = struct.unpack_from("<IIHHIIII", header, 4)
except struct.error as exc:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has a truncated header."
) from exc
if header_size < 32 or archive_base + header_size > size:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has an invalid header size."
)
if archive_size < header_size or archive_base + archive_size > size:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has an invalid archive size."
)
if format_version not in (0, 1):
raise remote_packages.RemotePackageError(
f"Downloaded MPQ uses unsupported format version {format_version}."
)
if sector_size_shift == 0 or sector_size_shift > 16:
raise remote_packages.RemotePackageError(
"Downloaded MPQ has an invalid sector-size shift."
)
tables = (
("hash", hash_table_offset, hash_table_entries),
("block", block_table_offset, block_table_entries),
)
for table_name, table_offset, entry_count in tables:
if entry_count <= 0:
raise remote_packages.RemotePackageError(
f"Downloaded MPQ has an empty {table_name} table."
)
table_size = entry_count * 16
if (
table_offset < header_size
or table_offset > archive_size
or table_size > archive_size - table_offset
):
raise remote_packages.RemotePackageError(
f"Downloaded MPQ has an out-of-bounds {table_name} table."
)
def _strict_managed_mpq_is_current(target_dir, mod_id, revision):
if not remote_packages.managed_mod_is_current(target_dir, mod_id, revision):
return False
files = remote_packages._load_managed_manifest(target_dir, mod_id)
if len(files) != 1:
return False
path = os.path.join(target_dir, files[0])
try:
_strict_verify_mpq(path)
return True
except remote_packages.RemotePackageError:
return False
def _strict_managed_mpq_is_usable(target_dir, mod_id):
if not remote_packages.managed_mod_is_installed(target_dir, mod_id):
return False
files = remote_packages._load_managed_manifest(target_dir, mod_id)
if len(files) != 1:
return False
path = os.path.join(target_dir, files[0])
try:
_strict_verify_mpq(path)
return True
except remote_packages.RemotePackageError:
return False
def _install_strict_mpq_runtime_hooks():
"""Enable strict MPQ checks only while the real visual installer is running.
Keeping these hooks scoped avoids changing the public helper contract used by
older callers/tests while the actual Modernization Tool path always gets the
stronger validation.
"""
originals = {
"_verify_mpq": remote_packages._verify_mpq,
"_download_remote_mpq": remote_packages._download_remote_mpq,
"managed_mpq_is_current": remote_packages.managed_mpq_is_current,
"managed_mpq_is_usable": remote_packages.managed_mpq_is_usable,
}
original_download_remote_mpq = originals["_download_remote_mpq"]
def strict_download_remote_mpq(*args, **kwargs):
temp_path = original_download_remote_mpq(*args, **kwargs)
try:
_strict_verify_mpq(temp_path)
except remote_packages.RemotePackageError as exc:
try:
os.remove(temp_path)
except OSError:
pass
label = kwargs.get("label") or "Downloading visual mod"
raise remote_packages.RemoteSourceUnavailable(
f"{label}: remote source returned an invalid MPQ package ({exc})."
) from exc
return temp_path
remote_packages._verify_mpq = _strict_verify_mpq
remote_packages._download_remote_mpq = strict_download_remote_mpq
remote_packages.managed_mpq_is_current = _strict_managed_mpq_is_current
remote_packages.managed_mpq_is_usable = _strict_managed_mpq_is_usable
return originals
def _restore_mpq_runtime_hooks(originals):
for name, value in originals.items():
setattr(remote_packages, name, value)
class ModernWowSetupTool(_ModernWowSetupToolCore):
"""Remote-fallback tool with authoritative, fail-safe Vanilla Tweaks output."""
def _vanilla_tweaks_signature(self):
signature = super()._vanilla_tweaks_signature()
# Keep the B-total policy generation stable for existing test builds.
# Fixed build/version string anchors are no longer part of preflight.
signature["selected_patch_normalization"] = 3
signature["source_fingerprint_policy"] = 1
return signature
def _desired_normalized_values(self):
desired = {
"fov": float(self.vt_fov.get()),
"farclip": float(self.vt_farclip.get()),
"frill": float(self.vt_frill.get()),
"nameplate": float(self.vt_nameplate.get()),
"maxcam": float(self.vt_maxcam.get()),
"sound": int(self.vt_soundchan.get()),
}
ranges = (
("FoV", desired["fov"], 0.5, 3.5),
("Farclip", desired["farclip"], 100.0, 50000.0),
("Frill Distance", desired["frill"], 0.0, 10000.0),
("Nameplate Distance", desired["nameplate"], 1.0, 500.0),
("Max Camera Distance", desired["maxcam"], 1.0, 1000.0),
)
for label, value, minimum, maximum in ranges:
if not math.isfinite(value) or not minimum <= value <= maximum:
raise RuntimeError(
f"{label} value is outside the supported WoW 1.12.1 range."
)
sound_channels = str(desired["sound"]).encode("ascii") + b"\x00"
if not 1 <= desired["sound"] <= 256 or len(sound_channels) > 4:
raise RuntimeError(
"Sound Channels value is outside the supported WoW 1.12.1 range."
)
desired["sound_bytes"] = sound_channels.ljust(4, b"\x00")
return desired
@staticmethod
def _numeric_bytes_from_desired(desired):
return {
"fov": struct.pack("<f", desired["fov"]),
"farclip": struct.pack("<f", desired["farclip"]),
"frill": struct.pack("<f", desired["frill"]),
"nameplate": struct.pack("<f", desired["nameplate"]),
"maxcam": struct.pack("<f", desired["maxcam"]),
"sound": desired["sound_bytes"],
}
@staticmethod
def _validate_client_identity(data):
"""Compatibility no-op: Turtle/Octo may move build/version strings."""
del data
return None
def _capture_source_numeric_states(self, data, desired):
"""Validate tight supported ranges and snapshot the exact source bytes."""
del desired # selected-value validation already ran before this method
snapshot = {}
for key, offset, label, minimum, maximum in _NUMERIC_SITES:
raw = bytes(data[offset:offset + 4])
value = struct.unpack("<f", raw)[0]
if not math.isfinite(value) or not minimum <= value <= maximum:
raise RuntimeError(
f"Unexpected {label} source value at 0x{offset:X}; "
"refusing to alter an unknown client."
)
snapshot[key] = raw
key, offset, label, minimum, maximum = _SOUND_SITE
raw = bytes(data[offset:offset + 4])
text, separator, tail = raw.partition(b"\x00")
if not separator or not text.isdigit() or any(tail):
raise RuntimeError(
f"Unexpected {label} source bytes at 0x{offset:X}; "
"refusing to alter an unknown client."
)
value = int(text)
if not minimum <= value <= maximum:
raise RuntimeError(
f"Unexpected {label} source value at 0x{offset:X}; "
"refusing to alter an unknown client."
)
snapshot[key] = raw
return snapshot
def _validate_staged_numeric_states(self, data, source_states, desired):
"""The upstream patcher may only leave source bytes or write our selection."""
if not isinstance(source_states, dict):
raise RuntimeError(
"Vanilla Tweaks source fingerprint is missing; refusing to normalize."
)
desired_bytes = self._numeric_bytes_from_desired(desired)
staged_sites = [
(key, offset, label)
for key, offset, label, _minimum, _maximum in _NUMERIC_SITES
]
staged_sites.append(_SOUND_SITE[:3])
for key, offset, label in staged_sites:
source_value = source_states.get(key)
if not isinstance(source_value, (bytes, bytearray)) or len(source_value) != 4:
raise RuntimeError(
f"Vanilla Tweaks source fingerprint for {label} is invalid."
)
current = bytes(data[offset:offset + 4])
if current not in (bytes(source_value), desired_bytes[key]):
raise RuntimeError(
f"Unexpected {label} bytes produced by vanilla-tweaks at "
f"0x{offset:X}; refusing to commit a partially trusted EXE."
)
def _validate_vanilla_tweaks_state(
self,
data,
*,
allow_foreign_custom_glues=False,
accepted_custom_glues=None,
):
"""Validate every non-numeric region the B-total policy may rewrite.
Returns a foreign Custom GlueXML byte tuple only during source preflight.
Such a tuple is treated as client-owned code and later restored exactly.
"""
required_size = 0x46795C
if len(data) < required_size:
raise RuntimeError(
"WoW.exe is too small for Vanilla Tweaks safety preflight."
)
quickloot_sites = (
(0x0C1ECF, 0x10),
(0x0C2B25, 0x0B),
)
for offset, displacement in quickloot_sites:
current = bytes(data[offset:offset + 2])
known = (
bytes((0x74, displacement)),
bytes((0x75, displacement)),
b"\x90\x90",
)
if current not in known:
raise RuntimeError(
f"Unexpected QuickLoot bytes at 0x{offset:X}; "
"refusing to alter an unknown client."
)
if data[0x3A4869] not in (0x14, 0x27):
raise RuntimeError(
"Unexpected Background Sound byte; refusing to alter an unknown client."
)
laa_current = bytes(data[0x126:0x128])
if laa_current not in (b"\x0F\x01", b"\x2F\x01"):
raise RuntimeError(
"Unexpected Large Address Aware bytes; refusing to alter an unknown client."
)
for offset, original, patched in _CAMERA_REGIONS:
current = bytes(data[offset:offset + len(original)])
if current not in (original, patched):
raise RuntimeError(
f"Unexpected Camera Skip Fix bytes at 0x{offset:X}; "
"refusing to alter an unknown client."
)
custom_state = tuple(
data[offset] for offset, _original, _patched in _CUSTOM_GLUES_SITES
)
custom_original = tuple(
original for _offset, original, _patched in _CUSTOM_GLUES_SITES
)
custom_patched = tuple(
patched for _offset, _original, patched in _CUSTOM_GLUES_SITES
)
foreign_custom_glues = None
accepted_custom = (
tuple(accepted_custom_glues)
if accepted_custom_glues is not None
else None
)
if custom_state not in (custom_original, custom_patched):
if accepted_custom is not None and custom_state == accepted_custom:
pass
elif allow_foreign_custom_glues:
# Turtle/Octo and other compatible clients may legitimately own
# this loader region. Never classify those bytes as vanilla and
# never overwrite them just to satisfy the checkbox state.
foreign_custom_glues = custom_state
else:
raise RuntimeError(
"Unexpected Custom GlueXML bytes; refusing to alter an unknown client."
)
return foreign_custom_glues
def _preflight_vanilla_tweaks_source(self, target):
"""Read and validate WoW.exe before vanilla-tweaks or any install write runs."""
self._vt_preserved_custom_glues = None
self._vt_source_numeric_states = None
wow_exe = os.path.join(target, "WoW.exe")
try:
with open(wow_exe, "rb") as handle:
data = bytearray(handle.read())
except OSError as exc:
raise RuntimeError(
"Could not inspect WoW.exe for Vanilla Tweaks safety preflight."
) from exc
desired = self._desired_normalized_values()
preserved = self._validate_vanilla_tweaks_state(
data,
allow_foreign_custom_glues=True,
)
self._validate_client_identity(data)
numeric_states = self._capture_source_numeric_states(data, desired)
self._vt_preserved_custom_glues = preserved
self._vt_source_numeric_states = numeric_states
return preserved
def validate_plugin_conflicts(self):
"""Use the installer's final read-only validation gate for EXE preflight."""
if not super().validate_plugin_conflicts():
return False
target = self.wow_dir.get().strip()
try:
self._preflight_vanilla_tweaks_source(target)
except Exception as exc:
messagebox.showerror(
"Vanilla Tweaks safety check",
f"{exc}\n\nNo installation files were changed.",
)
return False
return True
def configure_visual_audio(self, target):
"""Run every visual MPQ path with strict archive validation enabled."""
originals = _install_strict_mpq_runtime_hooks()
try:
return super().configure_visual_audio(target)
finally:
_restore_mpq_runtime_hooks(originals)
def run_installation(self):
"""Run the EXE patch transaction before the installer's first file write.
The base installer intentionally remains untouched. We intercept its
first mutating step (clean_unselected_files), run Vanilla Tweaks once,
then make the later vanilla-tweaks slot a no-op for this Apply.
"""
previous_clean = self.__dict__.get("clean_unselected_files")
previous_vt = self.__dict__.get("run_vanilla_tweaks")
had_clean_override = "clean_unselected_files" in self.__dict__
had_vt_override = "run_vanilla_tweaks" in self.__dict__
real_clean = self.clean_unselected_files
real_vt = self.run_vanilla_tweaks
state = {"ran": False, "result": None}
def run_vt_once(target):
if not state["ran"]:
state["result"] = real_vt(target)
state["ran"] = True
return state["result"]
def clean_after_vt(target):
run_vt_once(target)
return real_clean(target)
self.__dict__["clean_unselected_files"] = clean_after_vt
self.__dict__["run_vanilla_tweaks"] = run_vt_once
try:
return super().run_installation()
finally:
if had_clean_override:
self.__dict__["clean_unselected_files"] = previous_clean
else:
self.__dict__.pop("clean_unselected_files", None)
if had_vt_override:
self.__dict__["run_vanilla_tweaks"] = previous_vt
else:
self.__dict__.pop("run_vanilla_tweaks", None)
def _run_vanilla_tweaks_transactional(
self,
target,
tweaks_exe,
modern_cli=True,
):
"""Recheck the source immediately before the staged patch transaction."""
self._preflight_vanilla_tweaks_source(target)
return super()._run_vanilla_tweaks_transactional(
target,
tweaks_exe=tweaks_exe,
modern_cli=modern_cli,
)
def _normalize_selected_vanilla_tweaks_output(self, output_exe):
"""Make Tool-owned executable tweaks authoritative on pre-patched clients.
Blue Moon and Cross-faction Resurrection deliberately keep the previous
vanilla-tweaks behavior. A foreign Custom GlueXML region discovered on
the original WoW.exe is also preserved byte-for-byte because community
clients may use those offsets for their own loader/anti-tamper code.
"""
try:
with open(output_exe, "rb") as handle:
data = bytearray(handle.read())
except OSError as exc:
raise RuntimeError(
"Could not inspect WoW_Modernized.exe for Vanilla Tweaks normalization."
) from exc
preserved_custom_glues = getattr(
self,
"_vt_preserved_custom_glues",
None,
)
self._validate_vanilla_tweaks_state(
data,
accepted_custom_glues=preserved_custom_glues,
)
desired = self._desired_normalized_values()
source_numeric_states = getattr(
self,
"_vt_source_numeric_states",
None,
)
if source_numeric_states is None:
# Direct callers from older tests/tools predate source preflight.
# The real Apply/transaction path always supplies a source snapshot.
source_numeric_states = self._capture_source_numeric_states(data, desired)
self._validate_staged_numeric_states(
data,
source_numeric_states,
desired,
)
# All validation passed. Apply the exact Tool selections in memory.
quickloot_sites = (
(0x0C1ECF, 0x10),
(0x0C2B25, 0x0B),
)
desired_quickloot_opcode = 0x75 if self.vt_quickloot.get() else 0x74
for offset, displacement in quickloot_sites:
data[offset:offset + 2] = bytes(
(desired_quickloot_opcode, displacement)
)
data[0x3A4869] = 0x27 if self.vt_bg_sound.get() else 0x14
data[0x126:0x128] = b"\x2F\x01" if self.vt_laa.get() else b"\x0F\x01"
desired_camera_patched = bool(self.vt_cam_fix.get())
for offset, original, patched in _CAMERA_REGIONS:
selected = patched if desired_camera_patched else original
data[offset:offset + len(selected)] = selected
if preserved_custom_glues is not None:
# The original client owned this region. vanilla-tweaks may have
# rewritten it in staging, so put the exact source bytes back.
for index, (offset, _original, _patched) in enumerate(_CUSTOM_GLUES_SITES):
data[offset] = preserved_custom_glues[index]
else:
desired_custom_patched = bool(self.vt_custom_glues.get())
for offset, original, patched in _CUSTOM_GLUES_SITES:
data[offset] = patched if desired_custom_patched else original
struct.pack_into("<f", data, 0x4089B4, desired["fov"])
struct.pack_into("<f", data, 0x40FED8, desired["farclip"])
struct.pack_into("<f", data, 0x467958, desired["frill"])
struct.pack_into("<f", data, 0x40C448, desired["nameplate"])
struct.pack_into("<f", data, 0x4089A4, desired["maxcam"])
data[0x435D38:0x435D3C] = desired["sound_bytes"]
# Blue Moon (0x3E5B83) and Cross-faction Res (0x2067DE) are intentionally
# not normalized here. vanilla-tweaks keeps exactly the previous behavior
# for those two options.
staged = output_exe + ".modernization-normalized"
try:
with open(staged, "wb") as handle:
handle.write(data)
os.replace(staged, output_exe)
except OSError as exc:
raise RuntimeError(
"Could not write the normalized WoW_Modernized.exe."
) from exc
finally:
if os.path.exists(staged):
try:
os.remove(staged)
except OSError:
pass
class _DynamicModuleProxy(types.ModuleType):
"""Keep legacy module-level monkey patches visible to the preserved core."""
def __setattr__(self, name, value):
super().__setattr__(name, value)
if name == "get_base_path":
setattr(_dynamic_core, name, value)
# Existing tests and external callers historically patched
# setup_tool_dynamic.get_base_path. Preserve that behavior after splitting the
# untouched implementation into setup_tool_dynamic_core.py.
sys.modules[__name__].__class__ = _DynamicModuleProxy
if __name__ == "__main__":
root = tk.Tk()
app = ModernWowSetupTool(root)
root.mainloop()