Add configurable DXVK FPS limiting #14

Merged
Dusk-92 merged 10 commits from test/dxvk-fps-limit into main 2026-09-06 08:40:03 +00:00
4 changed files with 642 additions and 3 deletions
+1 -1
View File
@@ -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
+201
View File
@@ -0,0 +1,201 @@
import os
import re
DEFAULT_REFRESH_RATE = 60
_MAX_FRAME_RATE_RE = re.compile(
r"^\s*(?P<comment>#\s*)?d3d9\.maxFrameRate\s*=\s*(?P<value>[+-]?\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
+214 -2
View File
@@ -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()
+226
View File
@@ -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()