diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 56ab8ef..c7db236 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -4,6 +4,7 @@ import struct import sys import tkinter as tk import types +from tkinter import messagebox import setup_tool_dynamic_core as _dynamic_core # Keep the feature-branch implementation intact and layer only the executable @@ -80,10 +81,9 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): def _vanilla_tweaks_signature(self): signature = super()._vanilla_tweaks_signature() - # v2 expands normalization from the original selected subset to every - # executable tweak exposed by the Tool except the two intentionally - # legacy upstream-only patches (Blue Moon and Cross-faction Res). - signature["selected_patch_normalization"] = 2 + # v3 adds source preflight and preserves client-owned Custom GlueXML + # regions instead of letting vanilla-tweaks overwrite unknown loader code. + signature["selected_patch_normalization"] = 3 return signature @staticmethod @@ -95,30 +95,55 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): "refusing to alter an unknown client." ) - def _normalize_selected_vanilla_tweaks_output(self, output_exe): - """Make Tool-owned executable tweaks authoritative on pre-patched clients. + 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()), + } - Blue Moon and Cross-faction Resurrection deliberately keep the previous - vanilla-tweaks behavior because their complete pristine restoration - signatures are not part of this policy. - """ - try: - with open(output_exe, "rb") as handle: - data = bytearray(handle.read()) - except OSError as exc: + 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( - "Could not inspect WoW_Modernized.exe for Vanilla Tweaks normalization." - ) from exc + "Sound Channels value is outside the supported WoW 1.12.1 range." + ) + desired["sound_bytes"] = sound_channels.ljust(4, b"\x00") + return desired + def _validate_vanilla_tweaks_state( + self, + data, + *, + allow_foreign_custom_glues=False, + accepted_custom_glues=None, + ): + """Validate every 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_Modernized.exe is too small for Vanilla Tweaks normalization." + "WoW.exe is too small for Vanilla Tweaks safety preflight." ) - # Validate all code-patch signatures before changing anything. Scalar - # fields below are data constants, so they are validated by type/range - # rather than by one exact preset value. quickloot_sites = ( (0x0C1ECF, 0x10), (0x0C2B25, 0x0B), @@ -155,13 +180,33 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): "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) + 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): - raise RuntimeError( - "Unexpected Custom GlueXML bytes; refusing to alter an unknown client." - ) + 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." + ) self._validate_float_field(data, 0x4089B4, "FoV", 0.5, 3.5) self._validate_float_field(data, 0x40FED8, "Farclip", 100.0, 50000.0) @@ -181,29 +226,92 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): "Unexpected Sound Channels field; refusing to alter an unknown client." ) - desired_fov = float(self.vt_fov.get()) - desired_farclip = float(self.vt_farclip.get()) - desired_frill = float(self.vt_frill.get()) - desired_nameplate = float(self.vt_nameplate.get()) - desired_maxcam = float(self.vt_maxcam.get()) - desired_sound = int(self.vt_soundchan.get()) + return foreign_custom_glues - desired_values = ( - ("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), + 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 + 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 + + # Validate the selected values now too, so malformed settings cannot + # fail only after unrelated plugins/mods have already been updated. + self._desired_normalized_values() + preserved = self._validate_vanilla_tweaks_state( + data, + allow_foreign_custom_glues=True, ) - for label, value, minimum, maximum in desired_values: - if not math.isfinite(value) or not minimum <= value <= maximum: - raise RuntimeError(f"{label} value is outside the supported WoW 1.12.1 range.") + self._vt_preserved_custom_glues = preserved + return preserved - 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.") + 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 _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() # 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( @@ -215,19 +323,25 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): desired_camera_patched = bool(self.vt_cam_fix.get()) for offset, original, patched in _CAMERA_REGIONS: - desired = patched if desired_camera_patched else original - data[offset:offset + len(desired)] = desired + selected = patched if desired_camera_patched else original + data[offset:offset + len(selected)] = selected - 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 + 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("