diff options
Diffstat (limited to 'py_modules')
| -rw-r--r-- | py_modules/lsfg_vk/base_service.py | 45 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 312 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 197 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/constants.py | 2 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 84 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 96 |
6 files changed, 472 insertions, 264 deletions
diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py index b547759..b595b07 100644 --- a/py_modules/lsfg_vk/base_service.py +++ b/py_modules/lsfg_vk/base_service.py @@ -4,11 +4,10 @@ Base service class with common functionality. import os import shutil -import tempfile from pathlib import Path from typing import Any, Optional -from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME +from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME class BaseService: @@ -31,12 +30,16 @@ class BaseService: self.local_lib_dir = self.user_home / LOCAL_LIB self.local_share_dir = self.user_home / VULKAN_LAYER_DIR self.lsfg_script_path = self.user_home / SCRIPT_NAME + self.lsfg_launch_script_path = self.user_home / SCRIPT_NAME # ~/lsfg launch script + self.config_dir = self.user_home / CONFIG_DIR + self.config_file_path = self.config_dir / CONFIG_FILENAME def _ensure_directories(self) -> None: """Create necessary directories if they don't exist""" self.local_lib_dir.mkdir(parents=True, exist_ok=True) self.local_share_dir.mkdir(parents=True, exist_ok=True) - self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}") + self.config_dir.mkdir(parents=True, exist_ok=True) + self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}, {self.config_dir}") def _remove_if_exists(self, path: Path) -> bool: """Remove a file if it exists @@ -62,8 +65,8 @@ class BaseService: self.log.info(f"File not found: {path}") return False - def _atomic_write(self, path: Path, content: str, mode: int = 0o644) -> None: - """Write content to a file atomically + def _write_file(self, path: Path, content: str, mode: int = 0o644) -> None: + """Write content to a file Args: path: Target file path @@ -73,31 +76,17 @@ class BaseService: Raises: OSError: If write fails """ - # Create temporary file in the same directory to ensure atomic move - temp_path = None try: - with tempfile.NamedTemporaryFile( - mode='w', - dir=path.parent, - delete=False, - prefix=f'.{path.name}.', - suffix='.tmp' - ) as temp_file: - temp_file.write(content) - temp_path = Path(temp_file.name) + # Write directly to the file + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + f.flush() # Ensure data is written to disk + os.fsync(f.fileno()) # Force filesystem sync - # Set permissions before moving - temp_path.chmod(mode) - - # Atomic move - temp_path.replace(path) - self.log.info(f"Atomically wrote to {path}") + # Set permissions + path.chmod(mode) + self.log.info(f"Wrote to {path}") except Exception: - # Clean up temp file if something went wrong - if temp_path and temp_path.exists(): - try: - temp_path.unlink() - except OSError: - pass # Best effort cleanup + self.log.error(f"Failed to write to {path}") raise diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 0f1bdae..ed82d97 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,16 +1,18 @@ """ Centralized configuration schema for lsfg-vk. -This module defines the complete configuration structure, including: +This module defines the complete configuration structure for TOML-based config files, including: - Field definitions with types, defaults, and metadata -- Script generation logic +- TOML generation logic - Validation rules - Type definitions """ -from typing import TypedDict, Dict, Any, Union, Callable, cast -from dataclasses import dataclass, field +import re +from typing import TypedDict, Dict, Any, Union, cast +from dataclasses import dataclass from enum import Enum +from pathlib import Path class ConfigFieldType(Enum): @@ -18,6 +20,7 @@ class ConfigFieldType(Enum): BOOLEAN = "boolean" INTEGER = "integer" FLOAT = "float" + STRING = "string" @dataclass @@ -25,106 +28,84 @@ class ConfigField: """Configuration field definition""" name: str field_type: ConfigFieldType - default: Union[bool, int, float] + default: Union[bool, int, float, str] description: str - script_template: str # Template for script generation - script_comment: str = "" # Comment to add when disabled - - def get_script_line(self, value: Union[bool, int, float]) -> str: - """Generate script line for this field""" - if self.field_type == ConfigFieldType.BOOLEAN: - if value: - return self.script_template.format(value=1) - else: - return f"# {self.script_template.format(value=1)}" - else: - return self.script_template.format(value=value) + + def get_toml_value(self, value: Union[bool, int, float, str]) -> Union[bool, int, float, str]: + """Get the value for TOML output""" + return value # Configuration schema definition CONFIG_SCHEMA: Dict[str, ConfigField] = { - "enable_lsfg": ConfigField( - name="enable_lsfg", + "enable": ConfigField( + name="enable", field_type=ConfigFieldType.BOOLEAN, default=True, - description="Enables the frame generation layer", - script_template="export ENABLE_LSFG={value}", - script_comment="# export ENABLE_LSFG=1" + description="enable/disable lsfg on every game" + ), + + "dll": ConfigField( + name="dll", + field_type=ConfigFieldType.STRING, + default="", # Will be populated dynamically based on detection + description="specify where Lossless.dll is stored" ), "multiplier": ConfigField( name="multiplier", field_type=ConfigFieldType.INTEGER, default=2, - description="Traditional FPS multiplier value", - script_template="export LSFG_MULTIPLIER={value}" + description="change the fps multiplier" ), "flow_scale": ConfigField( name="flow_scale", field_type=ConfigFieldType.FLOAT, default=0.8, - description="Lowers the internal motion estimation resolution", - script_template="export LSFG_FLOW_SCALE={value}" + description="change the flow scale" ), - "hdr": ConfigField( - name="hdr", - field_type=ConfigFieldType.BOOLEAN, - default=False, - description="Enable HDR mode (only if Game supports HDR)", - script_template="export LSFG_HDR={value}", - script_comment="# export LSFG_HDR=1" - ), - - "perf_mode": ConfigField( - name="perf_mode", + "performance_mode": ConfigField( + name="performance_mode", field_type=ConfigFieldType.BOOLEAN, default=True, - description="Use lighter model for FG", - script_template="export LSFG_PERF_MODE={value}", - script_comment="# export LSFG_PERF_MODE=1" + description="toggle performance mode" ), - "immediate_mode": ConfigField( - name="immediate_mode", + "hdr_mode": ConfigField( + name="hdr_mode", field_type=ConfigFieldType.BOOLEAN, default=False, - description="Reduce input lag (Experimental, will cause issues in many games)", - script_template="export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync", - script_comment="# export MESA_VK_WSI_PRESENT_MODE=immediate # - disable vsync" + description="enable hdr mode" ), - "disable_vkbasalt": ConfigField( - name="disable_vkbasalt", - field_type=ConfigFieldType.BOOLEAN, - default=True, - description="Some plugins add vkbasalt layer, which can break lsfg. Toggling on fixes this", - script_template="export DISABLE_VKBASALT={value}", - script_comment="# export DISABLE_VKBASALT=1" + "experimental_present_mode": ConfigField( + name="experimental_present_mode", + field_type=ConfigFieldType.STRING, + default="", + description="experimental: override vulkan present mode (empty/fifo/vsync/mailbox/immediate)" ), - "frame_cap": ConfigField( - name="frame_cap", + "experimental_fps_limit": ConfigField( + name="experimental_fps_limit", field_type=ConfigFieldType.INTEGER, default=0, - description="Limit base game FPS (0 = disabled)", - script_template="export DXVK_FRAME_RATE={value}", - script_comment="# export DXVK_FRAME_RATE=60" + description="experimental: base framerate cap for dxvk games, before frame multiplier (0 = disabled)" ) } class ConfigurationData(TypedDict): """Type-safe configuration data structure""" - enable_lsfg: bool + enable: bool + dll: str multiplier: int flow_scale: float - hdr: bool - perf_mode: bool - immediate_mode: bool - disable_vkbasalt: bool - frame_cap: int + performance_mode: bool + hdr_mode: bool + experimental_present_mode: str + experimental_fps_limit: int class ConfigurationManager: @@ -139,6 +120,34 @@ class ConfigurationManager: }) @staticmethod + def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData: + """Get default configuration values with DLL path detection + + Args: + dll_detection_service: Optional DLL detection service instance + + Returns: + ConfigurationData with detected DLL path if available + """ + defaults = ConfigurationManager.get_defaults() + + # Try to detect DLL path if service provided + if dll_detection_service: + try: + dll_result = dll_detection_service.check_lossless_scaling_dll() + if dll_result.get("detected") and dll_result.get("path"): + defaults["dll"] = dll_result["path"] + except Exception: + # If detection fails, keep empty default + pass + + # If DLL path is still empty, use a reasonable fallback + if not defaults["dll"]: + defaults["dll"] = "/home/deck/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" + + return defaults + + @staticmethod def get_field_names() -> list[str]: """Get ordered list of configuration field names""" return list(CONFIG_SCHEMA.keys()) @@ -166,63 +175,154 @@ class ConfigurationManager: validated[field_name] = int(value) elif field_def.field_type == ConfigFieldType.FLOAT: validated[field_name] = float(value) + elif field_def.field_type == ConfigFieldType.STRING: + validated[field_name] = str(value) else: validated[field_name] = value return cast(ConfigurationData, validated) @staticmethod - def generate_script_content(config: ConfigurationData) -> str: - """Generate lsfg script content from configuration""" - script_lines = ["#!/bin/bash", ""] + def generate_toml_content(config: ConfigurationData) -> str: + """Generate TOML configuration file content using the new game-specific format""" + lines = ["version = 1"] + lines.append("") - # Generate script lines for each field - for field_name in CONFIG_SCHEMA.keys(): - field_def = CONFIG_SCHEMA[field_name] + # Add global section with DLL path only (if specified) + if config.get("dll"): + lines.append("[global]") + lines.append(f"# specify where Lossless.dll is stored") + lines.append(f'dll = "{config["dll"]}"') + lines.append("") + + # Add game section with process name for LSFG_PROCESS approach + lines.append("[[game]]") + lines.append("# Plugin-managed game entry (uses LSFG_PROCESS=decky-lsfg-vk)") + lines.append('exe = "decky-lsfg-vk"') + lines.append("") + + # Add all configuration fields to the game section + for field_name, field_def in CONFIG_SCHEMA.items(): + # Skip dll and enable fields - dll goes in global, enable is handled via multiplier + if field_name in ["dll", "enable"]: + continue + value = config[field_name] - if field_def.field_type == ConfigFieldType.BOOLEAN: - if value: - script_lines.append(field_def.script_template.format(value=1)) - else: - script_lines.append(field_def.script_comment) + # Handle enable field by setting multiplier to 1 when disabled + if field_name == "multiplier" and not config.get("enable", True): + value = 1 + lines.append(f"# LSFG disabled via plugin - multiplier set to 1") else: - # For frame_cap, special handling for 0 value - if field_name == "frame_cap" and value == 0: - script_lines.append(field_def.script_comment) - else: - script_lines.append(field_def.script_template.format(value=value)) - - # Add script footer - script_lines.extend([ - "", - "# Execute the passed command with the environment variables set", - 'exec "$@"' - ]) + lines.append(f"# {field_def.description}") + + # Format value based on type + if isinstance(value, bool): + lines.append(f"{field_name} = {str(value).lower()}") + elif isinstance(value, str) and value: # Only add non-empty strings + lines.append(f'{field_name} = "{value}"') + elif isinstance(value, (int, float)) and value != 0: # Only add non-zero numbers + lines.append(f"{field_name} = {value}") + + lines.append("") # Empty line for readability - return "\n".join(script_lines) + return "\n".join(lines) @staticmethod - def get_update_signature() -> list[tuple[str, type]]: - """Get the function signature for update_config method""" - signature = [] - for field_name, field_def in CONFIG_SCHEMA.items(): - if field_def.field_type == ConfigFieldType.BOOLEAN: - signature.append((field_name, bool)) - elif field_def.field_type == ConfigFieldType.INTEGER: - signature.append((field_name, int)) - elif field_def.field_type == ConfigFieldType.FLOAT: - signature.append((field_name, float)) - return signature + def parse_toml_content(content: str) -> ConfigurationData: + """Parse TOML content into configuration data using simple regex parsing""" + config = ConfigurationManager.get_defaults() + + try: + # Look for both [global] and [[game]] sections + lines = content.split('\n') + in_global_section = False + in_game_section = False + current_game_exe = None + + for line in lines: + line = line.strip() + + # Skip comments and empty lines + if not line or line.startswith('#'): + continue + + # Check for section headers + if line.startswith('[') and line.endswith(']'): + if line == '[global]': + in_global_section = True + in_game_section = False + elif line == '[[game]]': + in_global_section = False + in_game_section = True + current_game_exe = None + else: + in_global_section = False + in_game_section = False + continue + + # Parse key = value lines + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes from string values + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # Handle global section (dll only) + if in_global_section and key == "dll": + config["dll"] = value + + # Handle game section + elif in_game_section: + # Track the exe for this game section + if key == "exe": + current_game_exe = value + # Only parse config for our plugin-managed game entry + elif current_game_exe == "decky-lsfg-vk" and key in CONFIG_SCHEMA: + field_def = CONFIG_SCHEMA[key] + try: + if field_def.field_type == ConfigFieldType.BOOLEAN: + config[key] = value.lower() in ('true', '1', 'yes', 'on') + elif field_def.field_type == ConfigFieldType.INTEGER: + parsed_value = int(value) + # Handle enable field via multiplier + if key == "multiplier": + config[key] = parsed_value + config["enable"] = parsed_value != 1 + else: + config[key] = parsed_value + elif field_def.field_type == ConfigFieldType.FLOAT: + config[key] = float(value) + elif field_def.field_type == ConfigFieldType.STRING: + config[key] = value + except (ValueError, TypeError): + # If conversion fails, keep default value + pass + + return config + + except Exception: + # If parsing fails completely, return defaults + return ConfigurationManager.get_defaults() @staticmethod - def create_config_from_args(*args) -> ConfigurationData: - """Create configuration from ordered arguments""" - field_names = ConfigurationManager.get_field_names() - if len(args) != len(field_names): - raise ValueError(f"Expected {len(field_names)} arguments, got {len(args)}") - + def create_config_from_args(enable: bool, dll: str, multiplier: int, flow_scale: float, + performance_mode: bool, hdr_mode: bool, + experimental_present_mode: str = "", + experimental_fps_limit: int = 0) -> ConfigurationData: + """Create configuration from individual arguments""" return cast(ConfigurationData, { - field_name: args[i] - for i, field_name in enumerate(field_names) + "enable": enable, + "dll": dll, + "multiplier": multiplier, + "flow_scale": flow_scale, + "performance_mode": performance_mode, + "hdr_mode": hdr_mode, + "experimental_present_mode": experimental_present_mode, + "experimental_fps_limit": experimental_fps_limit }) diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index 8be7b47..255092a 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,8 +1,7 @@ """ -Configuration service for lsfg script management. +Configuration service for TOML-based lsfg configuration management. """ -import re from pathlib import Path from typing import Dict, Any @@ -12,25 +11,29 @@ from .types import ConfigurationResponse class ConfigurationService(BaseService): - """Service for managing lsfg script configuration""" + """Service for managing TOML-based lsfg configuration""" def get_config(self) -> ConfigurationResponse: - """Read current lsfg script configuration + """Read current TOML configuration Returns: ConfigurationResponse with current configuration or error """ try: - if not self.lsfg_script_path.exists(): + if not self.config_file_path.exists(): + # Return default configuration with DLL detection if file doesn't exist + from .dll_detection import DllDetectionService + dll_service = DllDetectionService(self.log) + config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) return { - "success": False, - "config": None, - "message": None, - "error": "lsfg script not found" + "success": True, + "config": config, + "message": "Using default configuration (config file not found)", + "error": None } - content = self.lsfg_script_path.read_text() - config = self._parse_script_content(content) + content = self.config_file_path.read_text(encoding='utf-8') + config = ConfigurationManager.parse_toml_content(content) return { "success": True, @@ -48,83 +51,35 @@ class ConfigurationService(BaseService): "message": None, "error": str(e) } + except Exception as e: + error_msg = f"Error parsing config file: {str(e)}" + self.log.error(error_msg) + # Return defaults with DLL detection if parsing fails + from .dll_detection import DllDetectionService + dll_service = DllDetectionService(self.log) + config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + return { + "success": True, + "config": config, + "message": f"Using default configuration due to parse error: {str(e)}", + "error": None + } - def _parse_script_content(self, content: str) -> ConfigurationData: - """Parse script content to extract configuration values - - Args: - content: Script file content - - Returns: - ConfigurationData with parsed values - """ - # Start with defaults - config = ConfigurationManager.get_defaults() - - lines = content.split('\n') - for line in lines: - line = line.strip() - - # Parse ENABLE_LSFG - if match := re.match(r'^(#\s*)?export\s+ENABLE_LSFG=(\d+)', line): - config["enable_lsfg"] = not bool(match.group(1)) and match.group(2) == '1' - - # Parse LSFG_MULTIPLIER - elif match := re.match(r'^export\s+LSFG_MULTIPLIER=(\d+)', line): - try: - config["multiplier"] = int(match.group(1)) - except ValueError: - pass - - # Parse LSFG_FLOW_SCALE - elif match := re.match(r'^export\s+LSFG_FLOW_SCALE=([0-9]*\.?[0-9]+)', line): - try: - config["flow_scale"] = float(match.group(1)) - except ValueError: - pass - - # Parse LSFG_HDR - elif match := re.match(r'^(#\s*)?export\s+LSFG_HDR=(\d+)', line): - config["hdr"] = not bool(match.group(1)) and match.group(2) == '1' - - # Parse LSFG_PERF_MODE - elif match := re.match(r'^(#\s*)?export\s+LSFG_PERF_MODE=(\d+)', line): - config["perf_mode"] = not bool(match.group(1)) and match.group(2) == '1' - - # Parse MESA_VK_WSI_PRESENT_MODE - elif match := re.match(r'^(#\s*)?export\s+MESA_VK_WSI_PRESENT_MODE=([^\s#]+)', line): - config["immediate_mode"] = not bool(match.group(1)) and match.group(2) == 'immediate' - - # Parse DISABLE_VKBASALT - elif match := re.match(r'^(#\s*)?export\s+DISABLE_VKBASALT=(\d+)', line): - config["disable_vkbasalt"] = not bool(match.group(1)) and match.group(2) == '1' - - # Parse DXVK_FRAME_RATE - elif match := re.match(r'^(#\s*)?export\s+DXVK_FRAME_RATE=(\d+)', line): - if not bool(match.group(1)): # Not commented out - try: - config["frame_cap"] = int(match.group(2)) - except ValueError: - pass - else: - # If it's commented out, frame cap is disabled (0) - config["frame_cap"] = 0 - - return config - - def update_config(self, enable_lsfg: bool, multiplier: int, flow_scale: float, - hdr: bool, perf_mode: bool, immediate_mode: bool, disable_vkbasalt: bool, frame_cap: int) -> ConfigurationResponse: - """Update lsfg script configuration + def update_config(self, enable: bool, dll: str, multiplier: int, flow_scale: float, + performance_mode: bool, hdr_mode: bool, + experimental_present_mode: str = "", + experimental_fps_limit: int = 0) -> ConfigurationResponse: + """Update TOML configuration Args: - enable_lsfg: Whether to enable LSFG + enable: Whether to enable LSFG + dll: Path to Lossless.dll multiplier: LSFG multiplier value flow_scale: LSFG flow scale value - hdr: Whether to enable HDR - perf_mode: Whether to enable performance mode - immediate_mode: Whether to enable immediate present mode (disable vsync) - disable_vkbasalt: Whether to disable vkbasalt layer - frame_cap: Frame rate cap value (0-60, 0 = disabled) + performance_mode: Whether to enable performance mode + hdr_mode: Whether to enable HDR mode + experimental_present_mode: Experimental Vulkan present mode override + experimental_fps_limit: Experimental FPS limit for DXVK games Returns: ConfigurationResponse with success status @@ -132,23 +87,28 @@ class ConfigurationService(BaseService): try: # Create configuration from individual arguments config = ConfigurationManager.create_config_from_args( - enable_lsfg, multiplier, flow_scale, hdr, perf_mode, immediate_mode, disable_vkbasalt, frame_cap + enable, dll, multiplier, flow_scale, performance_mode, hdr_mode, + experimental_present_mode, experimental_fps_limit ) - # Generate script content using centralized manager - script_content = ConfigurationManager.generate_script_content(config) + # Generate TOML content using centralized manager + toml_content = ConfigurationManager.generate_toml_content(config) - # Write the updated script atomically - self._atomic_write(self.lsfg_script_path, script_content, 0o755) + # Ensure config directory exists + self.config_dir.mkdir(parents=True, exist_ok=True) - self.log.info(f"Updated lsfg script configuration: enable_lsfg={enable_lsfg}, " - f"multiplier={multiplier}, flow_scale={flow_scale}, hdr={hdr}, " - f"perf_mode={perf_mode}, immediate_mode={immediate_mode}, " - f"disable_vkbasalt={disable_vkbasalt}, frame_cap={frame_cap}") + # Write the updated config directly to preserve inode for file watchers + self._write_file(self.config_file_path, toml_content, 0o644) + + self.log.info(f"Updated lsfg TOML configuration: enable={enable}, " + 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"experimental_fps_limit={experimental_fps_limit}") return { "success": True, - "config": None, + "config": config, "message": "lsfg configuration updated successfully", "error": None } @@ -171,3 +131,54 @@ class ConfigurationService(BaseService): "message": None, "error": str(e) } + + def update_dll_path(self, dll_path: str) -> ConfigurationResponse: + """Update just the DLL path in the configuration + + Args: + dll_path: Path to the Lossless.dll file + + Returns: + ConfigurationResponse with success status + """ + try: + # Get current config + current_response = self.get_config() + if not current_response["success"] or current_response["config"] is None: + # If we can't read current config, use defaults with DLL detection + from .dll_detection import DllDetectionService + dll_service = DllDetectionService(self.log) + config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + else: + config = current_response["config"] + + # Update just the DLL path + config["dll"] = dll_path + + # Generate TOML content and write it + 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) + + self.log.info(f"Updated DLL path in lsfg configuration: '{dll_path}'") + + return { + "success": True, + "config": config, + "message": f"DLL path updated to: {dll_path}", + "error": None + } + + except Exception as e: + error_msg = f"Error updating DLL path: {str(e)}" + self.log.error(error_msg) + return { + "success": False, + "config": None, + "message": None, + "error": str(e) + } diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 5f1e5a2..252c7a5 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -8,9 +8,11 @@ from pathlib import Path LOCAL_LIB = ".local/lib" LOCAL_SHARE_BASE = ".local/share" VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" +CONFIG_DIR = ".config/lsfg-vk" # File names SCRIPT_NAME = "lsfg" +CONFIG_FILENAME = "conf.toml" LIB_FILENAME = "liblsfg-vk.so" JSON_FILENAME = "VkLayer_LS_frame_generation.json" ZIP_FILENAME = "lsfg-vk_archlinux.zip" diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 767a97a..d193219 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -51,8 +51,11 @@ class InstallationService(BaseService): # Extract and install files self._extract_and_install_files(zip_path) - # Create the lsfg script - self._create_lsfg_script() + # Create the config file + self._create_config_file() + + # Create the lsfg launch script + self._create_lsfg_launch_script() self.log.info("lsfg-vk installed successfully") return {"success": True, "message": "lsfg-vk installed successfully", "error": None} @@ -102,17 +105,38 @@ class InstallationService(BaseService): shutil.copy2(src_file, dst_file) self.log.info(f"Copied {file} to {dst_file}") - def _create_lsfg_script(self) -> None: - """Create the lsfg script in home directory with default configuration""" - # Get default configuration - defaults = ConfigurationManager.get_defaults() + def _create_config_file(self) -> None: + """Create the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path""" + # Import here to avoid circular imports + from .dll_detection import DllDetectionService + + # Try to detect DLL path + dll_service = DllDetectionService(self.log) + config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + + # Generate TOML content using centralized manager + toml_content = ConfigurationManager.generate_toml_content(config) - # Generate script content using centralized manager - script_content = ConfigurationManager.generate_script_content(defaults) + # Write initial config file + self._write_file(self.config_file_path, toml_content, 0o644) + self.log.info(f"Created config file at {self.config_file_path}") - # Use atomic write to prevent corruption - self._atomic_write(self.lsfg_script_path, script_content, 0o755) - self.log.info(f"Created executable lsfg script at {self.lsfg_script_path}") + # Log detected DLL path if found + 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""" + 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 LSFG_PROCESS=decky-lsfg-vk +exec "$@" +""" + + # Write the script file + self._write_file(self.lsfg_launch_script_path, script_content, 0o755) + self.log.info(f"Created lsfg launch script at {self.lsfg_launch_script_path}") def check_installation(self) -> InstallationCheckResponse: """Check if lsfg-vk is already installed @@ -123,18 +147,18 @@ class InstallationService(BaseService): try: lib_exists = self.lib_file.exists() json_exists = self.json_file.exists() - script_exists = self.lsfg_script_path.exists() + config_exists = self.config_file_path.exists() - self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, script={script_exists}") + self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, config={config_exists}") return { "installed": lib_exists and json_exists, "lib_exists": lib_exists, "json_exists": json_exists, - "script_exists": script_exists, + "script_exists": config_exists, # Keep script_exists for backward compatibility "lib_path": str(self.lib_file), "json_path": str(self.json_file), - "script_path": str(self.lsfg_script_path), + "script_path": str(self.config_file_path), # Keep script_path for backward compatibility "error": None } @@ -148,7 +172,7 @@ class InstallationService(BaseService): "script_exists": False, "lib_path": str(self.lib_file), "json_path": str(self.json_file), - "script_path": str(self.lsfg_script_path), + "script_path": str(self.config_file_path), "error": str(e) } @@ -160,12 +184,24 @@ class InstallationService(BaseService): """ try: removed_files = [] - files_to_remove = [self.lib_file, self.json_file, self.lsfg_script_path] + files_to_remove = [self.lib_file, self.json_file, self.config_file_path, self.lsfg_launch_script_path] for file_path in files_to_remove: if self._remove_if_exists(file_path): removed_files.append(str(file_path)) + # Also try to remove the old script file if it exists (for backward compatibility) + if self._remove_if_exists(self.lsfg_script_path): + removed_files.append(str(self.lsfg_script_path)) + + # Remove config directory if it's empty + try: + if self.config_dir.exists() and not any(self.config_dir.iterdir()): + self.config_dir.rmdir() + removed_files.append(str(self.config_dir)) + except OSError: + pass # Directory not empty or other error, ignore + if not removed_files: return { "success": True, @@ -198,10 +234,12 @@ class InstallationService(BaseService): self.log.info("Checking for lsfg-vk files to clean up:") self.log.info(f" Library file: {self.lib_file}") self.log.info(f" JSON file: {self.json_file}") - self.log.info(f" lsfg script: {self.lsfg_script_path}") + self.log.info(f" Config file: {self.config_file_path}") + self.log.info(f" Launch script: {self.lsfg_launch_script_path}") + self.log.info(f" Old script file: {self.lsfg_script_path}") removed_files = [] - files_to_remove = [self.lib_file, self.json_file, self.lsfg_script_path] + files_to_remove = [self.lib_file, self.json_file, self.config_file_path, self.lsfg_launch_script_path, self.lsfg_script_path] for file_path in files_to_remove: try: @@ -210,6 +248,14 @@ class InstallationService(BaseService): except OSError as e: self.log.error(f"Failed to remove {file_path}: {e}") + # Try to remove config directory if empty + try: + if self.config_dir.exists() and not any(self.config_dir.iterdir()): + self.config_dir.rmdir() + removed_files.append(str(self.config_dir)) + except OSError: + pass # Directory not empty or other error, ignore + if removed_files: self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}") else: diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index 3e37047..101542c 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -69,6 +69,38 @@ class Plugin: """ return self.dll_detection_service.check_lossless_scaling_dll() + async def check_lossless_scaling_dll_and_update_config(self) -> Dict[str, Any]: + """Check for DLL and automatically update configuration if found + + This method should only be used during installation or when explicitly + requested by the user, not for routine DLL detection checks. + + Returns: + DllDetectionResponse dict with detection status and path info + """ + result = self.dll_detection_service.check_lossless_scaling_dll() + + # Convert to dict to allow modification + result_dict = dict(result) + + # If DLL was detected, automatically update the configuration + if result.get("detected") and result.get("path"): + try: + dll_path = result["path"] + if dll_path: # Type guard + update_result = self.configuration_service.update_dll_path(dll_path) + if update_result.get("success"): + result_dict["config_updated"] = True + result_dict["message"] = f"DLL detected and configuration updated: {dll_path}" + else: + result_dict["config_updated"] = False + result_dict["message"] = f"DLL detected but config update failed: {update_result.get('error', 'Unknown error')}" + except Exception as e: + result_dict["config_updated"] = False + result_dict["message"] = f"DLL detected but config update failed: {str(e)}" + + return result_dict + # Configuration methods async def get_lsfg_config(self) -> Dict[str, Any]: """Read current lsfg script configuration @@ -90,27 +122,41 @@ class Plugin: "defaults": ConfigurationManager.get_defaults() } - async def update_lsfg_config(self, enable_lsfg: bool, multiplier: int, flow_scale: float, - hdr: bool, perf_mode: bool, immediate_mode: bool, disable_vkbasalt: bool, frame_cap: int) -> Dict[str, Any]: - """Update lsfg script configuration + async def update_lsfg_config(self, enable: bool, dll: str, multiplier: int, flow_scale: float, + performance_mode: bool, hdr_mode: bool, + experimental_present_mode: str = "", + experimental_fps_limit: int = 0) -> Dict[str, Any]: + """Update lsfg TOML configuration Args: - enable_lsfg: Whether to enable LSFG + enable: Whether to enable LSFG + dll: Path to Lossless.dll multiplier: LSFG multiplier value flow_scale: LSFG flow scale value - hdr: Whether to enable HDR - perf_mode: Whether to enable performance mode - immediate_mode: Whether to enable immediate present mode (disable vsync) - disable_vkbasalt: Whether to disable vkbasalt layer - frame_cap: Frame rate cap value (0-60, 0 = disabled) + performance_mode: Whether to enable performance mode + hdr_mode: Whether to enable HDR mode + experimental_present_mode: Experimental Vulkan present mode override + experimental_fps_limit: Experimental FPS limit for DXVK games Returns: ConfigurationResponse dict with success status """ return self.configuration_service.update_config( - enable_lsfg, multiplier, flow_scale, hdr, perf_mode, immediate_mode, disable_vkbasalt, frame_cap + enable, dll, multiplier, flow_scale, performance_mode, hdr_mode, + experimental_present_mode, experimental_fps_limit ) + async def update_dll_path(self, dll_path: str) -> Dict[str, Any]: + """Update the DLL path in the configuration when detected + + Args: + dll_path: Path to the detected Lossless.dll file + + Returns: + ConfigurationResponse dict with success status + """ + return self.configuration_service.update_dll_path(dll_path) + # Self-updater methods async def check_for_plugin_update(self) -> Dict[str, Any]: """Check for plugin updates by comparing current version with latest GitHub release @@ -306,29 +352,43 @@ class Plugin: return False # Plugin lifecycle methods + # Launch option methods + async def get_launch_option(self) -> Dict[str, Any]: + """Get the launch option that users need to set for their games + + Returns: + Dict containing the launch option string and instructions + """ + return { + "launch_option": "~/lsfg %command%", + "instructions": "Add this to your game's launch options in Steam Properties", + "explanation": "The lsfg script is created during installation and sets up the environment for the plugin" + } + + # Lifecycle methods async def _main(self): """ - Asyncio-compatible long-running code, executed in a task when the plugin is loaded. + Main entry point for the plugin. - This method is called by Decky Loader when the plugin starts up. - Currently just logs that the plugin has loaded successfully. + This method is called by Decky Loader when the plugin is loaded. + Any initialization code should go here. """ import decky - decky.logger.info("Lossless Scaling VK plugin loaded!") + decky.logger.info("Lossless Scaling VK plugin loaded") async def _unload(self): """ - Function called first during the unload process. + Cleanup tasks when the plugin is unloaded. This method is called by Decky Loader when the plugin is being unloaded. - Use this for cleanup that should happen when the plugin stops. + Any cleanup code should go here. """ import decky - decky.logger.info("Lossless Scaling VK plugin unloading") + decky.logger.info("Lossless Scaling VK plugin unloaded") async def _uninstall(self): """ - Function called after `_unload` during uninstall. + Cleanup tasks when the plugin is uninstalled. This method is called by Decky Loader when the plugin is being uninstalled. It automatically cleans up any lsfg-vk files that were installed. |
