diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e4832ac..cfc836d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,6 +10,7 @@ This update focuses on **better compatibility, safer WoW.exe patching, improved - Improved support for already-patched clients while preserving client-specific loader code. - Removed overly strict build/version checks that could reject compatible clients. - SuperWoW now uses the same FoV selected by the Modernization Tool by synchronizing the `FoV` CVar in `WTF/Config.wtf`. +- Render Distance now keeps the executable Farclip ceiling fixed at **3000** while synchronizing the selected runtime value to `WTF/Config.wtf`, preventing stale higher Farclip values from conflicting with `WoW_Modernized.exe`. ## 🎨 MPQ & Recovery diff --git a/setup_tool.py b/setup_tool.py index 2e47764..d2c7f30 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -111,7 +111,7 @@ class WowSetupTool: # Tweaks Tab "fov": "Calculates horizontal Field of View mathematically scaled to maintain vertical aspect space based on your screen ratio.", - "farclip": "Increases the maximum terrain render distance. Vanilla default is 777. Tweaks default is 1500.", + "farclip": "Sets the active terrain render distance used by the game. Vanilla default is 777. The Tool keeps the executable Farclip ceiling fixed at 3000.", "frill": "Changes the ground clutter (grass) render distance. Vanilla default is 70. Tweaks default is 300.", "nameplate": "Increases the distance at which enemy nameplates become visible. Vanilla default is 20. Tweaks default is 41.", "cam": "Increases the maximum camera zoom-out distance. Vanilla default is 50. Max safe limit is 100.", @@ -749,7 +749,7 @@ class WowSetupTool: frame_nums.pack(fill='x', padx=15, pady=0) frame_nums.columnconfigure(1, weight=1) - self.create_slider_row(frame_nums, 0, "Render distance (Farclip) [Safe Max: 1500]:", self.vt_farclip, 777, 1500, 10000, "farclip") + self.create_slider_row(frame_nums, 0, "Render distance (Farclip) [Safe Max: 1500]:", self.vt_farclip, 777, 1500, 3000, "farclip") self.create_slider_row(frame_nums, 1, "Ground clutter (Frilldistance) [Safe Max: 300]:", self.vt_frill, 70, 300, 1000, "frill") self.create_slider_row(frame_nums, 2, "Nameplate range [Safe Max: 41]:", self.vt_nameplate, 20, 41, 150, "nameplate") self.create_slider_row(frame_nums, 3, "Camera distance [Safe Max: 100]:", self.vt_maxcam, 50, 100, 250, "cam") diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 6a233b8..04b91dc 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -80,7 +80,7 @@ _CUSTOM_GLUES_SITES = ( # Numeric fields may already contain legitimate Turtle/Octo/community values. # Preflight only sanity-checks those fixed-offset fields; normalization later -# overwrites them with the exact values selected in the Tool. +# applies the exact Tool policy without fingerprinting community numeric values. _NUMERIC_SITES = ( ("fov", 0x4089B4, "FoV", 0.5, 3.5), ("farclip", 0x40FED8, "Farclip", 777.0, 10000.0), @@ -93,6 +93,7 @@ _CLIENT_BUILD_OFFSET = 0x437BFC _CLIENT_VERSION_OFFSET = 0x437C04 _CLIENT_BUILD = b"5875" _CLIENT_VERSION = b"1.12.1" +_FARCLIP_EXE_CEILING = 3000.0 def _strict_verify_mpq(path): @@ -286,16 +287,20 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): 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 + # Runtime Farclip lives in Config.wtf; the executable ceiling is fixed. + # Keep the executable signature stable when only Render Distance changes. + signature["farclip"] = int(_FARCLIP_EXE_CEILING) + # Bump when normalization changes so existing managed installs get one + # clean WoW_Modernized.exe rebuild under the new policy. + signature["selected_patch_normalization"] = 4 signature["source_fingerprint_policy"] = 1 return signature def _desired_normalized_values(self): + selected_farclip = float(self.vt_farclip.get()) desired = { "fov": float(self.vt_fov.get()), - "farclip": float(self.vt_farclip.get()), + "farclip": selected_farclip, "frill": float(self.vt_frill.get()), "nameplate": float(self.vt_nameplate.get()), "maxcam": float(self.vt_maxcam.get()), @@ -304,7 +309,7 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): ranges = ( ("FoV", desired["fov"], 0.5, 3.5), - ("Farclip", desired["farclip"], 100.0, 50000.0), + ("Farclip", selected_farclip, 100.0, _FARCLIP_EXE_CEILING), ("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), @@ -321,8 +326,36 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): "Sound Channels value is outside the supported WoW 1.12.1 range." ) desired["sound_bytes"] = sound_channels.ljust(4, b"\x00") + + # The EXE field is the maximum allowed Farclip, not the active distance. + # Keep it fixed at 3000. The selected runtime value is synchronized to + # Config.wtf separately and may never exceed this ceiling. + desired["farclip"] = _FARCLIP_EXE_CEILING return desired + def validate_limits(self): + if not super().validate_limits(): + return False + + try: + farclip = float(self.vt_farclip.get()) + except (tk.TclError, TypeError, ValueError): + messagebox.showerror( + "Input Error", + "Render Distance (Farclip) must contain a valid number.", + ) + return False + + if not math.isfinite(farclip) or not 100.0 <= farclip <= _FARCLIP_EXE_CEILING: + messagebox.showerror( + "Limit Exceeded", + "Render distance (Farclip) must stay between 100 and 3000. " + "The 3000 hard maximum also applies when Safety Limits are disabled.", + ) + return False + + return True + @staticmethod def _validate_client_identity(data): """Compatibility no-op: Turtle/Octo may move build/version strings.""" @@ -554,10 +587,75 @@ class ModernWowSetupTool(_ModernWowSetupToolCore): except OSError: pass + def _configure_farclip_cvar(self, target): + """Synchronize the Tool-selected runtime Farclip to WTF/Config.wtf.""" + try: + farclip = int(self.vt_farclip.get()) + except (tk.TclError, TypeError, ValueError) as exc: + raise RuntimeError("Render Distance (Farclip) is not a valid number.") from exc + if not 100 <= farclip <= int(_FARCLIP_EXE_CEILING): + raise RuntimeError( + "Render Distance (Farclip) must stay between 100 and 3000." + ) + + wtf_dir = os.path.join(target, "WTF") + config_path = os.path.join(wtf_dir, "Config.wtf") + staged = config_path + ".modernization-farclip" + original_mode = None + restore_readonly = False + + try: + os.makedirs(wtf_dir, exist_ok=True) + + if os.path.exists(config_path): + original_mode = os.stat(config_path).st_mode + if not (original_mode & stat.S_IWRITE): + os.chmod(config_path, original_mode | stat.S_IWRITE) + restore_readonly = True + + existing = "" + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8", errors="ignore") as handle: + existing = handle.read() + + setting = f'SET farclip "{farclip}"' + pattern = re.compile( + r'^\s*SET\s+farclip\s+"[^"]*"\s*$', + re.IGNORECASE | re.MULTILINE, + ) + if pattern.search(existing): + updated = pattern.sub(setting, existing) + else: + if existing and not existing.endswith(("\n", "\r")): + existing += "\n" + updated = existing + setting + "\n" + + with open(staged, "w", encoding="utf-8", newline="") as handle: + handle.write(updated) + os.replace(staged, config_path) + + except OSError as exc: + raise RuntimeError( + "Could not synchronize Render Distance in WTF\\Config.wtf. " + "Close WoW and any program using the file, then try again." + ) from exc + finally: + if os.path.exists(staged): + try: + os.remove(staged) + except OSError: + pass + if restore_readonly and original_mode is not None and os.path.exists(config_path): + try: + os.chmod(config_path, original_mode) + except OSError: + pass + def configure_script_memory(self, target): - """Apply base Config.wtf settings, then synchronize SuperWoW FoV.""" + """Apply base Config.wtf settings, then synchronize FoV and Farclip.""" super().configure_script_memory(target) self._configure_superwow_fov_cvar(target) + self._configure_farclip_cvar(target) def run_installation(self): """Run the EXE patch transaction before the installer's first file write. diff --git a/tests/test_farclip_config_sync.py b/tests/test_farclip_config_sync.py new file mode 100644 index 0000000..ccfe654 --- /dev/null +++ b/tests/test_farclip_config_sync.py @@ -0,0 +1,137 @@ +import os +import tempfile +import unittest +from unittest import mock + +import setup_tool_dynamic + + +class _Var: + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + +class FarclipConfigSyncTests(unittest.TestCase): + def _tool(self, farclip=777): + tool = object.__new__(setup_tool_dynamic.ModernWowSetupTool) + tool.vt_farclip = _Var(farclip) + return tool + + @staticmethod + def _add_numeric_vars(tool): + tool.vt_fov = _Var(1.9199) + tool.vt_frill = _Var(300) + tool.vt_nameplate = _Var(41) + tool.vt_maxcam = _Var(100) + tool.vt_soundchan = _Var(64) + + def test_executable_farclip_ceiling_is_always_3000(self): + tool = self._tool(777) + self._add_numeric_vars(tool) + desired = tool._desired_normalized_values() + self.assertEqual(desired["farclip"], 3000.0) + + tool.vt_farclip = _Var(3000) + desired = tool._desired_normalized_values() + self.assertEqual(desired["farclip"], 3000.0) + + def test_farclip_above_3000_is_rejected_even_with_direct_internal_call(self): + tool = self._tool(5000) + self._add_numeric_vars(tool) + with self.assertRaisesRegex(RuntimeError, "Farclip"): + tool._desired_normalized_values() + + def test_normalization_signature_bump_forces_existing_install_refresh(self): + tool = self._tool(777) + self._add_numeric_vars(tool) + tool.vt_quickloot = _Var(True) + tool.vt_bg_sound = _Var(True) + tool.vt_laa = _Var(True) + tool.vt_cam_fix = _Var(True) + tool.vt_crossfaction_res = _Var(False) + tool.vt_custom_glues = _Var(True) + tool.vt_bluemoon = _Var(False) + signature = tool._vanilla_tweaks_signature() + self.assertEqual(signature["selected_patch_normalization"], 4) + self.assertEqual(signature["farclip"], 3000) + + tool.vt_farclip = _Var(1500) + runtime_changed = tool._vanilla_tweaks_signature() + self.assertEqual(runtime_changed["farclip"], 3000) + self.assertEqual(signature, runtime_changed) + + def test_existing_farclip_cvar_is_replaced_without_touching_other_settings(self): + tool = self._tool(777) + with tempfile.TemporaryDirectory() as root: + wtf_dir = os.path.join(root, "WTF") + os.makedirs(wtf_dir) + config_path = os.path.join(wtf_dir, "Config.wtf") + with open(config_path, "w", encoding="utf-8") as handle: + handle.write('SET locale "enUS"\nSET farclip "2100"\nSET gxWindow "1"\n') + + tool._configure_farclip_cvar(root) + + with open(config_path, "r", encoding="utf-8") as handle: + result = handle.read() + + self.assertIn('SET locale "enUS"', result) + self.assertIn('SET gxWindow "1"', result) + self.assertIn('SET farclip "777"', result) + self.assertNotIn('SET farclip "2100"', result) + + def test_missing_farclip_cvar_is_appended(self): + tool = self._tool(1500) + with tempfile.TemporaryDirectory() as root: + wtf_dir = os.path.join(root, "WTF") + os.makedirs(wtf_dir) + config_path = os.path.join(wtf_dir, "Config.wtf") + with open(config_path, "w", encoding="utf-8") as handle: + handle.write('SET locale "enUS"') + + tool._configure_farclip_cvar(root) + + with open(config_path, "r", encoding="utf-8") as handle: + result = handle.read() + + self.assertEqual( + result, + 'SET locale "enUS"\nSET farclip "1500"\n', + ) + + def test_missing_config_wtf_is_created(self): + tool = self._tool(1000) + with tempfile.TemporaryDirectory() as root: + tool._configure_farclip_cvar(root) + config_path = os.path.join(root, "WTF", "Config.wtf") + with open(config_path, "r", encoding="utf-8") as handle: + result = handle.read() + self.assertEqual(result, 'SET farclip "1000"\n') + + def test_farclip_cvar_rejects_value_above_fixed_ceiling(self): + tool = self._tool(3001) + with tempfile.TemporaryDirectory() as root: + with self.assertRaisesRegex(RuntimeError, "3000"): + tool._configure_farclip_cvar(root) + self.assertFalse(os.path.exists(os.path.join(root, "WTF", "Config.wtf"))) + + def test_configure_script_memory_also_synchronizes_farclip(self): + tool = self._tool(777) + tool.core_plugins = {"SuperWoWhook.dll": _Var(False)} + with mock.patch.object( + setup_tool_dynamic._ModernWowSetupToolCore, + "configure_script_memory", + ) as parent_configure, mock.patch.object( + tool, + "_configure_farclip_cvar", + ) as sync_farclip: + tool.configure_script_memory("C:/WoW") + + parent_configure.assert_called_once_with("C:/WoW") + sync_farclip.assert_called_once_with("C:/WoW") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_final_safety_hardening.py b/tests/test_final_safety_hardening.py index ffb2439..e505fc3 100644 --- a/tests/test_final_safety_hardening.py +++ b/tests/test_final_safety_hardening.py @@ -220,7 +220,7 @@ class VanillaTweaksSourceValidationTests(unittest.TestCase): 1.9199, places=4, ) - self.assertEqual(struct.unpack_from("