diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 149772b..3d3ac92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: run: python scripts/prepare_remote_fallbacks.py - name: Syntax check - run: python -m py_compile setup_tool.py setup_tool_dynamic.py setup_tool_responsive.py remote_packages.py tests/test_safety.py scripts/prepare_remote_fallbacks.py + run: python -m py_compile setup_tool.py setup_tool_dynamic.py setup_tool_responsive.py dxvk_fps.py remote_packages.py tests/test_safety.py tests/test_dxvk_fps_limit.py scripts/prepare_remote_fallbacks.py - name: Unit safety tests run: python -m unittest discover -s tests -v diff --git a/dxvk_fps.py b/dxvk_fps.py new file mode 100644 index 0000000..d0254c4 --- /dev/null +++ b/dxvk_fps.py @@ -0,0 +1,201 @@ +import os +import re + + +DEFAULT_REFRESH_RATE = 60 +_MAX_FRAME_RATE_RE = re.compile( + r"^\s*(?P#\s*)?d3d9\.maxFrameRate\s*=\s*(?P[+-]?\d+)\s*$", + re.IGNORECASE, +) + + +def detect_max_refresh_rate(default=DEFAULT_REFRESH_RATE): + """Return the highest refresh rate reported by active Windows displays.""" + try: + fallback = max(1, int(default)) + except (TypeError, ValueError): + fallback = DEFAULT_REFRESH_RATE + + if os.name != "nt": + return fallback + + try: + import ctypes + from ctypes import wintypes + + class DISPLAY_DEVICEW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("DeviceName", wintypes.WCHAR * 32), + ("DeviceString", wintypes.WCHAR * 128), + ("StateFlags", wintypes.DWORD), + ("DeviceID", wintypes.WCHAR * 128), + ("DeviceKey", wintypes.WCHAR * 128), + ] + + class POINTL(ctypes.Structure): + _fields_ = [ + ("x", wintypes.LONG), + ("y", wintypes.LONG), + ] + + class DEVMODEW(ctypes.Structure): + _fields_ = [ + ("dmDeviceName", wintypes.WCHAR * 32), + ("dmSpecVersion", wintypes.WORD), + ("dmDriverVersion", wintypes.WORD), + ("dmSize", wintypes.WORD), + ("dmDriverExtra", wintypes.WORD), + ("dmFields", wintypes.DWORD), + ("dmPosition", POINTL), + ("dmDisplayOrientation", wintypes.DWORD), + ("dmDisplayFixedOutput", wintypes.DWORD), + ("dmColor", wintypes.SHORT), + ("dmDuplex", wintypes.SHORT), + ("dmYResolution", wintypes.SHORT), + ("dmTTOption", wintypes.SHORT), + ("dmCollate", wintypes.SHORT), + ("dmFormName", wintypes.WCHAR * 32), + ("dmLogPixels", wintypes.WORD), + ("dmBitsPerPel", wintypes.DWORD), + ("dmPelsWidth", wintypes.DWORD), + ("dmPelsHeight", wintypes.DWORD), + ("dmDisplayFlags", wintypes.DWORD), + ("dmDisplayFrequency", wintypes.DWORD), + ("dmICMMethod", wintypes.DWORD), + ("dmICMIntent", wintypes.DWORD), + ("dmMediaType", wintypes.DWORD), + ("dmDitherType", wintypes.DWORD), + ("dmReserved1", wintypes.DWORD), + ("dmReserved2", wintypes.DWORD), + ("dmPanningWidth", wintypes.DWORD), + ("dmPanningHeight", wintypes.DWORD), + ] + + enum_display_devices = ctypes.windll.user32.EnumDisplayDevicesW + enum_display_settings = ctypes.windll.user32.EnumDisplaySettingsW + enum_display_devices.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(DISPLAY_DEVICEW), + wintypes.DWORD, + ] + enum_display_devices.restype = wintypes.BOOL + enum_display_settings.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(DEVMODEW), + ] + enum_display_settings.restype = wintypes.BOOL + + DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000001 + ENUM_CURRENT_SETTINGS = 0xFFFFFFFF + rates = [] + index = 0 + + while True: + device = DISPLAY_DEVICEW() + device.cb = ctypes.sizeof(DISPLAY_DEVICEW) + if not enum_display_devices(None, index, ctypes.byref(device), 0): + break + index += 1 + + if not (device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP): + continue + + mode = DEVMODEW() + mode.dmSize = ctypes.sizeof(DEVMODEW) + if not enum_display_settings( + device.DeviceName, + ENUM_CURRENT_SETTINGS, + ctypes.byref(mode), + ): + continue + + refresh_rate = int(mode.dmDisplayFrequency) + # Windows may report 0/1 when the rate is unknown/default. + if 1 < refresh_rate < 10000: + rates.append(refresh_rate) + + if rates: + return max(rates) + except (AttributeError, OSError, TypeError, ValueError): + pass + + return fallback + + +def read_dxvk_fps_limit(config_path): + """Return (enabled, fps) for the first positive DXVK maxFrameRate line, or None.""" + try: + with open(config_path, "r", encoding="utf-8", errors="ignore") as handle: + lines = handle.read().splitlines() + except OSError: + return None + + for line in lines: + match = _MAX_FRAME_RATE_RE.match(line) + if match is None: + continue + try: + value = int(match.group("value")) + except (TypeError, ValueError): + continue + if value <= 0: + continue + return match.group("comment") is None, value + return None + + +def apply_dxvk_fps_limit(config_path, enabled, fps): + """Atomically set or comment DXVK's d3d9.maxFrameRate option.""" + try: + value = int(fps) + except (TypeError, ValueError) as exc: + raise ValueError("DXVK FPS limit must be a positive integer.") from exc + if value <= 0: + raise ValueError("DXVK FPS limit must be a positive integer.") + + try: + with open(config_path, "r", encoding="utf-8", errors="ignore") as handle: + existing = handle.read() + except OSError as exc: + raise RuntimeError(f"Could not read dxvk.conf: {exc}") from exc + + setting = ( + f"d3d9.maxFrameRate = {value}" + if enabled + else f"# d3d9.maxFrameRate = {value}" + ) + + output = [] + replaced = False + for line in existing.splitlines(): + if _MAX_FRAME_RATE_RE.match(line): + if not replaced: + output.append(setting) + replaced = True + # Drop duplicate maxFrameRate lines, including legacy signed values, + # so only the user's selected setting can win. + continue + output.append(line) + + if not replaced: + if output and output[-1] != "": + output.append("") + output.append(setting) + + updated = "\n".join(output) + "\n" + staged = config_path + ".modernization-new" + try: + with open(staged, "w", encoding="utf-8", newline="\n") as handle: + handle.write(updated) + os.replace(staged, config_path) + except OSError as exc: + raise RuntimeError(f"Could not update dxvk.conf: {exc}") from exc + finally: + if os.path.exists(staged): + try: + os.remove(staged) + except OSError: + pass diff --git a/setup_tool_responsive.py b/setup_tool_responsive.py index 68fcfa8..9741a23 100644 --- a/setup_tool_responsive.py +++ b/setup_tool_responsive.py @@ -1,15 +1,38 @@ +import json +import os import tkinter as tk -from tkinter import ttk +from tkinter import messagebox, ttk +import dxvk_fps import setup_tool_dynamic class ResponsiveModernWowSetupTool(setup_tool_dynamic.ModernWowSetupTool): - """Production entry point with DPI/display-scaling-safe window behavior.""" + """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) @@ -44,6 +67,195 @@ class ResponsiveModernWowSetupTool(setup_tool_dynamic.ModernWowSetupTool): 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 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() diff --git a/tests/test_dxvk_fps_limit.py b/tests/test_dxvk_fps_limit.py new file mode 100644 index 0000000..5b864a3 --- /dev/null +++ b/tests/test_dxvk_fps_limit.py @@ -0,0 +1,226 @@ +import ctypes +import os +import tempfile +import types +import unittest +from unittest import mock + +import dxvk_fps +import setup_tool_dynamic +import setup_tool_responsive + + +class FakeVar: + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + + +class DxvkFpsLimitTests(unittest.TestCase): + def write_config(self, root, text): + path = os.path.join(root, "dxvk.conf") + with open(path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + return path + + def read_config(self, path): + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + def detect_with_fake_displays(self, devices, default=77): + """Run Windows display detection against deterministic fake Win32 APIs.""" + # Each record is (device name, state flags, refresh rate, settings_ok). + def enum_display_devices(_device, index, device_ptr, _flags): + if index >= len(devices): + return False + name, state_flags, _rate, _settings_ok = devices[index] + device_ptr._obj.DeviceName = name + device_ptr._obj.StateFlags = state_flags + return True + + def enum_display_settings(device_name, _mode, mode_ptr): + for name, _state_flags, rate, settings_ok in devices: + if name != device_name: + continue + if not settings_ok: + return False + mode_ptr._obj.dmDisplayFrequency = rate + return True + return False + + fake_user32 = types.SimpleNamespace( + EnumDisplayDevicesW=enum_display_devices, + EnumDisplaySettingsW=enum_display_settings, + ) + fake_windll = types.SimpleNamespace(user32=fake_user32) + + with mock.patch.object(dxvk_fps.os, "name", "nt"), mock.patch.object( + ctypes, + "windll", + fake_windll, + create=True, + ): + return dxvk_fps.detect_max_refresh_rate(default=default) + + def test_enable_replaces_bundled_commented_value_and_preserves_other_options(self): + with tempfile.TemporaryDirectory() as root: + path = self.write_config( + root, + "# DXVK configuration\n" + "# d3d9.maxFrameRate = 1000\n" + "dxvk.allowFse = False\n", + ) + + dxvk_fps.apply_dxvk_fps_limit(path, True, 165) + updated = self.read_config(path) + + self.assertIn("d3d9.maxFrameRate = 165\n", updated) + self.assertNotIn("# d3d9.maxFrameRate = 1000", updated) + self.assertIn("dxvk.allowFse = False\n", updated) + self.assertEqual(updated.count("d3d9.maxFrameRate"), 1) + self.assertEqual(dxvk_fps.read_dxvk_fps_limit(path), (True, 165)) + + def test_disable_comments_the_setting(self): + with tempfile.TemporaryDirectory() as root: + path = self.write_config( + root, + "d3d9.maxFrameRate = 144\n" + "dxvk.numCompilerThreads = 4\n", + ) + + dxvk_fps.apply_dxvk_fps_limit(path, False, 144) + updated = self.read_config(path) + + self.assertIn("# d3d9.maxFrameRate = 144\n", updated) + self.assertNotIn("\nd3d9.maxFrameRate = 144\n", "\n" + updated) + self.assertEqual(dxvk_fps.read_dxvk_fps_limit(path), (False, 144)) + + def test_duplicate_limit_lines_are_collapsed_to_one(self): + with tempfile.TemporaryDirectory() as root: + path = self.write_config( + root, + "d3d9.maxFrameRate = 60\n" + "# d3d9.maxFrameRate = 120\n" + "dxvk.allowFse = False\n", + ) + + dxvk_fps.apply_dxvk_fps_limit(path, True, 240) + updated = self.read_config(path) + + self.assertEqual(updated.count("d3d9.maxFrameRate"), 1) + self.assertIn("d3d9.maxFrameRate = 240\n", updated) + self.assertIn("dxvk.allowFse = False\n", updated) + + def test_signed_legacy_values_are_replaced_without_duplicates(self): + with tempfile.TemporaryDirectory() as root: + path = self.write_config( + root, + "d3d9.maxFrameRate = -120\n" + "# d3d9.maxFrameRate = +90\n" + "dxvk.allowFse = False\n", + ) + + dxvk_fps.apply_dxvk_fps_limit(path, True, 165) + updated = self.read_config(path) + + self.assertEqual(updated.count("d3d9.maxFrameRate"), 1) + self.assertIn("d3d9.maxFrameRate = 165\n", updated) + self.assertNotIn("-120", updated) + self.assertNotIn("+90", updated) + self.assertIn("dxvk.allowFse = False\n", updated) + + def test_missing_limit_line_is_appended(self): + with tempfile.TemporaryDirectory() as root: + path = self.write_config(root, "dxvk.allowFse = False\n") + + dxvk_fps.apply_dxvk_fps_limit(path, True, 75) + updated = self.read_config(path) + + self.assertIn("dxvk.allowFse = False\n", updated) + self.assertTrue(updated.endswith("d3d9.maxFrameRate = 75\n")) + + def test_invalid_values_are_rejected_without_modifying_file(self): + with tempfile.TemporaryDirectory() as root: + original = "# d3d9.maxFrameRate = 1000\n" + path = self.write_config(root, original) + + with self.assertRaises(ValueError): + dxvk_fps.apply_dxvk_fps_limit(path, True, 0) + + self.assertEqual(self.read_config(path), original) + + def test_refresh_detection_uses_fastest_attached_valid_display(self): + detected = self.detect_with_fake_displays( + [ + ("DISPLAY1", 0x00000001, 60, True), + # Faster but not attached to the desktop: must be ignored. + ("DISPLAY2", 0x00000000, 360, True), + ("DISPLAY3", 0x00000001, 165, True), + # Windows unknown/default frequency: must be ignored. + ("DISPLAY4", 0x00000001, 1, True), + # Current settings query failed: must be ignored. + ("DISPLAY5", 0x00000001, 240, False), + ], + default=77, + ) + self.assertEqual(detected, 165) + + def test_refresh_detection_falls_back_when_no_usable_display_rate_exists(self): + detected = self.detect_with_fake_displays( + [ + ("DISPLAY1", 0x00000000, 240, True), + ("DISPLAY2", 0x00000001, 0, True), + ("DISPLAY3", 0x00000001, 1, True), + ("DISPLAY4", 0x00000001, 165, False), + ], + default=77, + ) + self.assertEqual(detected, 77) + + def test_refresh_detection_always_returns_a_positive_integer(self): + detected = dxvk_fps.detect_max_refresh_rate(default=77) + self.assertIsInstance(detected, int) + self.assertGreater(detected, 0) + + @mock.patch.object(setup_tool_dynamic.ModernWowSetupTool, "configure_dxvk") + @mock.patch("setup_tool_responsive.dxvk_fps.apply_dxvk_fps_limit") + def test_directx9_never_applies_fps_config(self, apply_limit, parent_configure): + tool = setup_tool_responsive.ResponsiveModernWowSetupTool.__new__( + setup_tool_responsive.ResponsiveModernWowSetupTool + ) + tool.rendering_mode = FakeVar("directx9") + tool.limit_dxvk_fps = FakeVar(True) + tool.dxvk_fps_limit = FakeVar(165) + + tool.configure_dxvk("C:/WoW") + + parent_configure.assert_called_once_with("C:/WoW") + apply_limit.assert_not_called() + + @mock.patch.object(setup_tool_dynamic.ModernWowSetupTool, "configure_dxvk") + @mock.patch("setup_tool_responsive.dxvk_fps.apply_dxvk_fps_limit") + def test_dxvk_applies_selected_fps_value(self, apply_limit, parent_configure): + tool = setup_tool_responsive.ResponsiveModernWowSetupTool.__new__( + setup_tool_responsive.ResponsiveModernWowSetupTool + ) + tool.rendering_mode = FakeVar("dxvk") + tool.limit_dxvk_fps = FakeVar(True) + tool.dxvk_fps_limit = FakeVar(144) + + tool.configure_dxvk("C:/WoW") + + parent_configure.assert_called_once_with("C:/WoW") + apply_limit.assert_called_once_with( + os.path.join("C:/WoW", "dxvk.conf"), + True, + 144, + ) + + +if __name__ == "__main__": + unittest.main()