From 2b60a79ae3fdcb96806d0be3dfd6e9f069811c91 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Sat, 12 Sep 2026 11:03:21 +0500 Subject: [PATCH] feat: add ReShade integration Signed-off-by: Boris Yumankulov --- .../share/portproton/scripts/functions_helper | 182 ++++++- build-aux/share/portproton/scripts/var | 9 + portprotonqt/dialogs/settings_dialog.py | 26 +- portprotonqt/dialogs/settings_reshade.py | 446 ++++++++++++++++++ portprotonqt/meson.build | 1 + portprotonqt/settings_manager.py | 1 + tests/test_portproton_config.py | 180 ++++++- tests/test_settings_search.py | 40 ++ 8 files changed, 879 insertions(+), 6 deletions(-) create mode 100644 portprotonqt/dialogs/settings_reshade.py diff --git a/build-aux/share/portproton/scripts/functions_helper b/build-aux/share/portproton/scripts/functions_helper index c6e50b58..1990c4bb 100755 --- a/build-aux/share/portproton/scripts/functions_helper +++ b/build-aux/share/portproton/scripts/functions_helper @@ -2428,8 +2428,9 @@ pw_specialk_set_ini_value () { [[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file" if grep -q "^\[$section\]" "$ini_file" ; then - if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then - sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file" + if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \ + | grep -q "^[[:space:]]*$key[[:space:]]*=" ; then + sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file" else sed -i "/^\[$section\]/a $key=$value" "$ini_file" fi @@ -2522,6 +2523,171 @@ pw_specialk_sync_files () { } +pw_reshade_sync_files () { + local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source + local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup + local PATH_TO_GAME="${PATH_TO_GAME:-}" + runtime="$root/reshade/${PW_RESHADE_VER}_Addon" + reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}" + if [[ ! -f "$reshade_exe" ]] ; then + print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE." + reshade_exe="$PW_EXE_FILE" + fi + if [[ "$PW_USE_RESHADE" == 1 ]] ; then + detect=$(python_module graphics_detector "$reshade_exe") + file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32 + highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2) + ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2) + vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2) + source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-) + [[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false + fi + if [[ -n "$PW_RESHADE_EXE" ]] ; then + PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")" + fi + if [[ "$PW_USE_RESHADE" == 1 ]] \ + && [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \ + || ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then + try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \ + "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" + create_new_dir "$runtime" + unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \ + "$runtime" silent || fatal "Failed to extract ReShade." + try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" + fi + if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then + try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll" + fi + if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then + try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll" + fi + if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then + try_remove_dir "$root/shaders" + create_new_dir "$root/shaders/Shaders" + create_new_dir "$root/shaders/Textures" + for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do + extract_dir="${PORT_DATA_PATH}/data/tmp/$package" + if [[ "$package" == immerse_shaders ]] ; then + try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \ + "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" + else + try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \ + "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" + fi + try_remove_dir "$extract_dir" + create_new_dir "$extract_dir" + unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \ + silent || fatal "Failed to extract $package." + find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \; + find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \; + try_remove_dir "$extract_dir" + try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" + done + fi + if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then + dll="Vulkan${arch}" + elif [[ "$PW_USE_RESHADE" == 1 ]] ; then + case "$highest" in + "DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;; + "DirectX 8"|"DirectX 9") dll=d3d9.dll ;; + "DirectDraw") return 0 ;; + *) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;; + esac + fi + case "$PW_RESHADE_API" in + vulkan) vulkan=true; dll="Vulkan${arch}" ;; + dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;; + esac + if [[ -f "$PATH_TO_GAME/reshade_version" ]] \ + && { [[ "$PW_USE_RESHADE" != 1 ]] \ + || [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then + installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version") + installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version") + if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then + installed_arch=${installed#Vulkan} + manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json" + manifest_reg=${manifest_reg//\\/\\\\\\\\} + get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \ + "$manifest_reg" "" "" user + if [[ "$installed_arch" == 32 ]] ; then + get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \ + "$manifest_reg" "" "" system + else + get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \ + "$manifest_reg" "" "" system + fi + fi + try_remove_file "$PATH_TO_GAME/$installed" + if [[ -L "$PATH_TO_GAME/${installed}.b" \ + && $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then + unlink "$PATH_TO_GAME/${installed}.b" + fi + [[ -e "$PATH_TO_GAME/${installed}.b" ]] \ + && mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed" + [[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll" + [[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll" + [[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \ + && mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll" + [[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders" + try_remove_file "$PATH_TO_GAME/ReShade.ini" + try_remove_file "$PATH_TO_GAME/ReShadePreset.ini" + try_remove_file "$PATH_TO_GAME/ReShade.log" + try_remove_file "$PATH_TO_GAME/reshade_version" + fi + [[ "$PW_USE_RESHADE" != 1 ]] && return 0 + [[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders" + try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders" + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders' + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures' + if [[ -n "$PW_RESHADE_FONT" ]] ; then + reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT") + else + reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf + fi + if [[ -f "$reshade_font" ]] ; then + font_win="Z:${reshade_font//\//\\\\}" + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win" + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win" + elif [[ -n "$PW_RESHADE_FONT" ]] ; then + print_warning "ReShade font not found: $PW_RESHADE_FONT" + fi + screenshot_path='.\\' + if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then + create_new_dir "$PW_RESHADE_SCREENSHOT_PATH" + screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}" + fi + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY" + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY" + pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path" + if [[ "$vulkan" == true ]] ; then + manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json" + manifest_reg=${manifest_reg//\\/\\\\\\\\} + get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user + if [[ "$arch" == 32 ]] ; then + get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system + else + get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system + fi + var_winedlloverride_update "vulkan-1=n,b" + printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version" + return 0 + fi + [[ ! -f "$PATH_TO_GAME/reshade_version" \ + && -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \ + && ( ! -L "$PATH_TO_GAME/$dll" \ + || $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \ + && mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b" + try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll" + try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll" + var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b" + if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then + [[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b" + try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll" + var_winedlloverride_update "d3d8=n,b" + fi + printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version" +} + pw_prepare_gamescope_launch () { [[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0" @@ -2957,6 +3123,7 @@ start_portproton () { pw_optiscaler_sync_files pw_specialk_sync_files + pw_reshade_sync_files if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then var_vkd3d_config_update dxr @@ -4052,7 +4219,6 @@ steamplay_launch () { pw_optiscaler_sync_files pw_specialk_sync_files - PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx" pw_prepare_gamescope_launch @@ -4118,6 +4284,8 @@ steamplay_launch () { fi fi + pw_reshade_sync_files + # Winetricks if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then update_winetricks @@ -4331,7 +4499,9 @@ gui_edit_db () { KEY_EDIT_DB_GUI=$RANDOM DISABLE_EDIT_DB_LIST="" PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF - PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK + PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE + PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH + PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY @@ -4365,6 +4535,10 @@ gui_edit_db () { DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN" fi [[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2" + [[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK" + if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then + DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE" + fi [[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC" diff --git a/build-aux/share/portproton/scripts/var b/build-aux/share/portproton/scripts/var index 91ffef6b..cd6d279b 100755 --- a/build-aux/share/portproton/scripts/var +++ b/build-aux/share/portproton/scripts/var @@ -16,6 +16,8 @@ export VKD3D_SAREK_VER="sarek-2.6.0" export PW_LSFG_VK_VER="2.0.0-dev25-p11" export PW_OPTISCALER_VER="v03-0.9.0-pre8" export PW_SPECIALK_VER="SK_26_5_15" +export PW_RESHADE_VER="6.8.0" +export PW_D3D8TO9_VER="1.15.1" export PW_GALLIUM_NINE_VER="0.10" export DOTPFX_VER="48v9" export DEFPFX_VER="v1" @@ -33,6 +35,13 @@ check_variables PROTON_USE_XALIA "0" check_variables PW_USE_RUNTIME "1" check_variables PW_USE_D7VK "0" check_variables PW_USE_SPECIALK "0" +check_variables PW_USE_RESHADE "0" +check_variables PW_RESHADE_API "auto" +check_variables PW_RESHADE_EXE "" +check_variables PW_RESHADE_FONT "" +check_variables PW_RESHADE_SCREENSHOT_PATH "" +check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0" +check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0" check_variables PW_USE_ESYNC "0" check_variables PW_USE_FSYNC "0" check_variables PW_USE_NTSYNC "0" diff --git a/portprotonqt/dialogs/settings_dialog.py b/portprotonqt/dialogs/settings_dialog.py index ae1a0266..bfa8668e 100644 --- a/portprotonqt/dialogs/settings_dialog.py +++ b/portprotonqt/dialogs/settings_dialog.py @@ -55,6 +55,7 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update from portprotonqt.dialogs.proton_manager import show_proton_manager from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin +from portprotonqt.dialogs.settings_reshade import RESHADE_ENV_KEYS, ReShadeSettingsMixin from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin from portprotonqt.localization import _, format_setting_name_for_display from portprotonqt.logger import get_logger @@ -85,6 +86,8 @@ DEFAULT_WINE_SETTING_KEYS = { 'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME', 'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE', } + + def _normalize_prefix_directories(prefixes_dir): if not os.path.isdir(prefixes_dir): return @@ -149,6 +152,7 @@ class ExeSettingsDialog( DraggableDialog, MangoHudSettingsMixin, GamescopeSettingsMixin, + ReShadeSettingsMixin, VkBasaltSettingsMixin, ): """Dialog for configuring executable-specific settings.""" @@ -207,6 +211,7 @@ class ExeSettingsDialog( self.advanced_settings_by_key = {} self.init_mangohud_state() self.init_gamescope_state() + self.init_reshade_state() self.init_vkbasalt_state() self.blocked_keys = set() self.numa_nodes = {} @@ -332,6 +337,8 @@ class ExeSettingsDialog( self.mangohud_tab_layout = QVBoxLayout(self.mangohud_tab) self.vkbasalt_tab = QWidget() self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab) + self.reshade_tab = QWidget() + self.reshade_tab_layout = QVBoxLayout(self.reshade_tab) self.gamescope_tab = QWidget() self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab) @@ -339,6 +346,7 @@ class ExeSettingsDialog( self.tab_widget.addTab(self.advanced_tab, _("Advanced")) self.tab_widget.addTab(self.mangohud_tab, "MangoHud") self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt") + self.tab_widget.addTab(self.reshade_tab, "ReShade") if self.gamescope_available: self.tab_widget.addTab(self.gamescope_tab, "Gamescope") @@ -462,6 +470,7 @@ class ExeSettingsDialog( self.setup_mangohud_tab() self.setup_vkbasalt_tab() + self.setup_reshade_tab() if self.gamescope_available: self.setup_gamescope_tab() @@ -557,6 +566,7 @@ class ExeSettingsDialog( or key in MANGOHUD_ENV_KEYS or key in GAMESCOPE_ENV_KEYS or key in VKBASALT_ENV_KEYS + or key in RESHADE_ENV_KEYS or key in TOGGLE_BOOL_KEYS ): if val.startswith('"') and val.endswith('"') and len(val) >= 2: @@ -591,6 +601,8 @@ class ExeSettingsDialog( self.current_settings[key] = '' for key in VKBASALT_ENV_KEYS: self.current_settings[key] = '' + for key in RESHADE_ENV_KEYS: + self.current_settings[key] = '' elif not self.user_conf: self.current_settings.setdefault('PW_MANGOHUD', '0') self.current_settings.setdefault('PW_VKBASALT', '0') @@ -619,6 +631,7 @@ class ExeSettingsDialog( self.populate_advanced() self.populate_mangohud() self.populate_vkbasalt() + self.populate_reshade() if self.gamescope_available: self.populate_gamescope() self.populate_favorites(select_tab=True) @@ -1391,6 +1404,7 @@ class ExeSettingsDialog( self._filter_mangohud_settings(search_text) self._filter_vkbasalt_settings(search_text) + self._filter_reshade_settings(search_text) self._filter_gamescope_settings(search_text) def apply_changes(self): @@ -1465,6 +1479,7 @@ class ExeSettingsDialog( if self.gamescope_available: gamescope_changes = self._collect_gamescope_changes() vkbasalt_changes = self._collect_vkbasalt_changes() + reshade_changes = self._collect_reshade_changes() if self.user_conf: if self.current_settings.get('PW_MANGOHUD') != '1': mangohud_changes = [ @@ -1481,7 +1496,14 @@ class ExeSettingsDialog( change for change in gamescope_changes if change.startswith('PW_GAMESCOPE=') ] - specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes + if self.current_settings.get('PW_USE_RESHADE') != '1': + reshade_changes = [ + change for change in reshade_changes + if change.startswith('PW_USE_RESHADE=') + ] + specialized_changes = ( + mangohud_changes + vkbasalt_changes + reshade_changes + gamescope_changes + ) if self.user_conf: defaults = ('', '0', '0.00', 'Home') specialized_changes = [ @@ -1677,6 +1699,8 @@ class ExeSettingsDialog( ) return False + if isinstance(obj, QWidget) and self._handle_reshade_key_button_event(obj, event): + return True if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event): return True diff --git a/portprotonqt/dialogs/settings_reshade.py b/portprotonqt/dialogs/settings_reshade.py new file mode 100644 index 00000000..0f2b06c3 --- /dev/null +++ b/portprotonqt/dialogs/settings_reshade.py @@ -0,0 +1,446 @@ +"""ReShade settings support for executable settings dialog.""" + +import os +from typing import Any, cast + +from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QKeyEvent, QKeySequence +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox +from portprotonqt.localization import _ +from portprotonqt.theme_manager import ThemeManager + +RESHADE_ENV_KEYS = [ + 'PW_RESHADE_EXE', + 'PW_RESHADE_API', + 'PW_RESHADE_FONT', + 'PW_RESHADE_SCREENSHOT_PATH', + 'PW_RESHADE_SCREENSHOT_KEY', + 'PW_RESHADE_OVERLAY_KEY', +] +RESHADE_API_OPTIONS = { + "Auto": "auto", + "DXGI": "dxgi.dll", + "DirectX 8/9": "d3d9.dll", + "OpenGL": "opengl32.dll", + "Vulkan": "vulkan", +} +RESHADE_SPECIAL_KEYS = { + Qt.Key.Key_Cancel: 3, + Qt.Key.Key_Backspace: 8, + Qt.Key.Key_Tab: 9, + Qt.Key.Key_Clear: 12, + Qt.Key.Key_Return: 13, + Qt.Key.Key_Enter: 13, + Qt.Key.Key_Pause: 19, + Qt.Key.Key_CapsLock: 20, + Qt.Key.Key_Escape: 27, + Qt.Key.Key_Space: 32, + Qt.Key.Key_PageUp: 33, + Qt.Key.Key_PageDown: 34, + Qt.Key.Key_End: 35, + Qt.Key.Key_Home: 36, + Qt.Key.Key_Left: 37, + Qt.Key.Key_Up: 38, + Qt.Key.Key_Right: 39, + Qt.Key.Key_Down: 40, + Qt.Key.Key_Print: 44, + Qt.Key.Key_Insert: 45, + Qt.Key.Key_Delete: 46, + Qt.Key.Key_Help: 47, + Qt.Key.Key_Meta: 91, + Qt.Key.Key_Menu: 93, + Qt.Key.Key_Sleep: 95, + Qt.Key.Key_NumLock: 144, + Qt.Key.Key_ScrollLock: 145, + Qt.Key.Key_Semicolon: 186, + Qt.Key.Key_Equal: 187, + Qt.Key.Key_Comma: 188, + Qt.Key.Key_Minus: 189, + Qt.Key.Key_Period: 190, + Qt.Key.Key_Slash: 191, + Qt.Key.Key_QuoteLeft: 192, + Qt.Key.Key_BracketLeft: 219, + Qt.Key.Key_Backslash: 220, + Qt.Key.Key_BracketRight: 221, + Qt.Key.Key_Apostrophe: 222, + Qt.Key.Key_Back: 166, + Qt.Key.Key_Forward: 167, + Qt.Key.Key_Refresh: 168, + Qt.Key.Key_Stop: 169, + Qt.Key.Key_Search: 170, + Qt.Key.Key_Favorites: 171, + Qt.Key.Key_HomePage: 172, + Qt.Key.Key_VolumeMute: 173, + Qt.Key.Key_VolumeDown: 174, + Qt.Key.Key_VolumeUp: 175, + Qt.Key.Key_MediaNext: 176, + Qt.Key.Key_MediaPrevious: 177, + Qt.Key.Key_MediaStop: 178, + Qt.Key.Key_MediaPlay: 179, + Qt.Key.Key_LaunchMail: 180, + Qt.Key.Key_LaunchMedia: 181, + Qt.Key.Key_Launch0: 182, + Qt.Key.Key_Launch1: 183, +} +RESHADE_KEYPAD_KEYS = { + Qt.Key.Key_Asterisk: 106, + Qt.Key.Key_Plus: 107, + Qt.Key.Key_Comma: 108, + Qt.Key.Key_Minus: 109, + Qt.Key.Key_Period: 110, + Qt.Key.Key_Slash: 111, +} +RESHADE_FIRST_FUNCTION_KEY = 112 +RESHADE_LAST_FUNCTION_KEY = 135 +RESHADE_FIRST_NUMPAD_KEY = 96 + + +class ReShadeSettingsMixin: + """Mixin with ReShade settings UI and serialization logic.""" + + theme: Any + exe_path: str | None + current_settings: dict[str, str] + original_values: dict[str, str] + reshade_tab: QWidget + reshade_tab_layout: QVBoxLayout + + def init_reshade_state(self) -> None: + self.reshade_original_values = {} + self.reshade_key_target = None + + def setup_reshade_tab(self) -> None: + """Create ReShade tab widgets.""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus) + scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE) + container = QWidget() + layout = QVBoxLayout(container) + layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing) + + actions = QGroupBox(_("Actions")) + actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + actions_layout = QGridLayout(actions) + self.reshade_enable_button = self._create_reshade_button("") + self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable) + actions_layout.addWidget(self.reshade_enable_button, 0, 0) + layout.addWidget(actions) + + self._add_reshade_settings_group(layout) + layout.addStretch() + + scroll.setWidget(container) + self.reshade_tab_layout.addWidget(scroll) + + def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None: + self.reshade_settings_group = QGroupBox(_("Settings")) + self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + settings_layout = QGridLayout(self.reshade_settings_group) + self.reshade_exe_edit = self._add_reshade_file_row( + settings_layout, 0, "EXE", ".exe" + ) + self.reshade_api_combo = CustomComboBox(theme=self.theme) + self.reshade_api_combo.view().window().setWindowFlags( + Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint + ) + self.reshade_api_combo.view().window().setAttribute( + Qt.WidgetAttribute.WA_TranslucentBackground + ) + self.reshade_api_combo.setStyleSheet( + self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE + ) + self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS)) + settings_layout.addWidget(QLabel("API"), 1, 0) + settings_layout.addWidget(self.reshade_api_combo, 1, 1) + self.reshade_font_combo = CustomComboBox(theme=self.theme) + self.reshade_font_combo.view().window().setWindowFlags( + Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint + ) + self.reshade_font_combo.view().window().setAttribute( + Qt.WidgetAttribute.WA_TranslucentBackground + ) + self.reshade_font_combo.setStyleSheet( + self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE + ) + self.reshade_font_combo.addItem(_("Default"), "") + settings_layout.addWidget(QLabel(_("Font")), 2, 0) + settings_layout.addWidget(self.reshade_font_combo, 2, 1) + self.reshade_screenshot_edit = self._add_reshade_directory_row( + settings_layout, 3, _("Screenshots") + ) + self.reshade_screenshot_key_button = self._create_reshade_key_button( + "screenshot" + ) + settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0) + settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1) + self.reshade_overlay_key_button = self._create_reshade_key_button("overlay") + settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0) + settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1) + parent_layout.addWidget(self.reshade_settings_group) + self._load_reshade_fonts() + + def _create_reshade_button(self, label: str) -> QPushButton: + button = QPushButton(label) + button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + return button + + def _add_reshade_file_row( + self, layout: QGridLayout, row: int, label: str, file_filter: str + ) -> QLineEdit: + line_edit = QLineEdit() + line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE) + button = AutoSizeButton( + _("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True) + ) + button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + button.clicked.connect( + lambda _checked=False: self._select_reshade_file(line_edit, file_filter) + ) + field_layout = QHBoxLayout() + field_layout.addWidget(line_edit) + field_layout.addWidget(button) + layout.addWidget(QLabel(label), row, 0) + layout.addLayout(field_layout, row, 1) + return line_edit + + def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None: + from portprotonqt.dialogs.file_explorer import FileExplorer + + current_path = line_edit.text().strip() + initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None + explorer = FileExplorer( + self, theme=self.theme, file_filter=file_filter, initial_path=initial_path + ) + explorer.file_signal.file_selected.connect(line_edit.setText) + explorer.exec() + + def _add_reshade_directory_row( + self, layout: QGridLayout, row: int, label: str + ) -> QLineEdit: + line_edit = QLineEdit() + line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE) + button = AutoSizeButton( + _("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True) + ) + button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + button.clicked.connect( + lambda _checked=False: self._select_reshade_directory(line_edit) + ) + field_layout = QHBoxLayout() + field_layout.addWidget(line_edit) + field_layout.addWidget(button) + layout.addWidget(QLabel(label), row, 0) + layout.addLayout(field_layout, row, 1) + return line_edit + + def _select_reshade_directory(self, line_edit: QLineEdit) -> None: + from portprotonqt.dialogs.file_explorer import FileExplorer + + initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "") + explorer = FileExplorer( + self, theme=self.theme, initial_path=initial_path, directory_only=True + ) + explorer.file_signal.file_selected.connect(line_edit.setText) + explorer.exec() + + def _create_reshade_key_button(self, target: str) -> QPushButton: + button = self._create_reshade_button("") + button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target)) + button.installEventFilter(cast(QWidget, self)) + return button + + def _capture_reshade_key(self, target: str) -> None: + self.reshade_key_target = target + button = self._get_reshade_key_button(target) + button.setText(_("Press a button to choose")) + button.grabKeyboard() + + def _get_reshade_key_button(self, target: str) -> QPushButton: + if target == "screenshot": + return self.reshade_screenshot_key_button + return self.reshade_overlay_key_button + + def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool: + target = self.reshade_key_target + if target is None or obj is not self._get_reshade_key_button(target): + return False + if event.type() != QEvent.Type.KeyPress: + return False + key_event = cast(QKeyEvent, event) + key_value = self._reshade_virtual_key( + key_event.key(), key_event.modifiers() + ) + if key_value is None: + return True + modifiers = key_event.modifiers() + value = ",".join(str(item) for item in ( + key_value, + int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)), + int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)), + int(bool(modifiers & Qt.KeyboardModifier.AltModifier)), + )) + self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value + button = self._get_reshade_key_button(target) + button.setText(QKeySequence(key_event.keyCombination()).toString()) + button.releaseKeyboard() + self.reshade_key_target = None + return True + + def _reshade_virtual_key( + self, key: int, modifiers: Qt.KeyboardModifier + ) -> int | None: + is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier) + if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value: + return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value + if is_keypad: + try: + keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key)) + except ValueError: + keypad_key = None + if keypad_key is not None: + return keypad_key + if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value: + return key + if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value: + return key + if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value: + return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value + try: + return RESHADE_SPECIAL_KEYS.get(Qt.Key(key)) + except ValueError: + return None + + def _format_reshade_key(self, value: str) -> str: + try: + key, control, shift, alt = (int(item) for item in value.split(",")) + except ValueError: + return value + qt_key = next( + (item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None + ) + if qt_key is not None: + name = QKeySequence(qt_key).toString() + elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY: + name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}" + else: + name = chr(key) + modifiers = [ + label for active, label in ( + (control, "Ctrl"), (shift, "Shift"), (alt, "Alt") + ) if active + ] + return "+".join([*modifiers, name]) + + def _load_reshade_fonts(self) -> None: + selected_family = self.current_settings.get('PW_RESHADE_FONT', '') + font_options = cast(Any, self)._get_mangohud_font_options() + families = {name for name, _path in font_options} + self.reshade_font_combo.clear() + self.reshade_font_combo.addItem(_("Default"), "") + for family in sorted(families, key=str.casefold): + self.reshade_font_combo.addItem(family, family) + self._select_reshade_font(selected_family) + + def _select_reshade_font(self, family: str) -> None: + for index in range(self.reshade_font_combo.count()): + if self.reshade_font_combo.itemData(index) == family: + self.reshade_font_combo.setCurrentIndex(index) + return + if family: + self.reshade_font_combo.addItem(family, family) + self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1) + + def populate_reshade(self) -> None: + """Populate ReShade tab from current settings.""" + reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or '' + self.reshade_exe_edit.setText(reshade_exe) + api_value = self.current_settings.get('PW_RESHADE_API', 'auto') + api_label = next( + (label for label, value in RESHADE_API_OPTIONS.items() if value == api_value), + "Auto", + ) + self.reshade_api_combo.setCurrentText(api_label) + font_family = self.current_settings.get('PW_RESHADE_FONT', '') + self._select_reshade_font(font_family) + screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '') + screenshot_key = self.current_settings.get( + 'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0' + ) + overlay_key = self.current_settings.get( + 'PW_RESHADE_OVERLAY_KEY', '36,0,0,0' + ) + self.reshade_screenshot_edit.setText(screenshot_path) + self.reshade_screenshot_key_button.setText( + self._format_reshade_key(screenshot_key) + ) + self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key)) + self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key + self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key + self.reshade_original_values = { + key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS + } + self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe + self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key + self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key + self._update_reshade_widgets() + + def toggle_reshade_enable(self) -> None: + enabled = self.current_settings.get('PW_USE_RESHADE') == '1' + self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1' + self._update_reshade_widgets() + + def _update_reshade_widgets(self) -> None: + enabled = self.current_settings.get('PW_USE_RESHADE') == '1' + self.reshade_enable_button.setText( + _("Disable {0}").format("ReShade") + if enabled else _("Enable {0}").format("ReShade") + ) + self.reshade_enable_button.setStyleSheet( + self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE + ) + self.reshade_settings_group.setVisible(enabled) + + def _collect_reshade_changes(self) -> list[str]: + changes = [] + enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0' + original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0' + if enabled != original_enabled: + changes.append(f"PW_USE_RESHADE={enabled}") + values = { + 'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(), + 'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()], + 'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''), + 'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(), + 'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'], + 'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'], + } + for key, value in values.items(): + if value != self.reshade_original_values.get(key, ''): + changes.append(f"{key}={value}") + return changes + + def _filter_reshade_settings(self, search_text: str) -> None: + enabled = self.current_settings.get('PW_USE_RESHADE') == '1' + self.reshade_settings_group.setVisible( + enabled and ( + not search_text + or search_text in "exe api font screenshot overlay" + or search_text in _("Settings").lower() + ) + ) diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index 28a6c0d1..5ac911c1 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -118,6 +118,7 @@ install_data( 'dialogs/settings_dialog.py', 'dialogs/settings_mangohud.py', 'dialogs/settings_gamescope.py', + 'dialogs/settings_reshade.py', 'dialogs/settings_vkbasalt.py', 'dialogs/proton_manager.py', 'dialogs/prefix_backup.py', diff --git a/portprotonqt/settings_manager.py b/portprotonqt/settings_manager.py index 633df978..20f8ad00 100644 --- a/portprotonqt/settings_manager.py +++ b/portprotonqt/settings_manager.py @@ -149,6 +149,7 @@ def get_toggle_settings(): 'PW_USE_SPECIALK': _("Enable SpecialK (injection library for fixing graphics, latency and more)"), # Upscalers and Frame Generation 'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"), + 'PW_USE_RESHADE': _("Enable ReShade post-processing"), 'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"), 'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"), # Ray Tracing diff --git a/tests/test_portproton_config.py b/tests/test_portproton_config.py index 4d9f8a87..1cf8301f 100644 --- a/tests/test_portproton_config.py +++ b/tests/test_portproton_config.py @@ -1,8 +1,9 @@ """Tests for config/portproton.py — exec_line parsing, icon sanitization, launcher tail extraction.""" import os +import subprocess from pathlib import Path -from pytest import MonkeyPatch +from pytest import MonkeyPatch, mark from portprotonqt.config.portproton import ( extract_exec_target_path, @@ -192,3 +193,180 @@ class TestThemedLaunchIconNames: def test_reg_has_icon(self): assert "reg" in THEMED_LAUNCH_ICON_NAMES[".reg"] + + +def test_run_after_batch_is_created_next_to_exe() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + assert 'run_after_dir="$(dirname "${PW_EXE_FILE}")"' in helper + assert 'pw_exe_file_win="$("${WINELOADER}" winepath -w "${PW_EXE_FILE}"' in helper + assert "chcp 65001 >nul" in helper + assert 'start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}' in helper + assert 'start "" /unix "${PW_RUN_AFTER_EXE}"' in helper + assert 'LAUNCH_PARAMETERS="" proxy_launch_parameters="" \\' in helper + assert 'pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"' in helper + + +def test_game_launch_marker_is_emitted_for_wine_and_proton() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + marker = "printf '%s\\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'" + assert helper.count(marker) == 2 + + +def test_user_conf_get_without_name_lists_active_values(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definition = helper.split("manage_user_conf_value () {", 1)[1].split( + "use_exiftool () {", 1 + )[0] + user_conf = tmp_path / "user.conf" + user_conf.write_text( + '# comment\nexport PW_USE_ESYNC="1"\n# export PW_USE_FSYNC="1"\n', + encoding="utf-8", + ) + script = "manage_user_conf_value () {" + definition + '\nmanage_user_conf_value get\n' + + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + env={"PATH": os.defpath, "USER_CONF": str(user_conf)}, check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == 'PW_USE_ESYNC="1"\n' + + +@mark.parametrize("runtime,logging", [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1")]) +def test_wine_exit_code_survives_log_output_and_wineserver_wait( + tmp_path: Path, runtime: str, logging: str, +) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text() + definition = helper.split("pw_run () {", 1)[1].split("export -f pw_run", 1)[0] + script = "pw_run () {" + definition + ''' +check_variables () { :; } +pw_launch_wrapper () { return 37; } +wait_wineserver () { return 0; } +print_info () { :; } +PATH_TO_GAME="$1" +PW_TMPFS_PATH="$1" +PW_LOG_FILE="$1/wine.log" +PW_EXE_FILE="$1/game.exe" +PW_USE_RUNTIME="$2" +PW_LOG="$3" +pw_run "$PW_EXE_FILE" +exit $? +''' + (tmp_path / "game.exe").touch() + + result = subprocess.run( + ["bash", "-c", script, "bash", str(tmp_path), runtime, logging], + capture_output=True, text=True, env={"PATH": os.defpath}, check=False, + ) + + assert result.returncode == 37, result.stderr + assert result.stdout.count("PORTPROTONQT_GAME_EXIT_CODE=37") == 1 + + +def test_vk_gpu_info_uses_build_aux_binary() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + assert '/../../../bin/vk_gpu_info"' in helper + assert "dev-scripts/vk_gpu_info" not in helper + + +def test_reshade_prefers_directx_for_unity() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + assert "source=$(echo \"$detect\" | grep '^SOURCE=' | cut -d= -f2-)" in helper + assert ( + '[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] ' + "&& vulkan=false" + ) in helper + + +def test_reshade_and_optiscaler_are_mutually_exclusive() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"' in helper + assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"' in helper + + +def test_reshade_repeated_link_does_not_create_nested_shaders(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + link_function = helper.split("try_force_link_dir () {", 1)[1].split("\n}", 1)[0] + shaders = tmp_path / "shaders" + shaders.mkdir() + game = tmp_path / "game" + game.mkdir() + script = f'try_force_link_dir () {{{link_function}\n}}\n' + link_call = helper.split(' [[ "$PW_USE_RESHADE" != 1 ]] && return 0', 1)[1] + link_call = link_call.split(' pw_specialk_set_ini_value', 1)[0] + script += f'root="$1"\nPATH_TO_GAME="$2"\n{link_call * 2}' + + subprocess.run( + ["bash", "-c", script, "bash", str(tmp_path), str(game)], + check=True, + ) + + assert (game / "ReShade_shaders").resolve() == shaders + assert not (shaders / "shaders").exists() + + +def test_reshade_disable_cleans_unreal_shipping_directory(tmp_path: Path) -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + sync_function = helper.split("pw_reshade_sync_files () {", 1)[1].split("\n}", 1)[0] + game_root = tmp_path / "game" + game_root.mkdir() + game_exe = game_root / "Praest.exe" + game_exe.touch() + game = game_root / "Praest" / "Binaries" / "Win64" + game.mkdir(parents=True) + runtime = tmp_path / "plugins" / "reshade_windows" / "reshade" / "6.8.0_Addon" + runtime.mkdir(parents=True) + reshade_dll = runtime / "ReShade32.dll" + reshade_dll.touch() + (game / "opengl32.dll").symlink_to(reshade_dll) + (game / "opengl32.dll.b").symlink_to(reshade_dll) + (game / "ReShadePreset.ini").write_text("[GENERAL]\n") + (game / "reshade_version").write_text("6.8.0_Addon\nopengl32.dll\n") + script = f'pw_reshade_sync_files () {{{sync_function}\n}}\n' + script += 'try_remove_file () { rm -f "$1"; }\npw_reshade_sync_files\n' + + subprocess.run( + ["bash", "-c", script], + check=True, + env={ + **os.environ, + "PW_EXE_FILE": str(game_exe), + "PW_PLUGINS_PATH": str(tmp_path / "plugins"), + "PW_USE_RESHADE": "0", + "PW_RESHADE_API": "", + "PW_RESHADE_EXE": "", + "PATH_TO_GAME": str(game), + }, + ) + + assert not (game / "opengl32.dll").exists() + assert not (game / "opengl32.dll.b").exists() + assert not (game / "ReShadePreset.ini").exists() + + +def test_ini_writer_matches_spaces_around_equals() -> None: + helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text( + encoding="utf-8", + ) + + assert '^[[:space:]]*$key[[:space:]]*=' in helper diff --git a/tests/test_settings_search.py b/tests/test_settings_search.py index 0db73955..1603f535 100644 --- a/tests/test_settings_search.py +++ b/tests/test_settings_search.py @@ -37,6 +37,7 @@ from portprotonqt.dialogs.settings_mangohud import ( MANGOHUD_TOGGLE_DESCRIPTIONS, MangoHudSettingsMixin, ) +from portprotonqt.dialogs.settings_reshade import ReShadeSettingsMixin from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin @@ -90,6 +91,45 @@ def test_settings_dialog_search_accepts_typing_without_search_focus() -> None: QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) +def test_reshade_collects_enable_exe_api_and_font() -> None: + settings = ReShadeSettingsMixin() + settings.current_settings = { + "PW_USE_RESHADE": "1", + "PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0", + "PW_RESHADE_OVERLAY_KEY": "36,0,0,0", + } + settings.original_values = {"PW_USE_RESHADE": "0"} + settings.reshade_original_values = { + "PW_RESHADE_EXE": "", + "PW_RESHADE_API": "auto", + "PW_RESHADE_FONT": "", + "PW_RESHADE_SCREENSHOT_PATH": "", + "PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0", + "PW_RESHADE_OVERLAY_KEY": "36,0,0,0", + } + settings.reshade_exe_edit = QLineEdit("/games/game.exe") + settings.reshade_api_combo = CustomComboBox() + settings.reshade_api_combo.addItems(["Auto", "Vulkan"]) + settings.reshade_api_combo.setCurrentText("Vulkan") + settings.reshade_font_combo = CustomComboBox() + settings.reshade_font_combo.addItem("DejaVu Sans", "DejaVu Sans") + settings.reshade_screenshot_edit = QLineEdit("/screenshots") + + assert settings._collect_reshade_changes() == [ + "PW_USE_RESHADE=1", + "PW_RESHADE_EXE=/games/game.exe", + "PW_RESHADE_API=vulkan", + "PW_RESHADE_FONT=DejaVu Sans", + "PW_RESHADE_SCREENSHOT_PATH=/screenshots", + ] + assert settings._reshade_virtual_key( + Qt.Key.Key_Semicolon.value, Qt.KeyboardModifier.NoModifier + ) == 186 + assert settings._reshade_virtual_key( + Qt.Key.Key_1.value, Qt.KeyboardModifier.KeypadModifier + ) == 97 + + def test_mangohud_search_ignores_unavailable_categories() -> None: settings = MangoHudSettingsMixin() settings.mangohud_category_groups = {} -- 2.54.0