diff options
| author | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2025-07-22 13:33:13 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-22 13:33:13 -0400 |
| commit | 42668d412105b972e0cc7440b64e8e24aeef4587 (patch) | |
| tree | c8f20ccbe508ac6684a38c115d63602476575493 | |
| parent | 97bb41947bd44a712ad26905771a9d2cc4692878 (diff) | |
| parent | 43def41747d3b75bb547b649a00f12653c3ae537 (diff) | |
| download | decky-lsfg-vk-42668d412105b972e0cc7440b64e8e24aeef4587.tar.gz decky-lsfg-vk-42668d412105b972e0cc7440b64e8e24aeef4587.zip | |
Merge pull request #67 from xXJSONDeruloXx/workaround-envs
add workaround env vars, rm old tests
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | justfile | 7 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 118 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema_generated.py | 131 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 108 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration_helpers_generated.py | 22 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 7 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 28 | ||||
| -rw-r--r-- | scripts/generate_python_boilerplate.py | 329 | ||||
| -rw-r--r-- | scripts/generate_ts_schema.py | 158 | ||||
| -rw-r--r-- | shared_config.py | 43 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 11 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 63 | ||||
| -rw-r--r-- | src/config/configSchema.ts | 27 | ||||
| -rw-r--r-- | src/config/generatedConfigSchema.ts | 31 | ||||
| -rw-r--r-- | tests/conftest.py | 31 | ||||
| -rw-r--r-- | tests/test_configuration.py | 190 | ||||
| -rw-r--r-- | tests/test_dll_detection.py | 129 | ||||
| -rw-r--r-- | tests/test_installation.py | 150 |
20 files changed, 880 insertions, 710 deletions
@@ -55,3 +55,8 @@ out/* cli/ cli/* cli/decky + +# generated files +py_modules/lsfg_vk/config_schema_generated.py +py_modules/lsfg_vk/configuration_helpers_generated.py +src/config/generatedConfigSchema.ts @@ -1,8 +1,11 @@ default: - echo "Available recipes: build, test, clean" + echo "Available recipes: build, test, clean, generate-schema" + +generate-schema: + python3 scripts/generate_ts_schema.py build: - sudo rm -rf node_modules && .vscode/build.sh + python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh test: scp "out/Lossless Scaling.zip" deck@192.168.0.6:~/Desktop diff --git a/package.json b/package.json index 9fcf10d..ee18530 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decky-lossless-scaling-vk", - "version": "0.6.9", + "version": "0.6.10", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index c82d1d3..bbace42 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -19,6 +19,9 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_field_names, get_defaults, get_field_types +# Import auto-generated configuration components +from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic + @dataclass class ConfigField: @@ -52,45 +55,27 @@ CONFIG_SCHEMA["dll"] = ConfigField( description="specify where Lossless.dll is stored" ) -# Fields that should ONLY be in the lsfg script, not in TOML config +# Get script-only fields dynamically from shared config SCRIPT_ONLY_FIELDS = { - "dxvk_frame_rate": ConfigField( - name="dxvk_frame_rate", - field_type=ConfigFieldType.INTEGER, - default=0, - description="base framerate cap for DirectX games, before frame multiplier (0 = disabled, requires game re-launch)" - ), - - "enable_wow64": ConfigField( - name="enable_wow64", - field_type=ConfigFieldType.BOOLEAN, - default=False, - description="enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)" - ), - - "disable_steamdeck_mode": ConfigField( - name="disable_steamdeck_mode", - field_type=ConfigFieldType.BOOLEAN, - default=False, - description="disable Steam Deck mode (unlocks hidden settings in some games)" + field_name: ConfigField( + name=field_def["name"], + field_type=ConfigFieldType(field_def["fieldType"]), + default=field_def["default"], + description=field_def["description"] ) + for field_name, field_def in CONFIG_SCHEMA_DEF.items() + if field_def.get("location") == "script" } # Complete configuration schema (TOML + script-only fields) COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS} -class ConfigurationData(TypedDict): - """Type-safe configuration data structure""" - dll: str - multiplier: int - flow_scale: float - performance_mode: bool - hdr_mode: bool - experimental_present_mode: str - dxvk_frame_rate: int - enable_wow64: bool - disable_steamdeck_mode: bool +# Import auto-generated configuration components +from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic + +# Note: ConfigurationData is now imported from generated file +# No need to manually maintain the TypedDict anymore! class ConfigurationManager: @@ -186,7 +171,9 @@ class ConfigurationManager: if config.get("dll"): lines.append("[global]") lines.append(f"# specify where Lossless.dll is stored") - lines.append(f'dll = "{config["dll"]}"') + # Generate TOML lines for TOML fields only - USE GENERATED CONSTANTS + from .config_schema_generated import DLL + lines.append(f'dll = "{config[DLL]}"') lines.append("") # Add game section with process name for LSFG_PROCESS approach @@ -263,9 +250,10 @@ class ConfigurationManager: elif value.startswith("'") and value.endswith("'"): value = value[1:-1] - # Handle global section (dll only) + # Handle global section (dll only) - USE GENERATED CONSTANTS if in_global_section and key == "dll": - config["dll"] = value + from .config_schema_generated import DLL + config[DLL] = value # Handle game section elif in_game_section: @@ -305,42 +293,9 @@ class ConfigurationManager: Returns: Dict containing parsed script-only field values """ - script_values = {} - - try: - lines = script_content.split('\n') - - for line in lines: - line = line.strip() - - # Skip comments, empty lines, and non-export lines - if not line or line.startswith('#') or not line.startswith('export '): - continue - - # Parse export statements: export VAR=value - if '=' in line: - # Remove 'export ' prefix - export_line = line[len('export '):] - key, value = export_line.split('=', 1) - key = key.strip() - value = value.strip() - - # Map environment variables to config field names - if key == "DXVK_FRAME_RATE": - try: - script_values["dxvk_frame_rate"] = int(value) - except ValueError: - pass - elif key == "PROTON_USE_WOW64": - script_values["enable_wow64"] = value == "1" - elif key == "SteamDeck": - script_values["disable_steamdeck_mode"] = value == "0" - - except (ValueError, KeyError, IndexError) as e: - # If parsing fails, log the error and return empty dict (will use defaults) - print(f"Error parsing script content: {e}") - - return script_values + # Use auto-generated parsing logic + parse_script_values = get_script_parsing_logic() + return parse_script_values(script_content.split('\n')) @staticmethod def merge_config_with_script(toml_config: ConfigurationData, script_values: Dict[str, Union[bool, int, str]]) -> ConfigurationData: @@ -363,21 +318,8 @@ class ConfigurationManager: return cast(ConfigurationData, merged_config) @staticmethod - def create_config_from_args(dll: str, multiplier: int, flow_scale: float, - performance_mode: bool, hdr_mode: bool, - experimental_present_mode: str = "fifo", - dxvk_frame_rate: int = 0, - enable_wow64: bool = False, - disable_steamdeck_mode: bool = False) -> ConfigurationData: - """Create configuration from individual arguments""" - return cast(ConfigurationData, { - "dll": dll, - "multiplier": multiplier, - "flow_scale": flow_scale, - "performance_mode": performance_mode, - "hdr_mode": hdr_mode, - "experimental_present_mode": experimental_present_mode, - "dxvk_frame_rate": dxvk_frame_rate, - "enable_wow64": enable_wow64, - "disable_steamdeck_mode": disable_steamdeck_mode - }) + @staticmethod + def create_config_from_args(**kwargs) -> ConfigurationData: + """Create configuration from keyword arguments - USES GENERATED CODE""" + from .config_schema_generated import create_config_dict + return create_config_dict(**kwargs) diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py new file mode 100644 index 0000000..cc90207 --- /dev/null +++ b/py_modules/lsfg_vk/config_schema_generated.py @@ -0,0 +1,131 @@ +""" +Auto-generated configuration schema components from shared_config.py +DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build +""" + +from typing import TypedDict, Dict, Any, Union, cast +from enum import Enum +import sys +from pathlib import Path + +# Import shared configuration constants +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType + +# Field name constants for type-safe access +DLL = "dll" +MULTIPLIER = "multiplier" +FLOW_SCALE = "flow_scale" +PERFORMANCE_MODE = "performance_mode" +HDR_MODE = "hdr_mode" +EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" +DXVK_FRAME_RATE = "dxvk_frame_rate" +ENABLE_WOW64 = "enable_wow64" +DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" +MANGOHUD_WORKAROUND = "mangohud_workaround" +DISABLE_VKBASALT = "disable_vkbasalt" + + +class ConfigurationData(TypedDict): + """Type-safe configuration data structure - AUTO-GENERATED""" + dll: str + multiplier: int + flow_scale: float + performance_mode: bool + hdr_mode: bool + experimental_present_mode: str + dxvk_frame_rate: int + enable_wow64: bool + disable_steamdeck_mode: bool + mangohud_workaround: bool + disable_vkbasalt: bool + + +def get_script_parsing_logic(): + """Return the script parsing logic as a callable""" + def parse_script_values(lines): + script_values = {} + for line in lines: + line = line.strip() + if not line or line.startswith("#") or not line.startswith("export "): + continue + if "=" in line: + export_line = line[len("export "):] + key, value = export_line.split("=", 1) + key = key.strip() + value = value.strip() + + # Auto-generated parsing logic: + if key == "DXVK_FRAME_RATE": + try: + script_values["dxvk_frame_rate"] = int(value) + except ValueError: + pass + if key == "PROTON_USE_WOW64": + script_values["enable_wow64"] = value == "1" + if key == "SteamDeck": + script_values["disable_steamdeck_mode"] = value == "0" + if key == "MANGOHUD": + script_values["mangohud_workaround"] = value == "1" + if key == "DISABLE_VKBASALT": + script_values["disable_vkbasalt"] = value == "1" + + return script_values + return parse_script_values + + +def get_script_generation_logic(): + """Return the script generation logic as a callable""" + def generate_script_lines(config): + lines = [] + dxvk_frame_rate = config.get("dxvk_frame_rate", 0) + if dxvk_frame_rate > 0: + lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}") + if config.get("enable_wow64", False): + lines.append("export PROTON_USE_WOW64=1") + if config.get("disable_steamdeck_mode", False): + lines.append("export SteamDeck=0") + if config.get("mangohud_workaround", False): + lines.append("export MANGOHUD=1") + if config.get("disable_vkbasalt", False): + lines.append("export DISABLE_VKBASALT=1") + return lines + return generate_script_lines + + +def get_function_parameters() -> str: + """Return function signature parameters""" + return """dll: str = "/games/Lossless Scaling/Lossless.dll", + multiplier: int = 1, + flow_scale: float = 0.8, + performance_mode: bool = True, + hdr_mode: bool = False, + experimental_present_mode: str = "fifo", + dxvk_frame_rate: int = 0, + enable_wow64: bool = False, + disable_steamdeck_mode: bool = False, + mangohud_workaround: bool = False, + disable_vkbasalt: bool = False""" + + +def create_config_dict(**kwargs) -> ConfigurationData: + """Create configuration dictionary from keyword arguments""" + return cast(ConfigurationData, { + "dll": kwargs.get("dll"), + "multiplier": kwargs.get("multiplier"), + "flow_scale": kwargs.get("flow_scale"), + "performance_mode": kwargs.get("performance_mode"), + "hdr_mode": kwargs.get("hdr_mode"), + "experimental_present_mode": kwargs.get("experimental_present_mode"), + "dxvk_frame_rate": kwargs.get("dxvk_frame_rate"), + "enable_wow64": kwargs.get("enable_wow64"), + "disable_steamdeck_mode": kwargs.get("disable_steamdeck_mode"), + "mangohud_workaround": kwargs.get("mangohud_workaround"), + "disable_vkbasalt": kwargs.get("disable_vkbasalt"), + }) + + +# Field lists for dynamic operations +TOML_FIELDS = ['dll', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode'] +SCRIPT_FIELDS = ['dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt'] +ALL_FIELDS = ['dll', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt'] diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 47d0ebc..b9ee174 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -6,7 +6,9 @@ from pathlib import Path from typing import Dict, Any from .base_service import BaseService -from .config_schema import ConfigurationManager, ConfigurationData, CONFIG_SCHEMA +from .config_schema import ConfigurationManager, CONFIG_SCHEMA +from .config_schema_generated import ConfigurationData, get_script_generation_logic +from .configuration_helpers_generated import log_configuration_update from .types import ConfigurationResponse @@ -60,34 +62,59 @@ class ConfigurationService(BaseService): f"Using default configuration due to parse error: {str(e)}", config=config) - def update_config(self, dll: str, multiplier: int, flow_scale: float, - performance_mode: bool, hdr_mode: bool, - experimental_present_mode: str = "fifo", - dxvk_frame_rate: int = 0, - enable_wow64: bool = False, - disable_steamdeck_mode: bool = False) -> ConfigurationResponse: - """Update TOML configuration + def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse: + """Update TOML configuration from configuration dictionary (eliminates parameter duplication) Args: - dll: Path to Lossless.dll - multiplier: LSFG multiplier value - flow_scale: LSFG flow scale value - performance_mode: Whether to enable performance mode - hdr_mode: Whether to enable HDR mode - experimental_present_mode: Experimental Vulkan present mode override - dxvk_frame_rate: Frame rate cap for DirectX games, before frame multiplier (0 = disabled) - enable_wow64: Whether to enable PROTON_USE_WOW64=1 for 32-bit games - disable_steamdeck_mode: Whether to disable Steam Deck mode + config: Complete configuration data dictionary Returns: ConfigurationResponse with success status """ try: - # Create configuration from individual arguments - config = ConfigurationManager.create_config_from_args( - dll, multiplier, flow_scale, performance_mode, hdr_mode, - experimental_present_mode, dxvk_frame_rate, enable_wow64, disable_steamdeck_mode - ) + # Generate TOML content using centralized manager + toml_content = ConfigurationManager.generate_toml_content(config) + + # Ensure config directory exists + self.config_dir.mkdir(parents=True, exist_ok=True) + + # Write the updated config directly to preserve inode for file watchers + self._write_file(self.config_file_path, toml_content, 0o644) + + # Update the launch script with the new configuration + script_result = self.update_lsfg_script(config) + if not script_result["success"]: + self.log.warning(f"Failed to update launch script: {script_result['error']}") + + # Log with dynamic field listing + field_values = ", ".join(f"{k}={repr(v)}" for k, v in config.items()) + self.log.info(f"Updated lsfg configuration: {field_values}") + + return self._success_response(ConfigurationResponse, + "lsfg configuration updated successfully", + config=config) + + except (OSError, IOError) as e: + error_msg = f"Error updating lsfg config: {str(e)}" + self.log.error(error_msg) + return self._error_response(ConfigurationResponse, str(e), config=None) + except ValueError as e: + error_msg = f"Invalid configuration arguments: {str(e)}" + self.log.error(error_msg) + return self._error_response(ConfigurationResponse, str(e), config=None) + + def update_config(self, **kwargs) -> ConfigurationResponse: + """Update TOML configuration using generated schema - SIMPLIFIED WITH GENERATED CODE + + Args: + **kwargs: Configuration field values (see shared_config.py for available fields) + + Returns: + ConfigurationResponse with success status + """ + try: + # Create configuration from keyword arguments using generated function + config = ConfigurationManager.create_config_from_args(**kwargs) # Generate TOML content using centralized manager toml_content = ConfigurationManager.generate_toml_content(config) @@ -103,12 +130,8 @@ class ConfigurationService(BaseService): if not script_result["success"]: self.log.warning(f"Failed to update launch script: {script_result['error']}") - self.log.info(f"Updated lsfg TOML configuration: " - f"dll='{dll}', multiplier={multiplier}, flow_scale={flow_scale}, " - f"performance_mode={performance_mode}, hdr_mode={hdr_mode}, " - f"experimental_present_mode='{experimental_present_mode}', " - f"dxvk_frame_rate={dxvk_frame_rate}, " - f"enable_wow64={enable_wow64}, disable_steamdeck_mode={disable_steamdeck_mode}") + # Use auto-generated logging + log_configuration_update(self.log, config) return self._success_response(ConfigurationResponse, "lsfg configuration updated successfully", @@ -143,8 +166,9 @@ class ConfigurationService(BaseService): else: config = current_response["config"] - # Update just the DLL path - config["dll"] = dll_path + # Update just the DLL path - USE GENERATED CONSTANTS + from .config_schema_generated import DLL + config[DLL] = dll_path # Generate TOML content and write it toml_content = ConfigurationManager.generate_toml_content(config) @@ -207,22 +231,14 @@ class ConfigurationService(BaseService): "# This script sets up the environment for lsfg-vk to work with the plugin configuration" ] - # Add optional export statements based on configuration - if config.get("enable_wow64", False): - lines.append("export PROTON_USE_WOW64=1") - - if config.get("disable_steamdeck_mode", False): - lines.append("export SteamDeck=0") - - # Add DXVK_FRAME_RATE if dxvk_frame_rate is set - dxvk_frame_rate = config.get("dxvk_frame_rate", 0) - if dxvk_frame_rate > 0: - lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}") - - # Always add the LSFG_PROCESS export - lines.append("export LSFG_PROCESS=decky-lsfg-vk") + # Use auto-generated script generation logic + generate_script_lines = get_script_generation_logic() + lines.extend(generate_script_lines(config)) - # Add the execution line - lines.append('exec "$@"') + # Always add the LSFG_PROCESS export and execution line + lines.extend([ + "export LSFG_PROCESS=decky-lsfg-vk", + 'exec "$@"' + ]) return "\n".join(lines) + "\n" diff --git a/py_modules/lsfg_vk/configuration_helpers_generated.py b/py_modules/lsfg_vk/configuration_helpers_generated.py new file mode 100644 index 0000000..f9f4a65 --- /dev/null +++ b/py_modules/lsfg_vk/configuration_helpers_generated.py @@ -0,0 +1,22 @@ +""" +Auto-generated configuration helper functions from shared_config.py +DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build +""" + +from typing import Dict, Any +from .config_schema_generated import ConfigurationData, ALL_FIELDS + + +def log_configuration_update(logger, config: ConfigurationData) -> None: + """Log configuration update with all field values""" + logger.info(f"Updated lsfg TOML configuration: dll={config['dll']}, multiplier={config['multiplier']}, flow_scale={config['flow_scale']}, performance_mode={config['performance_mode']}, hdr_mode={config['hdr_mode']}, experimental_present_mode={config['experimental_present_mode']}, dxvk_frame_rate={config['dxvk_frame_rate']}, enable_wow64={config['enable_wow64']}, disable_steamdeck_mode={config['disable_steamdeck_mode']}, mangohud_workaround={config['mangohud_workaround']}, disable_vkbasalt={config['disable_vkbasalt']}") + + +def get_config_field_names() -> list[str]: + """Get all configuration field names""" + return ALL_FIELDS.copy() + + +def extract_config_values(config: ConfigurationData) -> Dict[str, Any]: + """Extract configuration values as a dictionary""" + return {field: config[field] for field in ALL_FIELDS} diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index b340093..996a03f 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -121,9 +121,10 @@ class InstallationService(BaseService): self._write_file(self.config_file_path, toml_content, 0o644) self.log.info(f"Created config file at {self.config_file_path}") - # Log detected DLL path if found - if config["dll"]: - self.log.info(f"Configured DLL path: {config['dll']}") + # Log detected DLL path if found - USE GENERATED CONSTANTS + from .config_schema_generated import DLL + if config[DLL]: + self.log.info(f"Configured DLL path: {config[DLL]}") def _create_lsfg_launch_script(self) -> None: """Create the ~/lsfg launch script for easier game setup""" diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 425d7e7..8fa2435 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -184,32 +184,20 @@ class Plugin: "defaults": ConfigurationManager.get_defaults() } - async def update_lsfg_config(self, dll: str, multiplier: int, flow_scale: float, - performance_mode: bool, hdr_mode: bool, - experimental_present_mode: str = "fifo", - dxvk_frame_rate: int = 0, - enable_wow64: bool = False, - disable_steamdeck_mode: bool = False) -> Dict[str, Any]: - """Update lsfg TOML configuration + async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Update lsfg TOML configuration using object-based API (single source of truth) Args: - dll: Path to Lossless.dll - multiplier: LSFG multiplier value - flow_scale: LSFG flow scale value - performance_mode: Whether to enable performance mode - hdr_mode: Whether to enable HDR mode - experimental_present_mode: Experimental Vulkan present mode override - dxvk_frame_rate: Frame rate cap for DirectX games, before frame multiplier (0 = disabled) - enable_wow64: Whether to enable PROTON_USE_WOW64=1 for 32-bit games - disable_steamdeck_mode: Whether to disable Steam Deck mode + config: Configuration data dictionary containing all settings Returns: ConfigurationResponse dict with success status """ - return self.configuration_service.update_config( - dll, multiplier, flow_scale, performance_mode, hdr_mode, - experimental_present_mode, dxvk_frame_rate, enable_wow64, disable_steamdeck_mode - ) + # Validate and extract configuration from the config dict + validated_config = ConfigurationManager.validate_config(config) + + # Use dynamic parameter passing based on schema + return self.configuration_service.update_config_from_dict(validated_config) async def update_dll_path(self, dll_path: str) -> Dict[str, Any]: """Update the DLL path in the configuration when detected diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py new file mode 100644 index 0000000..b16aa3f --- /dev/null +++ b/scripts/generate_python_boilerplate.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Generate Python boilerplate from shared_config.py + +This script generates repetitive Python code patterns from the canonical schema, +reducing manual maintenance when adding/removing configuration fields. +""" + +import sys +from pathlib import Path + +# Add project root to path to import shared_config +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType + + +def get_python_type(field_type: ConfigFieldType) -> str: + """Convert ConfigFieldType to Python type annotation""" + type_map = { + ConfigFieldType.BOOLEAN: "bool", + ConfigFieldType.INTEGER: "int", + ConfigFieldType.FLOAT: "float", + ConfigFieldType.STRING: "str" + } + return type_map.get(field_type, "Any") + + +def get_env_var_name(field_name: str) -> str: + """Convert field name to environment variable name""" + env_map = { + "dxvk_frame_rate": "DXVK_FRAME_RATE", + "enable_wow64": "PROTON_USE_WOW64", + "disable_steamdeck_mode": "SteamDeck", + "mangohud_workaround": "MANGOHUD", + "disable_vkbasalt": "DISABLE_VKBASALT" + } + return env_map.get(field_name, field_name.upper()) + + +def generate_typed_dict() -> str: + """Generate ConfigurationData TypedDict""" + lines = [ + "class ConfigurationData(TypedDict):", + " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\"" + ] + + for field_name, field_def in CONFIG_SCHEMA_DEF.items(): + python_type = get_python_type(ConfigFieldType(field_def["fieldType"])) + lines.append(f" {field_name}: {python_type}") + + return "\n".join(lines) + + +def generate_function_signature() -> str: + """Generate function signature for update_config and create_config_from_args""" + params = [] + + for field_name, field_def in CONFIG_SCHEMA_DEF.items(): + python_type = get_python_type(ConfigFieldType(field_def["fieldType"])) + default = field_def["default"] + + # Format default value + if isinstance(default, str): + default_str = f'"{default}"' + elif isinstance(default, bool): + default_str = str(default) + else: + default_str = str(default) + + params.append(f"{field_name}: {python_type} = {default_str}") + + return ",\n ".join(params) + + +def generate_config_dict_creation() -> str: + """Generate dictionary creation for create_config_from_args""" + lines = [" return cast(ConfigurationData, {"] + + for field_name in CONFIG_SCHEMA_DEF.keys(): + lines.append(f' "{field_name}": kwargs.get("{field_name}"),') + + lines.append(" })") + return "\n".join(lines) + + +def generate_script_parsing() -> str: + """Generate script content parsing logic""" + lines = [] + + script_fields = [ + (field_name, field_def) + for field_name, field_def in CONFIG_SCHEMA_DEF.items() + if field_def.get("location") == "script" + ] + + for field_name, field_def in script_fields: + env_var = get_env_var_name(field_name) + field_type = ConfigFieldType(field_def["fieldType"]) + + if field_type == ConfigFieldType.BOOLEAN: + if field_name == "disable_steamdeck_mode": + # Special case: SteamDeck=0 means disable_steamdeck_mode=True + lines.append(f' elif key == "{env_var}":') + lines.append(f' script_values["{field_name}"] = value == "0"') + else: + lines.append(f' elif key == "{env_var}":') + lines.append(f' script_values["{field_name}"] = value == "1"') + elif field_type == ConfigFieldType.INTEGER: + lines.append(f' elif key == "{env_var}":') + lines.append(' try:') + lines.append(f' script_values["{field_name}"] = int(value)') + lines.append(' except ValueError:') + lines.append(' pass') + elif field_type == ConfigFieldType.FLOAT: + lines.append(f' elif key == "{env_var}":') + lines.append(' try:') + lines.append(f' script_values["{field_name}"] = float(value)') + lines.append(' except ValueError:') + lines.append(' pass') + elif field_type == ConfigFieldType.STRING: + lines.append(f' elif key == "{env_var}":') + lines.append(f' script_values["{field_name}"] = value') + + return "\n".join(lines) + + +def generate_script_generation() -> str: + """Generate script content generation logic""" + lines = [] + + script_fields = [ + (field_name, field_def) + for field_name, field_def in CONFIG_SCHEMA_DEF.items() + if field_def.get("location") == "script" + ] + + for field_name, field_def in script_fields: + env_var = get_env_var_name(field_name) + field_type = ConfigFieldType(field_def["fieldType"]) + + if field_type == ConfigFieldType.BOOLEAN: + if field_name == "disable_steamdeck_mode": + # Special case: disable_steamdeck_mode=True should export SteamDeck=0 + lines.append(f' if config.get("{field_name}", False):') + lines.append(f' lines.append("export {env_var}=0")') + else: + lines.append(f' if config.get("{field_name}", False):') + lines.append(f' lines.append("export {env_var}=1")') + elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]: + default = field_def["default"] + if field_name == "dxvk_frame_rate": + # Special handling for DXVK_FRAME_RATE (only export if > 0) + lines.append(f' {field_name} = config.get("{field_name}", {default})') + lines.append(f' if {field_name} > 0:') + lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') + else: + lines.append(f' {field_name} = config.get("{field_name}", {default})') + lines.append(f' if {field_name} != {default}:') + lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') + elif field_type == ConfigFieldType.STRING: + lines.append(f' {field_name} = config.get("{field_name}", "")') + lines.append(f' if {field_name}:') + lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') + + return "\n".join(lines) + + +def generate_log_statement() -> str: + """Generate logging statement with all field values""" + field_parts = [] + + for field_name in CONFIG_SCHEMA_DEF.keys(): + field_parts.append(f"{field_name}={{{field_name}}}") + + log_format = ", ".join(field_parts) + return f' self.log.info(f"Updated lsfg TOML configuration: {log_format}")' + + +def generate_complete_schema_file() -> str: + """Generate complete config_schema_generated.py file""" + + # Generate field name constants + field_constants = [] + for field_name in CONFIG_SCHEMA_DEF.keys(): + const_name = field_name.upper() + field_constants.append(f'{const_name} = "{field_name}"') + + lines = [ + '"""', + 'Auto-generated configuration schema components from shared_config.py', + 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build', + '"""', + '', + 'from typing import TypedDict, Dict, Any, Union, cast', + 'from enum import Enum', + 'import sys', + 'from pathlib import Path', + '', + '# Import shared configuration constants', + 'sys.path.insert(0, str(Path(__file__).parent.parent.parent))', + 'from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType', + '', + '# Field name constants for type-safe access', + ] + field_constants + [ + '', + '', + generate_typed_dict(), + '', + '', + 'def get_script_parsing_logic():', + ' """Return the script parsing logic as a callable"""', + ' def parse_script_values(lines):', + ' script_values = {}', + ' for line in lines:', + ' line = line.strip()', + ' if not line or line.startswith("#") or not line.startswith("export "):', + ' continue', + ' if "=" in line:', + ' export_line = line[len("export "):]', + ' key, value = export_line.split("=", 1)', + ' key = key.strip()', + ' value = value.strip()', + '', + ' # Auto-generated parsing logic:', + f'{generate_script_parsing().replace(" elif", " if")}', + '', + ' return script_values', + ' return parse_script_values', + '', + '', + 'def get_script_generation_logic():', + ' """Return the script generation logic as a callable"""', + ' def generate_script_lines(config):', + ' lines = []', + f'{generate_script_generation()}', + ' return lines', + ' return generate_script_lines', + '', + '', + 'def get_function_parameters() -> str:', + ' """Return function signature parameters"""', + f' return """{generate_function_signature()}"""', + '', + '', + 'def create_config_dict(**kwargs) -> ConfigurationData:', + ' """Create configuration dictionary from keyword arguments"""', + f'{generate_config_dict_creation().replace(" return cast(ConfigurationData, {", " return cast(ConfigurationData, {").replace(" })", " })")}', + '', + '', + '# Field lists for dynamic operations', + f'TOML_FIELDS = {[name for name, field in CONFIG_SCHEMA_DEF.items() if field.get("location") == "toml"]}', + f'SCRIPT_FIELDS = {[name for name, field in CONFIG_SCHEMA_DEF.items() if field.get("location") == "script"]}', + f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}', + '' + ] + + return '\n'.join(lines) + + +def generate_complete_configuration_helpers() -> str: + """Generate configuration_helpers_generated.py file""" + + # Generate the log format string using config parameter + log_parts = [] + for field_name in CONFIG_SCHEMA_DEF.keys(): + log_parts.append(f"{field_name}={{config['{field_name}']}}") + log_format = ", ".join(log_parts) + + lines = [ + '"""', + 'Auto-generated configuration helper functions from shared_config.py', + 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build', + '"""', + '', + 'from typing import Dict, Any', + 'from .config_schema_generated import ConfigurationData, ALL_FIELDS', + '', + '', + 'def log_configuration_update(logger, config: ConfigurationData) -> None:', + ' """Log configuration update with all field values"""', + f' logger.info(f"Updated lsfg TOML configuration: {log_format}")', + '', + '', + 'def get_config_field_names() -> list[str]:', + ' """Get all configuration field names"""', + ' return ALL_FIELDS.copy()', + '', + '', + 'def extract_config_values(config: ConfigurationData) -> Dict[str, Any]:', + ' """Extract configuration values as a dictionary"""', + ' return {field: config[field] for field in ALL_FIELDS}', + '' + ] + + return '\n'.join(lines) + + +def main(): + """Generate complete Python configuration files""" + try: + # Create generated files in py_modules/lsfg_vk/ + target_dir = project_root / "py_modules" / "lsfg_vk" + + # Generate the complete schema file + schema_content = generate_complete_schema_file() + schema_file = target_dir / "config_schema_generated.py" + schema_file.write_text(schema_content) + print(f"ā
Generated {schema_file.relative_to(project_root)}") + + # Generate configuration helpers + helpers_content = generate_complete_configuration_helpers() + helpers_file = target_dir / "configuration_helpers_generated.py" + helpers_file.write_text(helpers_content) + print(f"ā
Generated {helpers_file.relative_to(project_root)}") + + print(f"\nšÆ Ready-to-use files generated!") + print(" Import these in your main files:") + print(" - from .config_schema_generated import ConfigurationData, get_script_parsing_logic, etc.") + print(" - from .configuration_helpers_generated import log_configuration_update, etc.") + + except Exception as e: + print(f"ā Error generating Python files: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py new file mode 100644 index 0000000..c4c0e8a --- /dev/null +++ b/scripts/generate_ts_schema.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Generate TypeScript schema from Python shared_config.py + +This script reads the canonical schema from shared_config.py and generates +the corresponding TypeScript files, ensuring single source of truth. +""" + +import sys +from pathlib import Path + +# Add project root to path to import shared_config +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType + + +def generate_typescript_schema(): + """Generate generatedConfigSchema.ts from Python schema""" + + # Generate field name constants + field_constants = [] + for field_name in CONFIG_SCHEMA_DEF.keys(): + const_name = field_name.upper() + field_constants.append(f'export const {const_name} = "{field_name}" as const;') + + # Generate enum + enum_lines = [ + "// src/config/generatedConfigSchema.ts", + "// Configuration field type enum - matches Python", + "export enum ConfigFieldType {", + " BOOLEAN = \"boolean\",", + " INTEGER = \"integer\",", + " FLOAT = \"float\",", + " STRING = \"string\"", + "}", + "", + "// Field name constants for type-safe access", + ] + field_constants + [ + "", + "// Configuration field definition", + "export interface ConfigField {", + " name: string;", + " fieldType: ConfigFieldType;", + " default: boolean | number | string;", + " description: string;", + "}", + "", + "// Configuration schema - auto-generated from Python", + "export const CONFIG_SCHEMA: Record<string, ConfigField> = {" + ] + + # Generate schema entries + schema_entries = [] + interface_fields = [] + defaults_fields = [] + field_types = [] + + for field_name, field_def in CONFIG_SCHEMA_DEF.items(): + # Schema entry + default_value = field_def["default"] + if isinstance(default_value, str): + default_str = f'"{default_value}"' + elif isinstance(default_value, bool): + default_str = "true" if default_value else "false" + else: + default_str = str(default_value) + + schema_entries.append(f' {field_name}: {{') + schema_entries.append(f' name: "{field_def["name"]}",') + schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},') + schema_entries.append(f' default: {default_str},') + schema_entries.append(f' description: "{field_def["description"]}"') + schema_entries.append(' },') + + # Interface field + if field_def["fieldType"] == ConfigFieldType.BOOLEAN: + ts_type = "boolean" + elif field_def["fieldType"] == ConfigFieldType.INTEGER: + ts_type = "number" + elif field_def["fieldType"] == ConfigFieldType.FLOAT: + ts_type = "number" + elif field_def["fieldType"] == ConfigFieldType.STRING: + ts_type = "string" + else: + ts_type = "any" + + interface_fields.append(f' {field_name}: {ts_type};') + defaults_fields.append(f' {field_name}: {default_str},') + field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},') + + # Complete the file + all_lines = enum_lines + schema_entries + [ + "};", + "", + "// Type-safe configuration data structure", + "export interface ConfigurationData {", + ] + interface_fields + [ + "}", + "", + "// Helper functions", + "export function getFieldNames(): string[] {", + " return Object.keys(CONFIG_SCHEMA);", + "}", + "", + "export function getDefaults(): ConfigurationData {", + " return {", + ] + defaults_fields + [ + " };", + "}", + "", + "export function getFieldTypes(): Record<string, ConfigFieldType> {", + " return {", + ] + field_types + [ + " };", + "}", + "", + "" + ] + + return "\n".join(all_lines) + + +def main(): + """Main function to generate TypeScript schema and Python boilerplate""" + try: + # Generate the TypeScript content + ts_content = generate_typescript_schema() + + # Write to the target file + target_file = project_root / "src" / "config" / "generatedConfigSchema.ts" + target_file.write_text(ts_content) + + print(f"ā
Generated {target_file} from shared_config.py") + print(f" Fields: {len(CONFIG_SCHEMA_DEF)}") + + # Also generate Python boilerplate + print("\nš Generating Python boilerplate...") + from pathlib import Path + import subprocess + + boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py" + result = subprocess.run([sys.executable, str(boilerplate_script)], + capture_output=True, text=True) + + if result.returncode == 0: + print(result.stdout) + else: + print(f"ā ļø Python boilerplate generation had issues:\n{result.stderr}") + + except Exception as e: + print(f"ā Error generating schema: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/shared_config.py b/shared_config.py index 46bfc84..68c7b82 100644 --- a/shared_config.py +++ b/shared_config.py @@ -24,63 +24,88 @@ CONFIG_SCHEMA_DEF = { "name": "dll", "fieldType": ConfigFieldType.STRING, "default": "/games/Lossless Scaling/Lossless.dll", - "description": "specify where Lossless.dll is stored" + "description": "specify where Lossless.dll is stored", + "location": "toml" # where this field is stored/used }, "multiplier": { "name": "multiplier", "fieldType": ConfigFieldType.INTEGER, "default": 1, - "description": "change the fps multiplier" + "description": "change the fps multiplier", + "location": "toml" }, "flow_scale": { "name": "flow_scale", "fieldType": ConfigFieldType.FLOAT, "default": 0.8, - "description": "change the flow scale" + "description": "change the flow scale", + "location": "toml" }, "performance_mode": { "name": "performance_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": True, - "description": "use a lighter model for FG (recommended for most games)" + "description": "use a lighter model for FG (recommended for most games)", + "location": "toml" }, "hdr_mode": { "name": "hdr_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "enable HDR mode (only for games that support HDR)" + "description": "enable HDR mode (only for games that support HDR)", + "location": "toml" }, "experimental_present_mode": { "name": "experimental_present_mode", "fieldType": ConfigFieldType.STRING, "default": "fifo", - "description": "override Vulkan present mode (may cause crashes)" + "description": "override Vulkan present mode (may cause crashes)", + "location": "toml" }, "dxvk_frame_rate": { "name": "dxvk_frame_rate", "fieldType": ConfigFieldType.INTEGER, "default": 0, - "description": "base framerate cap for DirectX games before frame multiplier" + "description": "base framerate cap for DirectX games before frame multiplier", + "location": "script" # script-only field }, "enable_wow64": { "name": "enable_wow64", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)" + "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)", + "location": "script" }, "disable_steamdeck_mode": { "name": "disable_steamdeck_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "disable Steam Deck mode (unlocks hidden settings in some games)" + "description": "disable Steam Deck mode (unlocks hidden settings in some games)", + "location": "script" + }, + + "mangohud_workaround": { + "name": "mangohud_workaround", + "fieldType": ConfigFieldType.BOOLEAN, + "default": False, + "description": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", + "location": "script" + }, + + "disable_vkbasalt": { + "name": "disable_vkbasalt", + "fieldType": ConfigFieldType.BOOLEAN, + "default": False, + "description": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", + "location": "script" } } diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 74caa57..8d14da6 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -1,5 +1,5 @@ import { callable } from "@decky/api"; -import { ConfigurationData, ConfigurationManager } from "../config/configSchema"; +import { ConfigurationData } from "../config/configSchema"; // Type definitions for API responses export interface InstallationResult { @@ -99,16 +99,15 @@ export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_opti export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); export const getLaunchScriptContent = callable<[], FileContentResult>("get_launch_script_content"); -// Updated config function using centralized configuration +// Updated config function using object-based configuration (single source of truth) export const updateLsfgConfig = callable< - [string, number, number, boolean, boolean, string, number, boolean, boolean], + [ConfigurationData], ConfigUpdateResult >("update_lsfg_config"); -// Helper function to create config update from configuration object +// Legacy helper function for backward compatibility export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => { - const args = ConfigurationManager.createArgsFromConfig(config); - return updateLsfgConfig(...args as [string, number, number, boolean, boolean, string, number, boolean, boolean]); + return updateLsfgConfig(config); }; // Self-updater API functions diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index 59ad880..c0b67fd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,5 +1,10 @@ import { PanelSectionRow, ToggleField, SliderField, DropdownItem } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; +import { + MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, + EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, + MANGOHUD_WORKAROUND, DISABLE_VKBASALT +} from "../config/generatedConfigSchema"; interface ConfigurationSectionProps { config: ConfigurationData; @@ -45,7 +50,7 @@ export function ConfigurationSection({ ]} showValue={false} notchTicksVisible={true} - onChange={(value) => onConfigChange('multiplier', value)} + onChange={(value) => onConfigChange(MULTIPLIER, value)} /> </PanelSectionRow> @@ -57,7 +62,7 @@ export function ConfigurationSection({ min={0.25} max={1.0} step={0.01} - onChange={(value) => onConfigChange('flow_scale', value)} + onChange={(value) => onConfigChange(FLOW_SCALE, value)} /> </PanelSectionRow> @@ -66,7 +71,7 @@ export function ConfigurationSection({ label="Performance Mode" description="Uses a lighter model for FG (Recommended for most games)" checked={config.performance_mode} - onChange={(value) => onConfigChange('performance_mode', value)} + onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> </PanelSectionRow> @@ -75,7 +80,7 @@ export function ConfigurationSection({ label="HDR Mode" description="Enables HDR mode (only for games that support HDR)" checked={config.hdr_mode} - onChange={(value) => onConfigChange('hdr_mode', value)} + onChange={(value) => onConfigChange(HDR_MODE, value)} /> </PanelSectionRow> @@ -101,7 +106,7 @@ export function ConfigurationSection({ description="Select a specific Vulkan presentation mode for better performance or compatibility (May cause crashes)" menuLabel="Select presentation mode" selectedOption={config.experimental_present_mode || "fifo"} - onChange={(value) => onConfigChange('experimental_present_mode', value.data)} + onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value.data)} rgOptions={[ { data: "fifo", label: "FIFO (VSync) - Default" }, { data: "mailbox", label: "Mailbox" } @@ -112,16 +117,26 @@ export function ConfigurationSection({ <PanelSectionRow> <div style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "16px", - marginBottom: "8px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "4px", - color: "white" + fontSize: "14px", + fontWeight: "bold", + marginTop: "16px", + marginBottom: "2px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "2px", + color: "white" + }} + > + Environment Variables + </div> + <div + style={{ + fontSize: "12px", + color: "#cccccc", + marginTop: "2px", + marginBottom: "8px" }} > - Environment Variables (Requires re-launch) + Must be toggled before game start or restart game to take effect </div> </PanelSectionRow> @@ -133,7 +148,7 @@ export function ConfigurationSection({ min={0} max={60} step={1} - onChange={(value) => onConfigChange('dxvk_frame_rate', value)} + onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)} /> </PanelSectionRow> @@ -151,7 +166,25 @@ export function ConfigurationSection({ label="Disable Steam Deck Mode" description="Disables Steam Deck mode (Unlocks hidden settings in some games)" checked={config.disable_steamdeck_mode} - onChange={(value) => onConfigChange('disable_steamdeck_mode', value)} + onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)} + /> + </PanelSectionRow> + + <PanelSectionRow> + <ToggleField + label="MangoHud Workaround" + description="Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode" + checked={config.mangohud_workaround} + onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)} + /> + </PanelSectionRow> + + <PanelSectionRow> + <ToggleField + label="Disable vkBasalt" + description="Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)" + checked={config.disable_vkbasalt} + onChange={(value) => onConfigChange(DISABLE_VKBASALT, value)} /> </PanelSectionRow> </> diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 4ab0d25..9568fd8 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -8,6 +8,7 @@ import { callable } from "@decky/api"; import type { ConfigurationData } from './generatedConfigSchema'; import { getDefaults } from './generatedConfigSchema'; +import { updateLsfgConfig } from "../api/lsfgApi"; // Re-export all auto-generated configuration constants export { @@ -17,7 +18,11 @@ export { ConfigurationData, getFieldNames, getDefaults, - getFieldTypes + getFieldTypes, + // Field name constants for type-safe access + DLL, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, + EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64, + DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT } from './generatedConfigSchema'; /** @@ -30,7 +35,6 @@ export class ConfigurationManager { // Callable methods for backend communication private getConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("get_configuration"); - private setConfiguration = callable<[{ config_data: ConfigurationData }], { success: boolean; error?: string }>("set_configuration"); private resetConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("reset_configuration"); private constructor() {} @@ -50,23 +54,6 @@ export class ConfigurationManager { } /** - * Create args array from config object for lsfg API calls - */ - static createArgsFromConfig(config: ConfigurationData): [string, number, number, boolean, boolean, string, number, boolean, boolean] { - return [ - config.dll, - config.multiplier, - config.flow_scale, - config.performance_mode, - config.hdr_mode, - config.experimental_present_mode, - config.dxvk_frame_rate, - config.enable_wow64, - config.disable_steamdeck_mode - ]; - } - - /** * Load configuration from backend */ async loadConfig(): Promise<ConfigurationData> { @@ -89,7 +76,7 @@ export class ConfigurationManager { */ async saveConfig(config: ConfigurationData): Promise<void> { try { - const result = await this.setConfiguration({ config_data: config }); + const result = await updateLsfgConfig(config); if (result.success) { this._config = config; } else { diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 5f0e368..4a301a1 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -7,6 +7,19 @@ export enum ConfigFieldType { STRING = "string" } +// Field name constants for type-safe access +export const DLL = "dll" as const; +export const MULTIPLIER = "multiplier" as const; +export const FLOW_SCALE = "flow_scale" as const; +export const PERFORMANCE_MODE = "performance_mode" as const; +export const HDR_MODE = "hdr_mode" as const; +export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const; +export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; +export const ENABLE_WOW64 = "enable_wow64" as const; +export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const; +export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const; +export const DISABLE_VKBASALT = "disable_vkbasalt" as const; + // Configuration field definition export interface ConfigField { name: string; @@ -71,6 +84,18 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = { default: false, description: "disable Steam Deck mode (unlocks hidden settings in some games)" }, + mangohud_workaround: { + name: "mangohud_workaround", + fieldType: ConfigFieldType.BOOLEAN, + default: false, + description: "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode" + }, + disable_vkbasalt: { + name: "disable_vkbasalt", + fieldType: ConfigFieldType.BOOLEAN, + default: false, + description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)" + }, }; // Type-safe configuration data structure @@ -84,6 +109,8 @@ export interface ConfigurationData { dxvk_frame_rate: number; enable_wow64: boolean; disable_steamdeck_mode: boolean; + mangohud_workaround: boolean; + disable_vkbasalt: boolean; } // Helper functions @@ -102,6 +129,8 @@ export function getDefaults(): ConfigurationData { dxvk_frame_rate: 0, enable_wow64: false, disable_steamdeck_mode: false, + mangohud_workaround: false, + disable_vkbasalt: false, }; } @@ -116,6 +145,8 @@ export function getFieldTypes(): Record<string, ConfigFieldType> { dxvk_frame_rate: ConfigFieldType.INTEGER, enable_wow64: ConfigFieldType.BOOLEAN, disable_steamdeck_mode: ConfigFieldType.BOOLEAN, + mangohud_workaround: ConfigFieldType.BOOLEAN, + disable_vkbasalt: ConfigFieldType.BOOLEAN, }; } diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 9ac31a0..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Test configuration for the lsfg-vk plugin tests. -""" - -import pytest -from unittest.mock import Mock - - -@pytest.fixture -def mock_logger(): - """Provide a mock logger for testing""" - return Mock() - - -@pytest.fixture -def mock_decky_logger(monkeypatch): - """Mock decky.logger for tests that import decky""" - mock_logger = Mock() - - # Create a mock decky module - mock_decky = Mock() - mock_decky.logger = mock_logger - - # Monkeypatch the import - monkeypatch.setattr('lsfg_vk.base_service.decky', mock_decky) - monkeypatch.setattr('lsfg_vk.installation.decky', mock_decky) - monkeypatch.setattr('lsfg_vk.dll_detection.decky', mock_decky) - monkeypatch.setattr('lsfg_vk.configuration.decky', mock_decky) - monkeypatch.setattr('lsfg_vk.plugin.decky', mock_decky) - - return mock_logger diff --git a/tests/test_configuration.py b/tests/test_configuration.py deleted file mode 100644 index 3e0ad79..0000000 --- a/tests/test_configuration.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Tests for the configuration service. -""" - -import tempfile -from pathlib import Path -from unittest.mock import Mock - -from lsfg_vk.configuration import ConfigurationService -from lsfg_vk.config_schema import ConfigurationManager - - -def test_parse_script_content(): - """Test parsing of script content with current environment variable format""" - - # Test script content matching current format - script_content = """#!/bin/bash -# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin -# This script sets up the environment for lsfg-vk to work with the plugin configuration -export PROTON_USE_WOW64=1 -export SteamDeck=0 -export DXVK_FRAME_RATE=18 -export LSFG_PROCESS=decky-lsfg-vk -exec "$@" -""" - - script_values = ConfigurationManager.parse_script_content(script_content) - - assert script_values["enable_wow64"] is True - assert script_values["disable_steamdeck_mode"] is True # SteamDeck=0 means disable - assert script_values["dxvk_frame_rate"] == 18 - - -def test_parse_script_content_minimal(): - """Test parsing when only required exports are present""" - - script_content = """#!/bin/bash -# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin -export LSFG_PROCESS=decky-lsfg-vk -exec "$@" -""" - - script_values = ConfigurationManager.parse_script_content(script_content) - - # Should be empty dict since no tracked env vars are present - assert script_values == {} - - -def test_merge_config_with_script(): - """Test merging TOML config with script environment variables""" - - # Get defaults - toml_config = ConfigurationManager.get_defaults() - - # Script values from parsing - script_values = { - "enable_wow64": True, - "disable_steamdeck_mode": True, - "dxvk_frame_rate": 30 - } - - merged = ConfigurationManager.merge_config_with_script(toml_config, script_values) - - # TOML fields should be preserved - assert merged["multiplier"] == 1 # default from TOML - assert merged["flow_scale"] == 0.8 # default from TOML - assert merged["performance_mode"] is True # default from TOML - - # Script fields should be overlaid - assert merged["enable_wow64"] is True - assert merged["disable_steamdeck_mode"] is True - assert merged["dxvk_frame_rate"] == 30 - - with tempfile.TemporaryDirectory() as temp_dir: - temp_home = Path(temp_dir) - - service = ConfigurationService(logger=mock_logger) - service.user_home = temp_home - service.lsfg_script_path = temp_home / "lsfg" - - script_content = """#!/bin/bash - -# export ENABLE_LSFG=1 -export LSFG_MULTIPLIER=2 -export LSFG_FLOW_SCALE=1.0 -# export LSFG_HDR=1 -# export LSFG_PERF_MODE=1 -# export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync - -exec "$@" -""" - - config = service._parse_script_content(script_content) - - assert config["enable_lsfg"] is False - assert config["multiplier"] == 2 - assert config["flow_scale"] == 1.0 - assert config["hdr"] is False - assert config["perf_mode"] is False - assert config["immediate_mode"] is False - - -def test_generate_script_content(): - """Test script content generation""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - temp_home = Path(temp_dir) - - service = ConfigurationService(logger=mock_logger) - service.user_home = temp_home - service.lsfg_script_path = temp_home / "lsfg" - - # Test with no toggles enabled - config = { - "enable_wow64": False, - "disable_steamdeck_mode": False - } - content = service._generate_script_content(config) - - assert "#!/bin/bash" in content - assert "export LSFG_PROCESS=decky-lsfg-vk" in content - assert "export PROTON_USE_WOW64=1" not in content - assert "export SteamDeck=0" not in content - assert 'exec "$@"' in content - - # Test with both toggles enabled - config = { - "enable_wow64": True, - "disable_steamdeck_mode": True - } - content = service._generate_script_content(config) - - assert "#!/bin/bash" in content - assert "export PROTON_USE_WOW64=1" in content - assert "export SteamDeck=0" in content - assert "export LSFG_PROCESS=decky-lsfg-vk" in content - assert 'exec "$@"' in content - - -def test_config_roundtrip(): - """Test that we can write config and read it back correctly""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - temp_home = Path(temp_dir) - - service = ConfigurationService(logger=mock_logger) - service.user_home = temp_home - service.lsfg_script_path = temp_home / "lsfg" - - # Update config - result = service.update_config( - dll="/path/to/dll", - multiplier=3, - flow_scale=1.5, - performance_mode=False, - hdr_mode=True, - experimental_present_mode="immediate", - dxvk_frame_rate=30, - enable_wow64=True, - disable_steamdeck_mode=False - ) - - assert result["success"] is True - - # Read it back - read_result = service.get_config() - - assert read_result["success"] is True - config = read_result["config"] - assert config["dll"] == "/path/to/dll" - assert config["multiplier"] == 3 - assert config["flow_scale"] == 1.5 - assert config["performance_mode"] is False - assert config["hdr_mode"] is True - assert config["experimental_present_mode"] == "immediate" - assert config["dxvk_frame_rate"] == 30 - assert config["enable_wow64"] is True - assert config["disable_steamdeck_mode"] is False - - assert read_result["success"] is True - config = read_result["config"] - - assert config["enable_lsfg"] is True - assert config["multiplier"] == 3 - assert config["flow_scale"] == 1.5 - assert config["hdr"] is True - assert config["perf_mode"] is False - assert config["immediate_mode"] is True diff --git a/tests/test_dll_detection.py b/tests/test_dll_detection.py deleted file mode 100644 index e50d733..0000000 --- a/tests/test_dll_detection.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Tests for the DLL detection service. -""" - -import os -import tempfile -from pathlib import Path -from unittest.mock import Mock, patch - -from lsfg_vk.dll_detection import DllDetectionService -from lsfg_vk.constants import LOSSLESS_DLL_NAME - - -def test_dll_detection_via_env_variable(): - """Test DLL detection via LSFG_DLL_PATH environment variable""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - # Create a fake DLL file - dll_path = Path(temp_dir) / LOSSLESS_DLL_NAME - dll_path.write_text("fake dll content") - - service = DllDetectionService(logger=mock_logger) - - # Test with environment variable set - with patch.dict(os.environ, {"LSFG_DLL_PATH": str(dll_path)}): - result = service.check_lossless_scaling_dll() - - assert result["detected"] is True - assert result["path"] == str(dll_path) - assert "LSFG_DLL_PATH" in result["source"] - assert result["error"] is None - - -def test_dll_detection_via_xdg_data_home(): - """Test DLL detection via XDG_DATA_HOME""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - # Create the expected directory structure - steam_dir = Path(temp_dir) / "Steam" / "steamapps" / "common" / "Lossless Scaling" - steam_dir.mkdir(parents=True) - - dll_path = steam_dir / LOSSLESS_DLL_NAME - dll_path.write_text("fake dll content") - - service = DllDetectionService(logger=mock_logger) - - # Test with XDG_DATA_HOME set, no LSFG_DLL_PATH - with patch.dict(os.environ, {"XDG_DATA_HOME": temp_dir}, clear=True): - result = service.check_lossless_scaling_dll() - - assert result["detected"] is True - assert result["path"] == str(dll_path) - assert "XDG_DATA_HOME" in result["source"] - assert result["error"] is None - - -def test_dll_detection_via_home_local_share(): - """Test DLL detection via HOME/.local/share""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - # Create the expected directory structure - steam_dir = Path(temp_dir) / ".local" / "share" / "Steam" / "steamapps" / "common" / "Lossless Scaling" - steam_dir.mkdir(parents=True) - - dll_path = steam_dir / LOSSLESS_DLL_NAME - dll_path.write_text("fake dll content") - - service = DllDetectionService(logger=mock_logger) - - # Test with HOME set, no other env vars - env = {"HOME": temp_dir} - with patch.dict(os.environ, env, clear=True): - result = service.check_lossless_scaling_dll() - - assert result["detected"] is True - assert result["path"] == str(dll_path) - assert "HOME/.local/share" in result["source"] - assert result["error"] is None - - -def test_dll_detection_not_found(): - """Test DLL detection when DLL is not found""" - mock_logger = Mock() - - service = DllDetectionService(logger=mock_logger) - - # Test with no environment variables set - with patch.dict(os.environ, {}, clear=True): - result = service.check_lossless_scaling_dll() - - assert result["detected"] is False - assert result["path"] is None - assert result["source"] is None - assert "not found" in result["message"] - assert result["error"] is None - - -def test_dll_detection_priority(): - """Test that LSFG_DLL_PATH takes priority over other locations""" - mock_logger = Mock() - - with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: - # Create DLL in both locations - dll_path1 = Path(temp_dir1) / LOSSLESS_DLL_NAME - dll_path1.write_text("fake dll content 1") - - steam_dir = Path(temp_dir2) / "Steam" / "steamapps" / "common" / "Lossless Scaling" - steam_dir.mkdir(parents=True) - dll_path2 = steam_dir / LOSSLESS_DLL_NAME - dll_path2.write_text("fake dll content 2") - - service = DllDetectionService(logger=mock_logger) - - # Set both environment variables - env = { - "LSFG_DLL_PATH": str(dll_path1), - "XDG_DATA_HOME": temp_dir2 - } - - with patch.dict(os.environ, env, clear=True): - result = service.check_lossless_scaling_dll() - - # Should prefer LSFG_DLL_PATH - assert result["detected"] is True - assert result["path"] == str(dll_path1) - assert "LSFG_DLL_PATH" in result["source"] diff --git a/tests/test_installation.py b/tests/test_installation.py deleted file mode 100644 index 2b3690e..0000000 --- a/tests/test_installation.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Tests for the installation service. -""" - -import os -import tempfile -import zipfile -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest -from pyfakefs.fake_filesystem_unittest import TestCase - -from lsfg_vk.installation import InstallationService -from lsfg_vk.constants import LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME - - -class TestInstallationService(TestCase): - """Test cases for InstallationService using pyfakefs""" - - def setUp(self): - """Set up fake filesystem""" - self.setUpPyfakefs() - self.mock_logger = Mock() - - # Create a test home directory - self.test_home = Path("/home/testuser") - self.fs.create_dir(self.test_home) - - # Patch Path.home() to return our test home - with patch('lsfg_vk.base_service.Path.home', return_value=self.test_home): - self.service = InstallationService(logger=self.mock_logger) - - def test_check_installation_no_files(self): - """Test installation check when no files are installed""" - result = self.service.check_installation() - - assert result["installed"] is False - assert result["lib_exists"] is False - assert result["json_exists"] is False - assert result["script_exists"] is False - assert result["error"] is None - - def test_check_installation_all_files_exist(self): - """Test installation check when all files exist""" - # Create the files - self.service.lib_file.parent.mkdir(parents=True, exist_ok=True) - self.service.lib_file.touch() - - self.service.json_file.parent.mkdir(parents=True, exist_ok=True) - self.service.json_file.touch() - - self.service.lsfg_script_path.touch() - - result = self.service.check_installation() - - assert result["installed"] is True - assert result["lib_exists"] is True - assert result["json_exists"] is True - assert result["script_exists"] is True - assert result["error"] is None - - def test_create_zip_for_testing(self): - """Helper to create a test zip file""" - # Create temp directory for zip contents - zip_content_dir = Path("/tmp/zip_content") - self.fs.create_dir(zip_content_dir) - - # Create test files - lib_file = zip_content_dir / LIB_FILENAME - json_file = zip_content_dir / JSON_FILENAME - - lib_file.write_text("fake library content") - json_file.write_text('{"layer": {"name": "VK_LAYER_LS_frame_generation"}}') - - # Create zip file - zip_path = Path("/tmp/test.zip") - with zipfile.ZipFile(zip_path, 'w') as zip_file: - zip_file.write(lib_file, LIB_FILENAME) - zip_file.write(json_file, JSON_FILENAME) - - return zip_path - - @patch('lsfg_vk.installation.Path.home') - def test_install_success(self, mock_home): - """Test successful installation""" - mock_home.return_value = self.test_home - - # Create the plugin directory and zip file - plugin_dir = Path("/plugin") - bin_dir = plugin_dir / "bin" - self.fs.create_dir(bin_dir) - - # Create a test zip file - zip_path = self.test_create_zip_for_testing() - zip_dest = bin_dir / ZIP_FILENAME - - # Copy our test zip to the expected location - with open(zip_path, 'rb') as src, open(zip_dest, 'wb') as dst: - dst.write(src.read()) - - # Mock the plugin directory detection - with patch('lsfg_vk.installation.Path.__file__', f"{plugin_dir}/lsfg_vk/installation.py"): - result = self.service.install() - - assert result["success"] is True - assert "successfully" in result["message"] - assert result["error"] is None - - # Check that files were created - assert self.service.lib_file.exists() - assert self.service.json_file.exists() - assert self.service.lsfg_script_path.exists() - - def test_uninstall_no_files(self): - """Test uninstall when no files exist""" - result = self.service.uninstall() - - assert result["success"] is True - assert "No lsfg-vk files found" in result["message"] - assert result["removed_files"] is None - - def test_uninstall_with_files(self): - """Test uninstall when files exist""" - # Create the files - self.service.lib_file.parent.mkdir(parents=True, exist_ok=True) - self.service.lib_file.touch() - - self.service.json_file.parent.mkdir(parents=True, exist_ok=True) - self.service.json_file.touch() - - self.service.lsfg_script_path.touch() - - result = self.service.uninstall() - - assert result["success"] is True - assert "uninstalled successfully" in result["message"] - assert len(result["removed_files"]) == 3 - - # Check that files were removed - assert not self.service.lib_file.exists() - assert not self.service.json_file.exists() - assert not self.service.lsfg_script_path.exists() - - -def test_installation_service_with_mock_logger(): - """Test that InstallationService accepts a mock logger""" - mock_logger = Mock() - service = InstallationService(logger=mock_logger) - assert service.log == mock_logger |
