fix: harden DXVK ownership and local builds

This commit is contained in:
2026-08-27 17:17:13 +02:00
parent f6629a72cf
commit d041363779
3 changed files with 241 additions and 35 deletions
+39 -1
View File
@@ -1 +1,39 @@
pyinstaller --noconsole --onefile --icon=PurpleWowLogo.ico --add-data "PurpleWowLogo.ico;." --add-data "Payload;Payload" --add-data "vanilla-tweaks.exe;." --name "WoW_Modernization_Tool" setup_tool_dynamic.py
@echo off
setlocal
cd /d "%~dp0"
where py >nul 2>nul
if errorlevel 1 (
echo ERROR: Python launcher ^(py.exe^) was not found.
exit /b 1
)
py -3.11 -c "import sys; a=sys.version_info[:3]; sys.exit(0 if a==(3,11,9) else f'ERROR: Python 3.11.9 is required; found {a[0]}.{a[1]}.{a[2]}')"
if errorlevel 1 exit /b 1
py -3.11 -m pip install --disable-pip-version-check -r requirements-build.txt
if errorlevel 1 exit /b 1
py -3.11 -m py_compile setup_tool.py setup_tool_dynamic.py remote_packages.py tests/test_safety.py
if errorlevel 1 exit /b 1
py -3.11 -m unittest discover -s tests -v
if errorlevel 1 exit /b 1
py -3.11 -m PyInstaller ^
--noconsole ^
--onefile ^
--icon=PurpleWowLogo.ico ^
--add-data "PurpleWowLogo.ico;." ^
--add-data "Payload;Payload" ^
--add-data "vanilla-tweaks.exe;." ^
--name "WoW_Modernization_Tool" ^
setup_tool_dynamic.py
if errorlevel 1 exit /b 1
if not exist "dist\WoW_Modernization_Tool.exe" (
echo ERROR: Build output is missing.
exit /b 1
)
echo Build complete: dist\WoW_Modernization_Tool.exe
+125 -34
View File
@@ -1236,41 +1236,68 @@ class WowSetupTool:
return True
def clean_unselected_files(self, target):
"""Removes managed files/folders if they were explicitly unselected by the user."""
# 1. Clean AutoLogin files if unselected
"""Remove explicitly unselected tool-managed files without hiding failures."""
def remove_managed_file(path, label):
if not os.path.lexists(path):
return
try:
os.remove(path)
except OSError as exc:
raise RuntimeError(
f"Could not remove {label}. Close WoW and any program using the file, then try again."
) from exc
def remove_managed_tree(path, label):
if not os.path.lexists(path):
return
try:
shutil.rmtree(path)
except OSError as exc:
raise RuntimeError(
f"Could not remove {label}. Close WoW and any program using the folder, then try again."
) from exc
# 1. Clean AutoLogin files if unselected.
if not self.install_autologin.get():
glue_dir = os.path.join(target, "Data", "Interface", "GlueXML")
for file_name in ["AutoLogin.lua", "AutoLogin.xml", "GlueXML.toc"]:
file_path = os.path.join(glue_dir, file_name)
if os.path.exists(file_path):
try: os.remove(file_path)
except: pass
if os.path.exists(glue_dir) and not os.listdir(glue_dir):
try: os.rmdir(glue_dir)
except: pass
remove_managed_file(
os.path.join(glue_dir, file_name),
f"managed AutoLogin file {file_name}",
)
if os.path.isdir(glue_dir):
try:
if not os.listdir(glue_dir):
os.rmdir(glue_dir)
except OSError:
# Leaving an empty directory behind is harmless; unlike a
# stale DLL/AddOn it cannot change the running client.
pass
# 2. Clean unselected Core Plugins and their dependent AddOns
# 2. Clean unselected Core Plugins and their dependent AddOns.
for dll_name, var in self.core_plugins.items():
if not var.get():
dll_path = os.path.join(target, dll_name)
if os.path.exists(dll_path):
try: os.remove(dll_path)
except: pass
remove_managed_file(
os.path.join(target, dll_name),
f"managed plugin {dll_name}",
)
addon_folder = self.addon_dependencies.get(dll_name)
if addon_folder:
addon_path = os.path.join(target, "Interface", "AddOns", addon_folder)
if os.path.exists(addon_path):
shutil.rmtree(addon_path, ignore_errors=True)
remove_managed_tree(
os.path.join(target, "Interface", "AddOns", addon_folder),
f"managed addon {addon_folder}",
)
# 3. Clean unselected Optional plugins
# 3. Clean unselected Optional plugins.
for dll_name, var in self.optional_plugins.items():
if not var.get():
dll_path = os.path.join(target, dll_name)
if os.path.exists(dll_path):
try: os.remove(dll_path)
except: pass
remove_managed_file(
os.path.join(target, dll_name),
f"managed plugin {dll_name}",
)
def configure_script_memory(self, target):
"""Optionally set AddOn Script Memory to 0 (unlimited) in WTF/Config.wtf."""
@@ -1996,23 +2023,87 @@ oLink.Save
if os.path.exists(source_file): shutil.copy2(source_file, target)
def _previous_settings_used_dxvk(self, target):
"""Use the previous saved selection as legacy ownership proof."""
settings_path = self._settings_path(target)
if not os.path.isfile(settings_path):
return False
try:
with open(settings_path, "r", encoding="utf-8") as handle:
saved = json.load(handle)
except (OSError, json.JSONDecodeError, ValueError, TypeError):
return False
return (
isinstance(saved, dict)
and saved.get("rendering_mode") == "dxvk"
)
def configure_dxvk(self, target):
"""Manage DXVK transactionally and never delete unowned renderer files."""
payload_dir = os.path.join(get_base_path(), "Payload")
d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll")
conf_src = os.path.join(payload_dir, "dxvk.conf")
d3d9_target = os.path.join(target, "d3d9.dll")
conf_target = os.path.join(target, "dxvk.conf")
managed_id = "renderer_dxvk"
managed_files = remote_packages._load_managed_manifest(target, managed_id)
if self.rendering_mode.get() == "dxvk":
d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll")
conf_src = os.path.join(payload_dir, "dxvk.conf")
missing = [
path for path in (d3d9_src, conf_src)
if not os.path.isfile(path)
]
if missing:
raise FileNotFoundError(
"Bundled DXVK files are incomplete: "
+ ", ".join(os.path.basename(path) for path in missing)
)
# Migrate installations created before renderer ownership manifests
# existed. A saved DXVK selection is explicit evidence that these
# two paths were managed by this tool on the previous Apply.
if not managed_files and self._previous_settings_used_dxvk(target):
for path in (conf_target, d3d9_target):
if os.path.lexists(path):
try:
os.remove(path)
except OSError as exc:
raise RuntimeError(
f"Could not migrate the previous managed DXVK file {os.path.basename(path)}. "
"Close WoW and any program using it, then try again."
) from exc
remote_packages._install_managed_files_transactional(
target,
managed_id,
[
(d3d9_src, "d3d9.dll"),
(conf_src, "dxvk.conf"),
],
revision="1",
)
return
# New managed installs restore any files that existed before DXVK was
# enabled. This means a user's own d3d9.dll/dxvk.conf is never lost.
if managed_files:
remote_packages.remove_managed_mod(target, managed_id)
return
# Legacy cleanup is allowed only with explicit previous settings proof.
# Unknown/manual renderer files are deliberately preserved.
if self._previous_settings_used_dxvk(target):
for path in (conf_target, d3d9_target):
if os.path.lexists(path):
try:
os.remove(path)
except OSError as exc:
raise RuntimeError(
f"Could not remove the previous managed DXVK file {os.path.basename(path)}. "
"Close WoW and any program using it, then try again."
) from exc
if os.path.exists(d3d9_src):
shutil.copy2(d3d9_src, d3d9_target)
if os.path.exists(conf_src):
shutil.copy2(conf_src, conf_target)
else:
for path in (d3d9_target, conf_target):
if os.path.exists(path):
os.remove(path)
def _managed_dll_entries(self):
"""Return every dlls.txt entry owned by this tool."""
+77
View File
@@ -155,6 +155,83 @@ class WdbBlockerTests(unittest.TestCase):
self.assertEqual(handle.read(), b"foreign")
class DxvkOwnershipTests(unittest.TestCase):
def make_tool(self, mode):
tool = WowSetupTool.__new__(WowSetupTool)
tool.rendering_mode = FakeVar(mode)
return tool
def make_payload(self, root):
payload = os.path.join(root, "Payload")
dxvk = os.path.join(payload, "DXVK_Standard")
os.makedirs(dxvk)
with open(os.path.join(dxvk, "d3d9.dll"), "wb") as handle:
handle.write(b"bundled-dxvk")
with open(os.path.join(payload, "dxvk.conf"), "wb") as handle:
handle.write(b"bundled-conf")
def test_enable_then_disable_restores_preexisting_renderer_files(self):
tool = self.make_tool("dxvk")
with tempfile.TemporaryDirectory() as bundle, tempfile.TemporaryDirectory() as target:
self.make_payload(bundle)
d3d9 = os.path.join(target, "d3d9.dll")
conf = os.path.join(target, "dxvk.conf")
with open(d3d9, "wb") as handle:
handle.write(b"user-d3d9")
with open(conf, "wb") as handle:
handle.write(b"user-conf")
with mock.patch("setup_tool.get_base_path", return_value=bundle):
tool.configure_dxvk(target)
with open(d3d9, "rb") as handle:
self.assertEqual(handle.read(), b"bundled-dxvk")
with open(conf, "rb") as handle:
self.assertEqual(handle.read(), b"bundled-conf")
tool.rendering_mode.set("directx9")
tool.configure_dxvk(target)
with open(d3d9, "rb") as handle:
self.assertEqual(handle.read(), b"user-d3d9")
with open(conf, "rb") as handle:
self.assertEqual(handle.read(), b"user-conf")
def test_directx9_preserves_unowned_renderer_files(self):
tool = self.make_tool("directx9")
with tempfile.TemporaryDirectory() as target:
d3d9 = os.path.join(target, "d3d9.dll")
conf = os.path.join(target, "dxvk.conf")
with open(d3d9, "wb") as handle:
handle.write(b"manual-d3d9")
with open(conf, "wb") as handle:
handle.write(b"manual-conf")
tool.configure_dxvk(target)
with open(d3d9, "rb") as handle:
self.assertEqual(handle.read(), b"manual-d3d9")
with open(conf, "rb") as handle:
self.assertEqual(handle.read(), b"manual-conf")
class StrictCleanupTests(unittest.TestCase):
def test_locked_managed_plugin_removal_is_not_silently_ignored(self):
tool = WowSetupTool.__new__(WowSetupTool)
tool.install_autologin = FakeVar(True)
tool.core_plugins = {"CorePlugin.dll": FakeVar(False)}
tool.optional_plugins = {}
tool.addon_dependencies = {}
with tempfile.TemporaryDirectory() as target:
dll_path = os.path.join(target, "CorePlugin.dll")
with open(dll_path, "wb") as handle:
handle.write(b"dll")
with mock.patch("setup_tool.os.remove", side_effect=PermissionError("locked")):
with self.assertRaises(RuntimeError):
tool.clean_unselected_files(target)
class ManagedPackageTests(unittest.TestCase):
def test_mpq_revision_and_magic_are_both_checked(self):
with tempfile.TemporaryDirectory() as root: