import json import os import re import stat import tkinter as tk from tkinter import messagebox, ttk import dxvk_fps import setup_tool_dynamic _FARCLIP_EXE_FLOOR = 3000.0 class ResponsiveModernWowSetupTool(setup_tool_dynamic.ModernWowSetupTool): """Production entry point with display-scaling-safe UI and DXVK FPS control.""" def __init__(self, root): # Define these before the parent constructor: WowSetupTool.__init__ calls # overridden UI/settings methods while it is still initializing. self.detected_refresh_rate = dxvk_fps.detect_max_refresh_rate() self.limit_dxvk_fps = tk.BooleanVar(master=root, value=True) self.dxvk_fps_limit = tk.IntVar( master=root, value=self.detected_refresh_rate, ) self.dxvk_fps_checkbox = None self.dxvk_fps_entry = None self.dxvk_fps_unit_label = None super().__init__(root) # Keep the FPS controls synchronized when the renderer radio selection # changes or when saved settings restore a different renderer. self._rendering_mode_trace = self.rendering_mode.trace_add( "write", lambda *_args: self._update_dxvk_fps_state(), ) self._update_dxvk_fps_state() # Preserve all existing installer behavior and only change how the # top-level Tk window allocates and exposes its controls. root.resizable(True, True) # Reserve the bottom action button before the expandable notebook so # Windows DPI/text scaling cannot push Apply outside the visible area. notebook = next( (child for child in root.winfo_children() if isinstance(child, ttk.Notebook)), None, ) apply_button = getattr(self, "apply_button", None) if notebook is not None and apply_button is not None: notebook.pack_forget() apply_button.pack_forget() apply_button.pack(side="bottom", pady=10, fill="x", padx=20) notebook.pack(fill="both", expand=True, padx=10, pady=(5, 10)) root.update_idletasks() # Prefer the widgets' requested size when the monitor has room, while # keeping a margin so the window remains reachable on smaller screens. screen_width = max(1, root.winfo_screenwidth()) screen_height = max(1, root.winfo_screenheight()) max_width = max(640, screen_width - 80) max_height = max(520, screen_height - 80) requested_width = max(680, root.winfo_reqwidth()) requested_height = max(660, root.winfo_reqheight()) width = min(requested_width, max_width) height = min(requested_height, max_height) root.geometry(f"{width}x{height}") root.minsize(min(640, max_width), min(520, max_height)) def build_main_tab(self, parent): super().build_main_tab(parent) optional_label = None for child in parent.winfo_children(): try: if child.cget("text") == "Optional Mods:": optional_label = child break except tk.TclError: continue fps_frame = ttk.Frame(parent) pack_options = { "fill": "x", "padx": 20, "pady": (8, 2), } if optional_label is not None: pack_options["before"] = optional_label fps_frame.pack(**pack_options) controls = ttk.Frame(fps_frame) controls.pack(anchor="w") self.dxvk_fps_checkbox = ttk.Checkbutton( controls, text="Limit DXVK FPS", variable=self.limit_dxvk_fps, command=self._update_dxvk_fps_state, ) self.dxvk_fps_checkbox.pack(side="left") self.dxvk_fps_entry = ttk.Entry( controls, textvariable=self.dxvk_fps_limit, width=7, ) self.dxvk_fps_entry.pack(side="left", padx=(10, 4)) self.dxvk_fps_unit_label = ttk.Label(controls, text="FPS") self.dxvk_fps_unit_label.pack(side="left") help_label = ttk.Label( fps_frame, text=( "Auto-detected from the fastest active display at startup. " "You can change it manually. DXVK only." ), font=("Segoe UI", 8, "italic"), ) help_label.pack(anchor="w", padx=(22, 0), pady=(1, 0)) tooltip = ( "Limits DXVK with d3d9.maxFrameRate. The initial value uses the " f"highest active display refresh rate detected at startup " f"({self.detected_refresh_rate} Hz). You can enter another positive " "integer. DirectX 9 is never modified by this option." ) setup_tool_dynamic.ToolTip(self.dxvk_fps_checkbox, tooltip) setup_tool_dynamic.ToolTip(self.dxvk_fps_entry, tooltip) self._update_dxvk_fps_state() def _update_dxvk_fps_state(self): is_dxvk = getattr(self, "rendering_mode", None) is not None and ( self.rendering_mode.get() == "dxvk" ) enabled = bool(self.limit_dxvk_fps.get()) checkbox = getattr(self, "dxvk_fps_checkbox", None) entry = getattr(self, "dxvk_fps_entry", None) unit_label = getattr(self, "dxvk_fps_unit_label", None) if checkbox is not None: checkbox.configure(state="normal" if is_dxvk else "disabled") if entry is not None: entry.configure(state="normal" if is_dxvk and enabled else "disabled") if unit_label is not None: if is_dxvk and enabled: unit_label.state(["!disabled"]) else: unit_label.state(["disabled"]) def _collect_settings(self): settings = super()._collect_settings() try: fps_value = int(self.dxvk_fps_limit.get()) except (tk.TclError, TypeError, ValueError): fps_value = int(self.detected_refresh_rate) settings["dxvk_fps_limit"] = { "enabled": bool(self.limit_dxvk_fps.get()), "value": fps_value, } return settings def _apply_settings_dict(self, saved): super()._apply_settings_dict(saved) fps_settings = saved.get("dxvk_fps_limit") if isinstance(saved, dict) else None if isinstance(fps_settings, dict): enabled = fps_settings.get("enabled") value = fps_settings.get("value") if isinstance(enabled, bool): self.limit_dxvk_fps.set(enabled) if ( isinstance(value, int) and not isinstance(value, bool) and value > 0 ): self.dxvk_fps_limit.set(value) self._update_dxvk_fps_state() def load_settings(self, target_dir): # Existing v2.3 installations predate this option. Preserve their # current DXVK limit state instead of silently enabling a new cap. has_saved_fps_setting = False settings_path = self._settings_path(target_dir) try: with open(settings_path, "r", encoding="utf-8") as handle: saved = json.load(handle) has_saved_fps_setting = isinstance( saved.get("dxvk_fps_limit") if isinstance(saved, dict) else None, dict, ) except (OSError, json.JSONDecodeError, ValueError, TypeError): pass loaded = super().load_settings(target_dir) if ( not has_saved_fps_setting and self._looks_like_managed_install(target_dir) and self.rendering_mode.get() == "dxvk" ): existing = dxvk_fps.read_dxvk_fps_limit( os.path.join(target_dir, "dxvk.conf") ) if existing is None: self.limit_dxvk_fps.set(False) else: enabled, value = existing self.limit_dxvk_fps.set(enabled) self.dxvk_fps_limit.set(value) self._update_dxvk_fps_state() return loaded def validate_limits(self): if not super().validate_limits(): return False if self.rendering_mode.get() != "dxvk" or not self.limit_dxvk_fps.get(): return True try: value = int(self.dxvk_fps_limit.get()) except (tk.TclError, TypeError, ValueError): messagebox.showerror( "Input Error", "DXVK FPS Limit must contain a positive whole number.", ) return False if value <= 0: messagebox.showerror( "Input Error", "DXVK FPS Limit must be greater than 0.", ) return False return True def _desired_normalized_values(self): desired = super()._desired_normalized_values() # The EXE field is a maximum allowed Farclip, not the active distance. # Keep a 3000 baseline ceiling so an existing Config.wtf value cannot # exceed a newly reduced executable limit. Values explicitly selected # above 3000 still raise the ceiling so the safety override keeps working. desired["farclip"] = max(_FARCLIP_EXE_FLOOR, desired["farclip"]) return desired def _configure_farclip_cvar(self, target): """Apply the selected render distance 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 farclip <= 0: raise RuntimeError("Render Distance (Farclip) must be greater than 0.") 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): # Keep the existing script-memory and SuperWoW FoV behavior, then make # the Tool's Render Distance selection authoritative at runtime too. super().configure_script_memory(target) self._configure_farclip_cvar(target) def configure_dxvk(self, target): super().configure_dxvk(target) # DirectX 9 follows the existing renderer cleanup path above. Never # create or modify dxvk.conf when DXVK is not selected. if self.rendering_mode.get() != "dxvk": return dxvk_fps.apply_dxvk_fps_limit( os.path.join(target, "dxvk.conf"), bool(self.limit_dxvk_fps.get()), int(self.dxvk_fps_limit.get()), ) if __name__ == "__main__": root = tk.Tk() app = ResponsiveModernWowSetupTool(root) root.mainloop()