diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-07-30 16:26:27 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-07-30 16:26:27 -0400 |
| commit | 4390d600ffd35184c4c30bc64480f58e468627de (patch) | |
| tree | a99fbdadacd15395125e5344a462abbe5327e17e | |
| parent | 6c5e8a8ef81b7b3d2bf19b1783250e6b0cefedf7 (diff) | |
| download | decky-lsfg-vk-feat/lsfg-v2-release.tar.gz decky-lsfg-vk-feat/lsfg-v2-release.zip | |
feat: migrate Decky plugin to lsfg-vk v2feat/lsfg-v2-release
| -rw-r--r-- | README.md | 24 | ||||
| -rw-r--r-- | package.json | 31 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/base_service.py | 27 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema.py | 734 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/config_schema_generated.py | 25 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 36 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/constants.py | 24 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 85 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 597 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 35 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/types.py | 3 | ||||
| -rw-r--r-- | scripts/generate_python_boilerplate.py | 8 | ||||
| -rw-r--r-- | shared_config.py | 138 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 1 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 68 | ||||
| -rw-r--r-- | src/components/FlatpaksModal.tsx | 4 | ||||
| -rw-r--r-- | src/components/FpsMultiplierControl.tsx | 6 | ||||
| -rw-r--r-- | src/components/InstallationButton.tsx | 2 | ||||
| -rw-r--r-- | src/components/UsageInstructions.tsx | 2 | ||||
| -rw-r--r-- | src/config/configSchema.ts | 4 | ||||
| -rw-r--r-- | src/config/generatedConfigSchema.ts | 104 | ||||
| -rw-r--r-- | src/hooks/useLsfgHooks.ts | 4 | ||||
| -rw-r--r-- | tests/test_lsfg_v2_migration.py | 156 |
23 files changed, 929 insertions, 1189 deletions
@@ -18,6 +18,8 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://github.com/PancakeTAS/lsfg-vk)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. +> The v2 layer payload is currently pinned to the owner-fork prerelease [`v2.0.0-decky.1`](https://github.com/xXJSONDeruloXx/lsfg-vk/releases/tag/v2.0.0-decky.1). + ## Installation 1. **Download the plugin** from the [releases tab](https://github.com/xXJSONDeruloXx/decky-lsfg-vk/releases) @@ -33,7 +35,7 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer -4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, performance mode, HDR settings, and experimental features +4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, performance mode, FP16, automatic profile matching, and optional GPU selection 5. **Apply launch option** to games you want to use frame generation with: - Add `~/lsfg %command%` to your game's launch options in Steam Properties - Or use the "Launch Option Clipboard" button in the plugin to copy the command @@ -46,8 +48,11 @@ The plugin provides several configuration options to optimize frame generation f ### Core Settings - **FPS Multiplier**: Choose between 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) -- **Performance Mode**: Uses a lighter processing model - recommended for most games -- **HDR Mode**: Enable for games that support HDR output +- **Performance Mode**: Uses a lighter processing model +- **Allow FP16**: Enable half-precision acceleration; disable it for older NVIDIA GPUs +- **Active In**: Optionally match a profile to executable names, Wine executables, or process names +- **GPU**: Optionally select the GPU by name, vendor/device ID, or PCI bus ID +- **Disable Frame Generation**: Disable the layer for the next launch without creating an invalid 1x profile ## Feedback and Support @@ -59,6 +64,7 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc - Ensure you've added `~/lsfg %command%` to your game's launch options - Check that the Lossless Scaling DLL was detected correctly in the plugin - Try enabling Performance Mode if you're experiencing crashes +- Ensure the selected profile has no `Active In` value when you want the launcher to force that profile - Make sure your game is running in fullscreen mode for best results **Performance issues?** @@ -70,17 +76,17 @@ For per-game feedback and community support, please join the [decky-lsfg-vk Disc ## What it does The plugin: -- Automatically downloads and installs the latest lsfg-vk Vulkan layer to `~/.local/lib/` -- Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` -- Creates a TOML configuration file in `~/.config/lsfg-vk/conf.toml` with your settings +- Downloads checksum-pinned x86_64 or Armada/aarch64 lsfg-vk v2 assets from the owner-fork release to `~/.local/lib/` +- Configures the v2 Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` +- Migrates the existing `~/.config/lsfg-vk/conf.toml` from v1 to v2 automatically on layer update, retaining a one-time `.v1.bak` backup - Automatically detects your Lossless Scaling DLL installation - Provides an easy-to-use interface to configure frame generation settings: - **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality vs performance - **Performance Mode**: Use lighter processing for better performance - - **HDR Mode**: Enable for HDR-compatible games - - **Experimental Features**: Override present mode and set FPS limits -- **Hot-reloading**: Configuration changes apply immediately without restarting games + - **FP16, Active In, and GPU**: Use v2 profile parameters and native matching + - **Launch workarounds**: Preserve existing DXVK, Gamescope, Zink, MangoHud, and vkBasalt launch options +- **Hot-reloading**: Multiplier, flow scale, and performance-mode changes are reloaded while games run; other changes may require a swapchain recreation or restart - Easy uninstallation that removes all installed files when no longer needed ## Credits diff --git a/package.json b/package.json index 347820b..75bf2f7 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "decky-lsfg-vk", - "version": "0.12.5", + "version": "0.13.0", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "python3 -m unittest discover -s tests -v" }, "repository": { "type": "git", @@ -45,29 +45,14 @@ "remote_binary_bundling" : true, "remote_binary": [ { - "name": "lsfg-vk_noui.zip", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/fp16-test-2/lsfg-vk_noui.zip", - "sha256hash": "a406b3730144c2011e2c2acd3cf44f3ec6c048ee86099bc9c3ac90aa1515e5ec" + "name": "lsfg-vk-layer-x86_64.tar.xz", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.1/lsfg-vk-layer-x86_64.tar.xz", + "sha256hash": "b96c60d838cd778797406bafc31e17a3698e8145248113c2bc41151b2823a6a0" }, { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "sha256hash": "8381d1eff0b0786af5a0e66955521eadec783866f73b9b1aa4664c952ed601a4" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "sha256hash": "70ea6d9d2a8adad63b8e5c2c3d94a636764ad4d0266ce9f37cb9dc7943d8fc3a" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v1.0.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "sha256hash": "0651bda96751ef0f1314a5179585926a0cd354476790ca2616662c39fe6fae54" - }, - { - "name": "liblsfg-vk-arm64.so", - "url": "https://github.com/Janleyx/decky-lsfg-vk-for-Ayn-Odin-2/releases/download/arm64-layer-3e89e54/liblsfg-vk-arm64.so", - "sha256hash": "2e84f8d1a1dd0344474846f6252d6f948f72caaaeab3cd316c04254be05a9949" + "name": "lsfg-vk-layer-aarch64.tar.xz", + "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/v2.0.0-decky.1/lsfg-vk-layer-aarch64.tar.xz", + "sha256hash": "b1703042f50ff9470aa0b90953077949a9f144dcbd926f80fbb7e386e8393367" } ], "pnpm": { diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py index 262e2b0..15e14f7 100644 --- a/py_modules/lsfg_vk/base_service.py +++ b/py_modules/lsfg_vk/base_service.py @@ -5,12 +5,13 @@ Base service class with common functionality. import logging import os import shutil +import tempfile from pathlib import Path from typing import Any, Optional, TypeVar, Dict import decky -from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME +from .constants import LOCAL_LIB, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME ResponseType = TypeVar('ResponseType', bound=Dict[str, Any]) @@ -79,16 +80,24 @@ class BaseService: Raises: OSError: If write fails """ + temp_path = None try: - with open(path, 'w', encoding='utf-8') as f: - f.write(content) - f.flush() - os.fsync(f.fileno()) - - path.chmod(mode) - self.log.info(f"Wrote to {path}") - + with tempfile.NamedTemporaryFile( + mode='w', encoding='utf-8', dir=path.parent, + prefix=f'.{path.name}.', delete=False, + ) as temp_file: + temp_path = Path(temp_file.name) + temp_file.write(content) + temp_file.flush() + os.fsync(temp_file.fileno()) + + temp_path.chmod(mode) + os.replace(temp_path, path) + self.log.info(f"Atomically wrote {path}") + except (OSError, IOError, PermissionError) as e: + if temp_path is not None: + temp_path.unlink(missing_ok=True) self.log.error(f"Failed to write to {path}: {e}") raise diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py index 3a82bbd..258b9dd 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,605 +1,313 @@ -""" -Centralized configuration schema for lsfg-vk. - -This module defines the complete configuration structure for lsfg-vk, managing TOML-based config files, including: -- Field definitions with types, defaults, and metadata -- TOML generation logic -- Validation rules -- Type definitions -""" +"""lsfg-vk v2 configuration and Decky profile management.""" +import json import logging import re import sys -from typing import TypedDict, Dict, Any, Union, cast, List +import tomllib from dataclasses import dataclass -from enum import Enum from pathlib import Path +from typing import Any, Dict, TypedDict, Union, cast -# Import shared configuration constants 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 +from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_defaults +from .config_schema_generated import ConfigurationData, get_script_parsing_logic @dataclass class ConfigField: - """Configuration field definition""" name: str field_type: ConfigFieldType default: Union[bool, int, float, str] description: str - - def get_toml_value(self, value: Union[bool, int, float, str]) -> Union[bool, int, float, str]: - """Get the value for TOML output""" - return value -# Use shared configuration schema as source of truth CONFIG_SCHEMA: Dict[str, ConfigField] = { - field_name: ConfigField( - name=field_def["name"], - field_type=ConfigFieldType(field_def["fieldType"]), - default=field_def["default"], - description=field_def["description"] + name: ConfigField( + name=definition["name"], + field_type=ConfigFieldType(definition["fieldType"]), + default=definition["default"], + description=definition["description"], ) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() + for name, definition in CONFIG_SCHEMA_DEF.items() } - -# Override DLL default to empty (will be populated dynamically) -CONFIG_SCHEMA["dll"] = ConfigField( - name="dll", - field_type=ConfigFieldType.STRING, - default="", # Will be populated dynamically based on detection - description="specify where Lossless.dll is stored" -) - -# Get script-only fields dynamically from shared config +GLOBAL_SECTION_FIELDS = {"dll", "allow_fp16"} SCRIPT_ONLY_FIELDS = { - 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" + name for name, definition in CONFIG_SCHEMA_DEF.items() + if definition["location"] == "script" } - -# Complete configuration schema (TOML + script-only fields) -COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS} - -# Constants for profile management +PROFILE_TOML_FIELDS = {"active_in", "gpu", "multiplier", "flow_scale", "performance_mode", "pacing"} DEFAULT_PROFILE_NAME = "decky-lsfg-vk" -GLOBAL_SECTION_FIELDS = {"dll", "no_fp16"} - -# Note: ConfigurationData is now imported from generated file -# No need to manually maintain the TypedDict anymore! +CURRENT_PROFILE_COMMENT = re.compile(r'^\s*#\s*decky-current-profile\s*=\s*"([^"]+)"\s*$') class ProfileData(TypedDict): - """Profile data with current profile tracking""" current_profile: str - profiles: Dict[str, ConfigurationData] # profile_name -> config - global_config: Dict[str, Any] # Global settings (dll, no_fp16) + profiles: Dict[str, ConfigurationData] + global_config: Dict[str, Any] + + +def _toml_string(value: str) -> str: + return json.dumps(value) class ConfigurationManager: - """Centralized configuration management""" - + """Read both legacy v1 and upstream v2 configs, and write only v2.""" + @staticmethod def get_defaults() -> ConfigurationData: - """Get default configuration values""" - # Use shared defaults and add script-only fields - shared_defaults = get_defaults() - - # Add script-only fields that aren't in the shared schema - script_defaults = { - field.name: field.default - for field in SCRIPT_ONLY_FIELDS.values() - } - - return cast(ConfigurationData, {**shared_defaults, **script_defaults}) - + return cast(ConfigurationData, dict(get_defaults())) + @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 (OSError, IOError, KeyError, TypeError) as e: - # If detection fails, keep empty default - logging.getLogger(__name__).debug(f"DLL detection failed: {e}") - - # 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" - + result = dll_detection_service.check_lossless_scaling_dll() + if result.get("detected") and result.get("path"): + defaults["dll"] = result["path"] + except (OSError, IOError, KeyError, TypeError) as error: + logging.getLogger(__name__).debug("DLL detection failed: %s", error) return defaults - + @staticmethod def get_field_names() -> list[str]: - """Get ordered list of configuration field names""" - # Use shared field names and add script-only fields - shared_names = get_field_names() - script_names = list(SCRIPT_ONLY_FIELDS.keys()) - return shared_names + script_names - + return list(CONFIG_SCHEMA) + @staticmethod def get_field_types() -> Dict[str, ConfigFieldType]: - """Get field type mapping""" - # Use shared field types and add script-only field types - shared_types = {name: ConfigFieldType(type_str) for name, type_str in get_field_types().items()} - script_types = {field.name: field.field_type for field in SCRIPT_ONLY_FIELDS.values()} - return {**shared_types, **script_types} - + return {name: field.field_type for name, field in CONFIG_SCHEMA.items()} + @staticmethod def validate_config(config: Dict[str, Any]) -> ConfigurationData: - """Validate and convert configuration data""" - validated = {} - - for field_name, field_def in COMPLETE_CONFIG_SCHEMA.items(): - value = config.get(field_name, field_def.default) - - # Type validation and conversion - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated[field_name] = bool(value) - elif field_def.field_type == ConfigFieldType.INTEGER: - 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) + validated: Dict[str, Any] = {} + for name, field in CONFIG_SCHEMA.items(): + value = config.get(name, field.default) + if field.field_type == ConfigFieldType.BOOLEAN: + value = value.lower() in {"true", "1", "yes", "on"} if isinstance(value, str) else bool(value) + elif field.field_type == ConfigFieldType.INTEGER: + value = int(value) + elif field.field_type == ConfigFieldType.FLOAT: + value = float(value) else: - validated[field_name] = value - + value = str(value) + validated[name] = value + + if validated["multiplier"] < 2: + raise ValueError("multiplier must be 2 or greater") + if not 0.25 <= validated["flow_scale"] <= 1.0: + raise ValueError("flow_scale must be between 0.25 and 1.0") + if validated["pacing"] != "none": + raise ValueError("only pacing = 'none' is currently available") return cast(ConfigurationData, validated) - + + @staticmethod + def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> ConfigurationData: + config = ConfigurationManager.get_defaults() + for field in PROFILE_TOML_FIELDS | SCRIPT_ONLY_FIELDS: + if field not in profile: + continue + value = profile[field] + if field == "active_in" and isinstance(value, list): + value = ", ".join(str(item) for item in value) + config[field] = value + for field in GLOBAL_SECTION_FIELDS: + if field in global_config: + config[field] = global_config[field] + return ConfigurationManager.validate_config(config) + @staticmethod def generate_toml_content(config: ConfigurationData) -> str: - """Generate TOML configuration file content for single profile (backward compatibility)""" - # For backward compatibility, create a single profile structure profile_data: ProfileData = { "current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: config}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": False # Always enabled even if previously set - } + "global_config": {field: config[field] for field in GLOBAL_SECTION_FIELDS}, } return ConfigurationManager.generate_toml_content_multi_profile(profile_data) - + @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: - """Generate TOML configuration file content with multiple profiles""" - lines = ["version = 1"] - lines.append("") - - # Add global section with global fields - lines.append("[global]") - - # Add current_profile field - lines.append(f"# Currently selected profile") - lines.append(f'current_profile = "{profile_data["current_profile"]}"') - lines.append("") - - # Add dll field if specified - dll_path = profile_data["global_config"].get("dll", "") - if dll_path: - lines.append(f"# specify where Lossless.dll is stored") - lines.append(f'dll = "{dll_path}"') - lines.append("") - - lines.append(f"# FP16 acceleration") - lines.append(f"no_fp16 = false") - lines.append("") - - # Add game sections for each profile - # Sort profiles to ensure consistent order (default profile first) - sorted_profiles = sorted(profile_data["profiles"].items(), - key=lambda x: (x[0] != DEFAULT_PROFILE_NAME, x[0])) - - for profile_name, config in sorted_profiles: - lines.append("[[game]]") - if profile_name == DEFAULT_PROFILE_NAME: - lines.append("# Plugin-managed game entry (default profile)") - else: - lines.append(f"# Profile: {profile_name}") - lines.append(f'exe = "{profile_name}"') - lines.append("") - - # Add all configuration fields to the game section (excluding global fields) - for field_name, field_def in CONFIG_SCHEMA.items(): - # Skip global fields - they go in global section - if field_name in GLOBAL_SECTION_FIELDS: - continue - - value = config.get(field_name, field_def.default) - - # Add field description comment - 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)): # Always include numbers, even if 0 or 1 - lines.append(f"{field_name} = {value}") - - lines.append("") # Empty line for readability - - return "\n".join(lines) - + global_config = profile_data["global_config"] + lines = [ + "version = 2", + f"# decky-current-profile = {_toml_string(profile_data['current_profile'])}", + "", + "[global]", + ] + if global_config.get("dll"): + lines.append(f"dll = {_toml_string(str(global_config['dll']))}") + lines.append(f"allow_fp16 = {str(bool(global_config.get('allow_fp16', True))).lower()}") + + for profile_name, raw_config in profile_data["profiles"].items(): + config = ConfigurationManager.validate_config({**raw_config, **global_config}) + lines.extend(["", "[[profile]]", f"name = {_toml_string(profile_name)}"]) + active_in = [entry.strip() for entry in config["active_in"].split(",") if entry.strip()] + if len(active_in) == 1: + lines.append(f"active_in = {_toml_string(active_in[0])}") + elif active_in: + lines.append("active_in = [" + ", ".join(_toml_string(entry) for entry in active_in) + "]") + if config["gpu"]: + lines.append(f"gpu = {_toml_string(config['gpu'])}") + lines.extend([ + f"multiplier = {config['multiplier']}", + f"flow_scale = {config['flow_scale']}", + f"performance_mode = {str(config['performance_mode']).lower()}", + "pacing = 'none'", + ]) + return "\n".join(lines) + "\n" + @staticmethod - def parse_toml_content(content: str) -> ConfigurationData: - """Parse TOML content into configuration data for the currently selected profile (backward compatibility)""" - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - current_profile = profile_data["current_profile"] - - # Merge global config with current profile config - current_config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()) - - # Add global fields to the config - for field_name in GLOBAL_SECTION_FIELDS: - if field_name in profile_data["global_config"]: - current_config[field_name] = profile_data["global_config"][field_name] - - return current_config - + def _profile_data_from_v1(data: Dict[str, Any]) -> ProfileData: + old_global = data.get("global", {}) + global_config = { + "dll": str(old_global.get("dll", "")), + "allow_fp16": not bool(old_global.get("no_fp16", False)), + } + profiles: Dict[str, ConfigurationData] = {} + for game in data.get("game", []): + name = str(game.get("exe", DEFAULT_PROFILE_NAME)) + migrated_profile = dict(game) + if int(migrated_profile.get("multiplier", 2)) <= 1: + migrated_profile["disable_lsfgvk"] = True + migrated_profile["multiplier"] = max(2, int(migrated_profile.get("multiplier", 2))) + profiles[name] = ConfigurationManager._config_from_profile(migrated_profile, global_config) + if not profiles: + profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() + current_profile = str(old_global.get("current_profile", DEFAULT_PROFILE_NAME)) + if current_profile not in profiles: + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=global_config, + ) + + @staticmethod + def is_legacy_v1(content: str) -> bool: + try: + return tomllib.loads(content).get("version") == 1 + except tomllib.TOMLDecodeError: + return False + @staticmethod def parse_toml_content_multi_profile(content: str) -> ProfileData: - """Parse TOML content into profile data structure""" + data = tomllib.loads(content) + if data.get("version") == 1: + return ConfigurationManager._profile_data_from_v1(data) + if data.get("version") != 2: + raise ValueError("unsupported lsfg-vk configuration version") + + raw_global = dict(data.get("global", {})) + global_config = { + "dll": str(raw_global.get("dll", "")), + "allow_fp16": bool(raw_global.get("allow_fp16", True)), + } profiles: Dict[str, ConfigurationData] = {} - global_config: Dict[str, Any] = {} - current_profile = DEFAULT_PROFILE_NAME - - try: - # Look for both [global] and [[game]] sections - lines = content.split('\n') - in_global_section = False - in_game_section = False - current_game_exe = None - current_game_config: Dict[str, Any] = {} - - 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(']'): - # Save previous game section if we were in one - if in_game_section and current_game_exe: - # Validate and store the profile config - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - current_game_config = {} - - # Set new section state - 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 - if in_global_section: - if key == "current_profile": - current_profile = value - elif key == "dll": - global_config["dll"] = value - elif key == "no_fp16": - # Always enforce FP16 to be enabled (no_fp16 = false) - global_config["no_fp16"] = False - - # Handle game section - elif in_game_section: - # Track the exe for this game section - if key == "exe": - current_game_exe = value - # Store config fields for current game - elif key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - current_game_config[key] = value.lower() in ('true', '1', 'yes', 'on') - elif field_def.field_type == ConfigFieldType.INTEGER: - current_game_config[key] = int(value) - elif field_def.field_type == ConfigFieldType.FLOAT: - current_game_config[key] = float(value) - elif field_def.field_type == ConfigFieldType.STRING: - current_game_config[key] = value - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - - # Handle final game section if we were in one - if in_game_section and current_game_exe: - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - - # Ensure we have at least the default profile - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - # Ensure current_profile exists in profiles - if current_profile not in profiles: - current_profile = DEFAULT_PROFILE_NAME - if DEFAULT_PROFILE_NAME not in profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config - ) - - except (ValueError, KeyError, TypeError, AttributeError) as e: - # If parsing fails completely, return default profile structure - logging.getLogger(__name__).warning(f"Failed to parse TOML profiles, using defaults: {e}") - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: ConfigurationManager.get_defaults()}, - global_config={} - ) - + for profile in data.get("profile", []): + name = str(profile.get("name", DEFAULT_PROFILE_NAME)) + profiles[name] = ConfigurationManager._config_from_profile(profile, global_config) + if not profiles: + profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() + + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + for line in content.splitlines(): + match = CURRENT_PROFILE_COMMENT.match(line) + if match and match.group(1) in profiles: + current_profile = match.group(1) + break + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=global_config, + ) + + @staticmethod + def parse_toml_content(content: str) -> ConfigurationData: + profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) + return profile_data["profiles"][profile_data["current_profile"]] + @staticmethod def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: - """Parse launch script content to extract environment variable values - - Args: - script_content: Content of the launch script file - - Returns: - Dict containing parsed script-only field values - """ - # Use auto-generated parsing logic - parse_script_values = get_script_parsing_logic() - return parse_script_values(script_content.split('\n')) - + return get_script_parsing_logic()(script_content.splitlines()) + @staticmethod - def merge_config_with_script(toml_config: ConfigurationData, script_values: Dict[str, Union[bool, int, str]]) -> ConfigurationData: - """Merge TOML configuration with script environment variable values - - Args: - toml_config: Configuration loaded from TOML file - script_values: Environment variable values parsed from script - - Returns: - Complete configuration with script values overlaid on TOML config - """ - merged_config = dict(toml_config) - - # Update script-only fields with values from script - for field_name in SCRIPT_ONLY_FIELDS.keys(): - if field_name in script_values: - merged_config[field_name] = script_values[field_name] - - return cast(ConfigurationData, merged_config) + def merge_config_with_script( + toml_config: ConfigurationData, + script_values: Dict[str, Union[bool, int, str]], + ) -> ConfigurationData: + merged = dict(toml_config) + for field in SCRIPT_ONLY_FIELDS: + if field in script_values: + merged[field] = script_values[field] + return cast(ConfigurationData, merged) @staticmethod def normalize_profile_name(profile_name: str) -> str: - """Normalize profile name by converting spaces to dashes and trimming - - This allows users to enter names with spaces, which are then safely - converted to dashes for storage and shell script compatibility. - - Args: - profile_name: The raw profile name from user input - - Returns: - Normalized profile name with spaces converted to dashes - """ - if not profile_name: - return profile_name - - # Trim whitespace and convert spaces to dashes - normalized = profile_name.strip().replace(' ', '-') - - # Collapse multiple consecutive dashes into one - while '--' in normalized: - normalized = normalized.replace('--', '-') - - # Remove leading/trailing dashes - normalized = normalized.strip('-') - - return normalized - + return re.sub(r"\s+", "-", profile_name.strip()).strip("-") + @staticmethod def validate_profile_name(profile_name: str) -> bool: - """Validate profile name for safety (after normalization)""" - if not profile_name: - return False - - # Normalize first - this converts spaces to dashes normalized = ConfigurationManager.normalize_profile_name(profile_name) - - if not normalized: - return False - - # Check for invalid characters that could cause issues in shell scripts or TOML - # Note: spaces are now allowed as input (they get converted to dashes) - invalid_chars = set('\t\n\r\'"\\/$|&;()<>{}[]`*?') - if any(char in invalid_chars for char in normalized): - return False - - # Check for reserved names - reserved_names = {'global', 'game', 'current_profile'} - if normalized.lower() in reserved_names: - return False - - return True - + invalid = '\t\n\r\'"\\/$|&;()<>{}[]`*?' + return bool(normalized) and not any(char in invalid for char in normalized) and normalized.lower() not in {"global", "profile"} + @staticmethod def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData: - """Create a new profile by copying from source profile or defaults""" if not ConfigurationManager.validate_profile_name(profile_name): raise ValueError(f"Invalid profile name: {profile_name}") - - # Normalize the profile name (converts spaces to dashes) - profile_name = ConfigurationManager.normalize_profile_name(profile_name) - - if profile_name in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' already exists") - - # Copy from source profile or use defaults - if source_profile and source_profile in profile_data["profiles"]: - new_config = dict(profile_data["profiles"][source_profile]) - else: - new_config = ConfigurationManager.get_defaults() - - # Create new profile data structure - new_profile_data = ProfileData( + normalized = ConfigurationManager.normalize_profile_name(profile_name) + if normalized in profile_data["profiles"]: + raise ValueError(f"Profile '{normalized}' already exists") + source = source_profile if source_profile in profile_data["profiles"] else profile_data["current_profile"] + return ProfileData( current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + profiles={**profile_data["profiles"], normalized: dict(profile_data["profiles"][source])}, + global_config=dict(profile_data["global_config"]), ) - new_profile_data["profiles"][profile_name] = new_config - - return new_profile_data - + @staticmethod def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Delete a profile (cannot delete default profile)""" if profile_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot delete default profile '{DEFAULT_PROFILE_NAME}'") - + raise ValueError("Cannot delete the default profile") if profile_name not in profile_data["profiles"]: raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + profiles = dict(profile_data["profiles"]) + del profiles[profile_name] + current_profile = profile_data["current_profile"] + if current_profile == profile_name: + current_profile = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=dict(profile_data["global_config"]), ) - - # Remove the profile - del new_profile_data["profiles"][profile_name] - - # If we deleted the current profile, switch to default - if new_profile_data["current_profile"] == profile_name: - new_profile_data["current_profile"] = DEFAULT_PROFILE_NAME - # Ensure default profile exists - if DEFAULT_PROFILE_NAME not in new_profile_data["profiles"]: - new_profile_data["profiles"][DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return new_profile_data - + @staticmethod def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData: - """Rename a profile""" if old_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot rename default profile '{DEFAULT_PROFILE_NAME}'") - - if not ConfigurationManager.validate_profile_name(new_name): - raise ValueError(f"Invalid profile name: {new_name}") - - # Normalize the new name (converts spaces to dashes) - new_name = ConfigurationManager.normalize_profile_name(new_name) - - if old_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{old_name}' does not exist") - - if new_name in profile_data["profiles"]: - raise ValueError(f"Profile '{new_name}' already exists") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles={}, - global_config=dict(profile_data["global_config"]) + raise ValueError("Cannot rename the default profile") + if old_name not in profile_data["profiles"] or not ConfigurationManager.validate_profile_name(new_name): + raise ValueError("Invalid profile rename") + normalized = ConfigurationManager.normalize_profile_name(new_name) + if normalized in profile_data["profiles"]: + raise ValueError(f"Profile '{normalized}' already exists") + profiles = { + normalized if name == old_name else name: value + for name, value in profile_data["profiles"].items() + } + current_profile = normalized if profile_data["current_profile"] == old_name else profile_data["current_profile"] + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=dict(profile_data["global_config"]), ) - - # Copy profiles with new name - for profile_name, config in profile_data["profiles"].items(): - if profile_name == old_name: - new_profile_data["profiles"][new_name] = dict(config) - else: - new_profile_data["profiles"][profile_name] = dict(config) - - # Update current_profile if necessary - if new_profile_data["current_profile"] == old_name: - new_profile_data["current_profile"] = new_name - - return new_profile_data - + @staticmethod def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Set the current active profile""" if profile_name not in profile_data["profiles"]: raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( + return ProfileData( current_profile=profile_name, profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) + global_config=dict(profile_data["global_config"]), ) - - return new_profile_data diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py index b320a97..4c37c90 100644 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ b/py_modules/lsfg_vk/config_schema_generated.py @@ -14,12 +14,14 @@ from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType # Field name constants for type-safe access DLL = "dll" -NO_FP16 = "no_fp16" +ALLOW_FP16 = "allow_fp16" MULTIPLIER = "multiplier" FLOW_SCALE = "flow_scale" PERFORMANCE_MODE = "performance_mode" -HDR_MODE = "hdr_mode" -EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" +PACING = "pacing" +ACTIVE_IN = "active_in" +GPU = "gpu" +DISABLE_LSFGVK = "disable_lsfgvk" DXVK_FRAME_RATE = "dxvk_frame_rate" ENABLE_WOW64 = "enable_wow64" DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" @@ -33,12 +35,14 @@ ENABLE_ZINK = "enable_zink" class ConfigurationData(TypedDict): """Type-safe configuration data structure - AUTO-GENERATED""" dll: str - no_fp16: bool + allow_fp16: bool multiplier: int flow_scale: float performance_mode: bool - hdr_mode: bool - experimental_present_mode: str + pacing: str + active_in: str + gpu: str + disable_lsfgvk: bool dxvk_frame_rate: int enable_wow64: bool disable_steamdeck_mode: bool @@ -64,6 +68,8 @@ def get_script_parsing_logic(): value = value.strip() # Auto-generated parsing logic: + if key == "DISABLE_LSFGVK": + script_values["disable_lsfgvk"] = value == "1" if key == "DXVK_FRAME_RATE": try: script_values["dxvk_frame_rate"] = int(value) @@ -81,8 +87,6 @@ def get_script_parsing_logic(): script_values["force_enable_vkbasalt"] = value == "1" if key == "ENABLE_GAMESCOPE_WSI": script_values["enable_wsi"] = value != "0" - if key == "DXVK_HDR": - script_values["enable_wsi"] = value != "0" if key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa": script_values["enable_zink"] = True if key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink": @@ -98,6 +102,8 @@ def get_script_generation_logic(): """Return the script generation logic as a callable""" def generate_script_lines(config): lines = [] + if config.get("disable_lsfgvk", False): + lines.append("export DISABLE_LSFGVK=1") dxvk_frame_rate = config.get("dxvk_frame_rate", 0) if dxvk_frame_rate > 0: lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}") @@ -113,7 +119,6 @@ def get_script_generation_logic(): lines.append("export ENABLE_VKBASALT=1") if not config.get("enable_wsi", False): lines.append("export ENABLE_GAMESCOPE_WSI=0") - lines.append("export DXVK_HDR=0") if config.get("enable_zink", False): lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa") lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink") @@ -122,4 +127,4 @@ def get_script_generation_logic(): return generate_script_lines -ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] +ALL_FIELDS = ['dll', 'allow_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'pacing', 'active_in', 'gpu', 'disable_lsfgvk', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index fce3738..446ac3a 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -3,7 +3,8 @@ Configuration service for TOML-based lsfg configuration management. """ from pathlib import Path -from typing import Dict, Any +import shlex +from typing import Dict, Any, cast from .base_service import BaseService from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME @@ -14,6 +15,14 @@ from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse class ConfigurationService(BaseService): """Service for managing TOML-based lsfg configuration""" + + @staticmethod + def _profile_selection_lines(profile_name: str, config: ConfigurationData) -> list[str]: + """Use native matching when a selected profile declares active_in.""" + active_in = str(config.get("active_in", "")).strip() + if active_in: + return ["# active_in is configured; lsfg-vk will select a matching profile automatically."] + return [f"export LSFGVK_PROFILE={shlex.quote(profile_name)}"] def get_config(self) -> ConfigurationResponse: """Read current TOML configuration merged with launch script environment variables @@ -124,7 +133,10 @@ class ConfigurationService(BaseService): generate_script_lines = get_script_generation_logic() lines.extend(generate_script_lines(config)) - lines.append("export LSFG_PROCESS=decky-lsfg-vk") + lines.extend([ + f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}", + *self._profile_selection_lines(DEFAULT_PROFILE_NAME, config), + ]) lines.extend(self._generate_game_launch_lines()) return "\n".join(lines) + "\n" @@ -153,7 +165,10 @@ class ConfigurationService(BaseService): generate_script_lines = get_script_generation_logic() lines.extend(generate_script_lines(merged_config)) - lines.append(f"export LSFG_PROCESS={current_profile}") + lines.extend([ + f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}", + *self._profile_selection_lines(current_profile, cast(ConfigurationData, merged_config)), + ]) lines.extend(self._generate_game_launch_lines()) return "\n".join(lines) + "\n" @@ -187,12 +202,21 @@ class ConfigurationService(BaseService): profiles={DEFAULT_PROFILE_NAME: default_config}, global_config={ "dll": default_config.get("dll", ""), - "no_fp16": False + "allow_fp16": default_config.get("allow_fp16", True) } ) content = self.config_file_path.read_text(encoding='utf-8') - return ConfigurationManager.parse_toml_content_multi_profile(content) + profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) + if self.lsfg_script_path.exists(): + script_values = ConfigurationManager.parse_script_content( + self.lsfg_script_path.read_text(encoding='utf-8') + ) + current_profile = profile_data["current_profile"] + profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( + profile_data["profiles"][current_profile], script_values + ) + return profile_data def _save_profile_data(self, profile_data: ProfileData) -> None: """Save profile data to config file""" @@ -393,7 +417,7 @@ class ConfigurationService(BaseService): profile_data["profiles"][profile_name] = config # Update global config fields if they're in the config - for field_name in ["dll", "no_fp16"]: + for field_name in ["dll", "allow_fp16"]: if field_name in config: profile_data["global_config"][field_name] = config[field_name] diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 023894f..ad3fb32 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -1,28 +1,26 @@ -""" -Constants for the lsfg-vk plugin. -""" +"""Constants for the lsfg-vk Decky plugin.""" 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" SCRIPT_NAME = "lsfg" CONFIG_FILENAME = "conf.toml" -LIB_FILENAME = "liblsfg-vk.so" -JSON_FILENAME = "VkLayer_LS_frame_generation.json" -ZIP_FILENAME = "lsfg-vk_noui.zip" -ARM_LIB_FILENAME = "liblsfg-vk-arm64.so" +LIB_FILENAME = "liblsfg-vk-layer.so" +JSON_FILENAME = "VkLayer_LSFGVK_frame_generation.json" +LAYER_ARCHIVE_X86_64 = "lsfg-vk-layer-x86_64.tar.xz" +LAYER_ARCHIVE_AARCH64 = "lsfg-vk-layer-aarch64.tar.xz" +CLI_FILENAME = "lsfg-vk-cli" + +LEGACY_LIB_FILENAME = "liblsfg-vk.so" +LEGACY_JSON_FILENAME = "VkLayer_LS_frame_generation.json" FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak" FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak" FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak" -SO_EXT = ".so" -JSON_EXT = ".json" - BIN_DIR = "bin" ARMADA_DEVICE_ENV = Path("/usr/libexec/armada/device-env") @@ -35,3 +33,7 @@ ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH" ENV_XDG_DATA_HOME = "XDG_DATA_HOME" ENV_HOME = "HOME" + +def get_layer_archive_filename(is_arm: bool) -> str: + """Return the v2 layer archive appropriate for the native game host.""" + return LAYER_ARCHIVE_AARCH64 if is_arm else LAYER_ARCHIVE_X86_64 diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index c9be0ec..7f7ef07 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -8,9 +8,6 @@ from pathlib import Path from typing import Dict, Any, List, Optional from .base_service import BaseService -from .constants import ( - FLATPAK_23_08_FILENAME, FLATPAK_24_08_FILENAME, FLATPAK_25_08_FILENAME, BIN_DIR, CONFIG_DIR -) from .types import BaseResponse @@ -52,6 +49,23 @@ class FlatpakService(BaseService): self.extension_id_25_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/25.08" self.flatpak_command = None + def _get_lsfg_paths(self) -> tuple[str, str]: + """Return the v2 config directory and directory containing Lossless.dll.""" + config_path = str(self.config_dir) + dll_directory = str(self.user_home / ".local/share/Steam/steamapps/common") + if not self.config_file_path.exists(): + return config_path, dll_directory + try: + profile_data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + configured_dll = profile_data["global_config"].get("dll", "") + if configured_dll: + dll_directory = str(Path(str(configured_dll)).parent) + except (OSError, ValueError, KeyError, TypeError) as error: + self.log.debug("Could not read configured DLL path for Flatpak override: %s", error) + return config_path, dll_directory + def _get_clean_env(self): """Get a clean environment without PyInstaller's bundled libraries""" env = os.environ.copy() @@ -177,20 +191,14 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): return self._error_response(BaseResponse, "Flatpak is not available on this system") - plugin_dir = Path(__file__).parent.parent.parent if version == "23.08": - filename = FLATPAK_23_08_FILENAME - elif version == "24.08": - filename = FLATPAK_24_08_FILENAME - else: - filename = FLATPAK_25_08_FILENAME - flatpak_path = plugin_dir / BIN_DIR / filename - - if not flatpak_path.exists(): - return self._error_response(BaseResponse, f"Flatpak file not found: {flatpak_path}") + return self._error_response( + BaseResponse, + "Flathub supplies the lsfg-vk v2 extension for runtimes 24.08 and 25.08; install 23.08 manually.", + ) result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", str(flatpak_path)], + ["install", "--user", "--noninteractive", "flathub", f"org.freedesktop.Platform.VulkanLayer.lsfgvk//{version}"], capture_output=True, text=True ) @@ -298,10 +306,7 @@ class FlatpakService(BaseService): return {"filesystem": False, "env": False} output = result.stdout - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" + config_path, dll_directory = self._get_lsfg_paths() filesystem_section = "" in_context = False @@ -317,10 +322,9 @@ class FlatpakService(BaseService): break has_config_fs = config_path in filesystem_section - has_dll_fs = dll_path in filesystem_section - has_lsfg_fs = lsfg_path in filesystem_section + has_dll_fs = dll_directory in filesystem_section - filesystem_override = has_config_fs and has_dll_fs and has_lsfg_fs + filesystem_override = has_config_fs and has_dll_fs env_override = False in_environment = False @@ -331,11 +335,11 @@ class FlatpakService(BaseService): in_environment = True elif line.startswith("[") and line != "[Environment]": in_environment = False - elif in_environment and line.startswith(f"LSFG_CONFIG={config_path}/conf.toml"): + elif in_environment and line.startswith(f"LSFGVK_CONFIG={config_path}/conf.toml"): env_override = True break - self.log.debug(f"Override status for {app_id}: filesystem={filesystem_override} ({has_config_fs}/{has_dll_fs}/{has_lsfg_fs}), env={env_override}") + self.log.debug(f"Override status for {app_id}: filesystem={filesystem_override} ({has_config_fs}/{has_dll_fs}), env={env_override}") return {"filesystem": filesystem_override, "env": env_override} @@ -351,15 +355,11 @@ class FlatpakService(BaseService): "Flatpak is not available on this system", app_id=app_id, operation="set") - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" + config_path, dll_directory = self._get_lsfg_paths() filesystem_overrides = [ - f"--filesystem={dll_path}", - f"--filesystem={config_path}:rw", - f"--filesystem={lsfg_path}:rw" + f"--filesystem={dll_directory}:ro", + f"--filesystem={config_path}:rw", ] for override in filesystem_overrides: @@ -373,7 +373,7 @@ class FlatpakService(BaseService): app_id=app_id, operation="set") result = self._run_flatpak_command( - ["override", "--user", f"--env=LSFG_CONFIG={config_path}/conf.toml", app_id], + ["override", "--user", f"--env=LSFGVK_CONFIG={config_path}/conf.toml", app_id], capture_output=True, text=True ) @@ -401,28 +401,11 @@ class FlatpakService(BaseService): "Flatpak is not available on this system", app_id=app_id, operation="remove") - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" + config_path, dll_directory = self._get_lsfg_paths() - reset_result = self._run_flatpak_command( - ["override", "--user", "--reset", app_id], - capture_output=True, text=True - ) - - if reset_result.returncode == 0: - self.log.info(f"Successfully reset all overrides for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"All overrides reset for {app_id}", - app_id=app_id, operation="remove") - - self.log.debug(f"Reset failed, trying individual removal: {reset_result.stderr}") - filesystem_overrides = [ - f"--nofilesystem={dll_path}", + f"--nofilesystem={dll_directory}", f"--nofilesystem={config_path}", - f"--nofilesystem={lsfg_path}" ] removal_errors = [] @@ -437,7 +420,7 @@ class FlatpakService(BaseService): removal_errors.append(f"{override}: {result.stderr}") result = self._run_flatpak_command( - ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], + ["override", "--user", "--unset-env=LSFGVK_CONFIG", app_id], capture_output=True, text=True ) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index 49ed711..cb6b161 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -1,443 +1,298 @@ -""" -Installation service for lsfg-vk. -""" +"""Installation and in-place migration service for lsfg-vk v2.""" -import os +import json import platform import shutil -import traceback -import zipfile +import tarfile import tempfile -import json +import traceback from pathlib import Path -from typing import Dict, Any +from typing import Any, Dict from .base_service import BaseService +from .config_schema import ConfigurationManager, ProfileData from .constants import ( - LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR, - SO_EXT, JSON_EXT, ARM_LIB_FILENAME, ARMADA_DEVICE_ENV + ARMADA_DEVICE_ENV, + BIN_DIR, + JSON_FILENAME, + LEGACY_JSON_FILENAME, + LEGACY_LIB_FILENAME, + LIB_FILENAME, + get_layer_archive_filename, ) -from .config_schema import ConfigurationManager -from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse +from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - """Service for handling lsfg-vk installation and uninstallation""" - + """Install architecture-matched v2 assets and migrate the public config.""" + def __init__(self, logger=None): super().__init__(logger) - self.lib_file = self.local_lib_dir / LIB_FILENAME self.json_file = self.local_share_dir / JSON_FILENAME - + self.legacy_lib_file = self.local_lib_dir / LEGACY_LIB_FILENAME + self.legacy_json_file = self.local_share_dir / LEGACY_JSON_FILENAME + def install(self) -> InstallationResponse: - """Install lsfg-vk by extracting the zip file to ~/.local - - Returns: - InstallationResponse with success status and message/error - """ try: plugin_dir = Path(__file__).parent.parent.parent - zip_path = plugin_dir / BIN_DIR / ZIP_FILENAME - - if not zip_path.exists(): - error_msg = f"{ZIP_FILENAME} not found at {zip_path}" + archive_name = get_layer_archive_filename(self._is_arm_architecture()) + archive_path = plugin_dir / BIN_DIR / archive_name + if not archive_path.exists(): + error_msg = f"{archive_name} not found at {archive_path}" self.log.error(error_msg) return self._error_response(InstallationResponse, error_msg, message="") - + self._ensure_directories() - - self._extract_and_install_files(zip_path) - - # If on ARM, overwrite the .so with the ARM version - if self._is_arm_architecture(): - self.log.info("Detected ARM architecture, using ARM binary") - arm_so_path = plugin_dir / BIN_DIR / ARM_LIB_FILENAME - self._copy_plugin_file(arm_so_path, self.lib_file) - self.log.info(f"Overwrote with ARM binary: {self.lib_file}") - - self._create_config_file() - - self._create_lsfg_launch_script() - - self.log.info("lsfg-vk installed successfully") - return self._success_response(InstallationResponse, "lsfg-vk installed successfully") - - except (OSError, zipfile.BadZipFile, shutil.Error) as e: - error_msg = f"Error installing lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, str(e), message="") - except Exception as e: - error_msg = f"Unexpected error installing lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, str(e), message="") - + self._extract_and_install_files(archive_path) + profile_data = self._create_config_file() + self._create_lsfg_launch_script(profile_data) + self._remove_legacy_layer_files() + + self.log.info("lsfg-vk v2 installed successfully from %s", archive_name) + return self._success_response(InstallationResponse, "lsfg-vk v2 installed successfully") + except (OSError, tarfile.TarError, shutil.Error) as error: + self.log.error("Error installing lsfg-vk: %s", error) + return self._error_response(InstallationResponse, str(error), message="") + except Exception as error: + self.log.error("Unexpected error installing lsfg-vk: %s", error) + return self._error_response(InstallationResponse, str(error), message="") + def _is_arm_architecture(self) -> bool: - """Check if running on ARM architecture - - Returns: - True if running on ARM (aarch64), False otherwise - """ - if platform.machine().lower() in ('aarch64', 'arm64'): + """Detect the native game host, including Decky running under FEX on Armada.""" + if platform.machine().lower() in ("aarch64", "arm64"): return True - - # Decky runs through FEX on Armada, so Python reports x86_64 even - # though the host is AArch64. Armada exposes this native helper only - # on its ARM image, including inside Decky's FEX rootfs. if ARMADA_DEVICE_ENV.is_file(): self.log.info("Detected native AArch64 Armada host through device-env") return True - - # Fall back to the native PID 1 ELF header. e_machine 183 is AArch64. try: - with Path('/proc/1/exe').open('rb') as host_executable: + with Path("/proc/1/exe").open("rb") as host_executable: elf_header = host_executable.read(20) - if elf_header[:4] == b'\x7fELF' and elf_header[5] in (1, 2): - byte_order = 'little' if elf_header[5] == 1 else 'big' + if elf_header[:4] == b"\x7fELF" and elf_header[5] in (1, 2): + byte_order = "little" if elf_header[5] == 1 else "big" if int.from_bytes(elf_header[18:20], byte_order) == 183: self.log.info("Detected native AArch64 host through PID 1") return True - except OSError as e: - self.log.debug(f"Could not inspect native host architecture: {e}") - + except OSError as error: + self.log.debug("Could not inspect native host architecture: %s", error) return False @staticmethod - def _copy_plugin_file(src_file: Path, dst_file: Path) -> None: - """Copy plugin content without preserving FEX-incompatible metadata.""" - shutil.copyfile(src_file, dst_file) - dst_file.chmod(0o644) - - def _extract_and_install_files(self, zip_path: Path) -> None: - """Extract zip file and install files to appropriate locations - - Args: - zip_path: Path to the zip file to extract - - Raises: - zipfile.BadZipFile: If zip file is corrupted - OSError: If file operations fail - """ - # Destination mapping for file types - dest_map = { - SO_EXT: self.local_lib_dir, - JSON_EXT: self.local_share_dir + def _copy_plugin_file(source: Path, destination: Path, mode: int = 0o644) -> None: + """Copy payload content without preserving FEX-incompatible metadata.""" + shutil.copyfile(source, destination) + destination.chmod(mode) + + def _extract_and_install_files(self, archive_path: Path) -> None: + """Extract just the v2 library and manifest from an owner release archive.""" + destinations = { + LIB_FILENAME: self.lib_file, + JSON_FILENAME: self.json_file, } - - with zipfile.ZipFile(zip_path, 'r') as zip_ref: + found = set() + with tarfile.open(archive_path, "r:xz") as archive: with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - zip_ref.extractall(temp_path) - - # Process extracted files - for root, dirs, files in os.walk(temp_path): - root_path = Path(root) - for file in files: - src_file = root_path / file - file_path = Path(file) - - # Check if we know where this file type should go - dst_dir = dest_map.get(file_path.suffix) - if dst_dir: - dst_file = dst_dir / file - - # Special handling for JSON files - need to modify library_path - if file_path.suffix == JSON_EXT and file == JSON_FILENAME: - self._copy_and_fix_json_file(src_file, dst_file) - else: - self._copy_plugin_file(src_file, dst_file) - - self.log.info(f"Copied {file} to {dst_file}") - - def _copy_and_fix_json_file(self, src_file: Path, dst_file: Path) -> None: - """Copy JSON file and fix the library_path to use relative path - - Args: - src_file: Source JSON file path - dst_file: Destination JSON file path - """ + temp_root = Path(temp_dir) + for member in archive.getmembers(): + if not member.isfile(): + continue + filename = Path(member.name).name + destination = destinations.get(filename) + if destination is None: + continue + source = archive.extractfile(member) + if source is None: + continue + temporary_file = temp_root / filename + with source, temporary_file.open("wb") as output: + shutil.copyfileobj(source, output) + destination.parent.mkdir(parents=True, exist_ok=True) + if filename == JSON_FILENAME: + self._copy_and_fix_json_file(temporary_file, destination) + else: + self._copy_plugin_file(temporary_file, destination) + found.add(filename) + self.log.info("Installed %s to %s", filename, destination) + + missing = [name for name in destinations if name not in found] + if missing: + raise OSError("Archive did not contain required lsfg-vk files: " + ", ".join(missing)) + + def _copy_and_fix_json_file(self, source: Path, destination: Path) -> None: + """Point the manifest at ~/.local/lib after Decky installs it.""" try: - # Read the JSON file - with open(src_file, 'r') as f: - json_data = json.load(f) - - # Fix the library_path from "liblsfg-vk.so" to "../../../lib/liblsfg-vk.so" - if 'layer' in json_data and 'library_path' in json_data['layer']: - current_path = json_data['layer']['library_path'] - if current_path == "liblsfg-vk.so": - json_data['layer']['library_path'] = "../../../lib/liblsfg-vk.so" - self.log.info(f"Fixed library_path from '{current_path}' to '../../../lib/liblsfg-vk.so'") - - # Write the modified JSON file - with open(dst_file, 'w') as f: - json.dump(json_data, f, indent=2) - - except (json.JSONDecodeError, KeyError, OSError) as e: - self.log.error(f"Error fixing JSON file {src_file}: {e}") - # Fallback to simple copy if JSON modification fails - self._copy_plugin_file(src_file, dst_file) - - def _create_config_file(self) -> None: - """Create or update the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path - - If a config file already exists, preserve existing profiles and only update global settings like DLL path. - """ - # Import here to avoid circular imports + data = json.loads(source.read_text(encoding="utf-8")) + data["layer"]["library_path"] = "../../../lib/liblsfg-vk-layer.so" + destination.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + destination.chmod(0o644) + except (json.JSONDecodeError, KeyError, OSError) as error: + self.log.error("Error fixing JSON file %s: %s", source, error) + self._copy_plugin_file(source, destination) + + def _create_config_file(self) -> ProfileData: + """Migrate v1 once, normalize v2, and retain Decky launch workarounds.""" from .dll_detection import DllDetectionService - - # Try to detect DLL path + dll_service = DllDetectionService(self.log) - - # Check if config file already exists + profile_data: ProfileData + was_legacy = False if self.config_file_path.exists(): + content = self.config_file_path.read_text(encoding="utf-8") + was_legacy = ConfigurationManager.is_legacy_v1(content) try: - # Read existing config to preserve user profiles - content = self.config_file_path.read_text(encoding='utf-8') - existing_profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - self.log.info(f"Found existing config file, preserving user profiles") - - # Create merged profile data that preserves user settings but adds any new fields - merged_profile_data = self._merge_config_with_defaults(existing_profile_data, dll_service) - - # Generate TOML content with merged profiles - toml_content = ConfigurationManager.generate_toml_content_multi_profile(merged_profile_data) - - except Exception as e: - self.log.warning(f"Failed to parse existing config file: {str(e)}, creating new one") - # Fall back to creating a new config file - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) + profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) + except (ValueError, KeyError, TypeError) as error: + self.log.warning("Could not parse existing config, using defaults: %s", error) + default = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + profile_data = ProfileData( + current_profile="decky-lsfg-vk", + profiles={"decky-lsfg-vk": default}, + global_config={"dll": default["dll"], "allow_fp16": default["allow_fp16"]}, + ) + if was_legacy: + self._backup_legacy_config(content) else: - # No existing config file, create a new one with defaults - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) - self.log.info(f"Creating new config file") - - # Write config file - 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 - USE GENERATED CONSTANTS - from .config_schema_generated import DLL + default = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + profile_data = ProfileData( + current_profile="decky-lsfg-vk", + profiles={"decky-lsfg-vk": default}, + global_config={"dll": default["dll"], "allow_fp16": default["allow_fp16"]}, + ) + + profile_data = self._merge_config_with_defaults(profile_data, dll_service) + script_values = self._read_script_values() + current_profile = profile_data["current_profile"] + profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script( + profile_data["profiles"][current_profile], script_values + ) + self._write_file( + self.config_file_path, + ConfigurationManager.generate_toml_content_multi_profile(profile_data), + 0o644, + ) + return profile_data + + def _backup_legacy_config(self, content: str) -> None: + backup_path = self.config_file_path.with_name(f"{self.config_file_path.name}.v1.bak") + if backup_path.exists(): + return + self._write_file(backup_path, content, 0o644) + self.log.info("Backed up v1 configuration to %s", backup_path) + + def _read_script_values(self) -> Dict[str, Any]: + if not self.lsfg_launch_script_path.exists(): + return {} try: - # Try to parse the written content to get the DLL path - final_content = self.config_file_path.read_text(encoding='utf-8') - final_config = ConfigurationManager.parse_toml_content(final_content) - if final_config.get(DLL): - self.log.info(f"Configured DLL path: {final_config[DLL]}") - except (OSError, IOError, ValueError, KeyError) as e: - # Don't fail installation if we can't log the DLL path - self.log.debug(f"Could not log DLL path: {e}") - - def _create_lsfg_launch_script(self) -> None: - """Create the ~/lsfg launch script for easier game setup""" - # Use the default configuration for the initial script - from .config_schema import ConfigurationManager - default_config = ConfigurationManager.get_defaults() - - # Create configuration service to generate the script + return ConfigurationManager.parse_script_content( + self.lsfg_launch_script_path.read_text(encoding="utf-8") + ) + except OSError as error: + self.log.warning("Could not preserve launch-script settings: %s", error) + return {} + + def _create_lsfg_launch_script(self, profile_data: ProfileData = None) -> None: + """Write the v2 launcher after config migration has completed.""" from .configuration import ConfigurationService - config_service = ConfigurationService(logger=self.log) - config_service.user_home = self.user_home - config_service.lsfg_script_path = self.lsfg_launch_script_path - - # Generate script content with default configuration - script_content = config_service._generate_script_content(default_config) - - # 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}") - + + if profile_data is None: + profile_data = ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + configuration_service = ConfigurationService(logger=self.log) + configuration_service.user_home = self.user_home + configuration_service.config_dir = self.config_dir + configuration_service.config_file_path = self.config_file_path + configuration_service.lsfg_script_path = self.lsfg_launch_script_path + script = configuration_service._generate_script_content_for_profile(profile_data) + self._write_file(self.lsfg_launch_script_path, script, 0o755) + + def _merge_config_with_defaults(self, existing: ProfileData, dll_service) -> ProfileData: + defaults = ConfigurationManager.get_defaults_with_dll_detection(dll_service) + global_config = dict(existing.get("global_config", {})) + global_config.setdefault("dll", defaults["dll"]) + global_config.setdefault("allow_fp16", defaults["allow_fp16"]) + + profiles: Dict[str, Any] = {} + for name, existing_config in existing.get("profiles", {}).items(): + profiles[name] = ConfigurationManager.validate_config({ + **defaults, + **existing_config, + **global_config, + }) + if not profiles: + profiles["decky-lsfg-vk"] = ConfigurationManager.validate_config(defaults) + + current_profile = existing.get("current_profile", "decky-lsfg-vk") + if current_profile not in profiles: + current_profile = "decky-lsfg-vk" if "decky-lsfg-vk" in profiles else next(iter(profiles)) + return ProfileData( + current_profile=current_profile, + profiles=profiles, + global_config=global_config, + ) + + def _remove_legacy_layer_files(self) -> None: + for path in (self.legacy_lib_file, self.legacy_json_file): + if self._remove_if_exists(path): + self.log.info("Removed superseded v1 layer file %s", path) + def get_launch_script_path(self) -> str: - """Get the path to the lsfg launch script - - Returns: - String path to the launch script file - """ return str(self.lsfg_launch_script_path) def check_installation(self) -> InstallationCheckResponse: - """Check if lsfg-vk is already installed - - Returns: - InstallationCheckResponse with installation status and file paths - """ try: lib_exists = self.lib_file.exists() json_exists = self.json_file.exists() - config_exists = self.config_file_path.exists() - - self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, config={config_exists}") - + legacy_installed = self.legacy_lib_file.exists() or self.legacy_json_file.exists() return { "installed": lib_exists and json_exists, + "legacy_installed": legacy_installed, "lib_exists": lib_exists, "json_exists": json_exists, - "script_exists": config_exists, # Keep script_exists for backward compatibility + "script_exists": self.config_file_path.exists(), "lib_path": str(self.lib_file), "json_path": str(self.json_file), - "script_path": str(self.config_file_path), # Keep script_path for backward compatibility - "error": None + "script_path": str(self.config_file_path), + "error": None, } - - except Exception as e: - error_msg = f"Error checking lsfg-vk installation: {str(e)}" - self.log.error(error_msg) + except OSError as error: return { "installed": False, + "legacy_installed": False, "lib_exists": False, "json_exists": False, "script_exists": False, "lib_path": str(self.lib_file), "json_path": str(self.json_file), "script_path": str(self.config_file_path), - "error": str(e) + "error": str(error), } - + def uninstall(self) -> UninstallationResponse: - """Uninstall lsfg-vk by removing the installed files - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - - Returns: - UninstallationResponse with success status and removed files list - """ try: - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, 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)) - - # Don't remove config directory since we're preserving the config file - - if not removed_files: - return self._success_response(UninstallationResponse, - "No lsfg-vk files found to remove", - removed_files=None) - - self.log.info("lsfg-vk uninstalled successfully") - return self._success_response(UninstallationResponse, - f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.", - removed_files=removed_files) - - except OSError as e: - error_msg = f"Error uninstalling lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(UninstallationResponse, str(e), - message="", removed_files=None) - + removed = [] + for path in ( + self.lib_file, + self.json_file, + self.legacy_lib_file, + self.legacy_json_file, + self.lsfg_launch_script_path, + ): + if self._remove_if_exists(path): + removed.append(str(path)) + if not removed: + return self._success_response(UninstallationResponse, "No lsfg-vk files found to remove", removed_files=None) + return self._success_response( + UninstallationResponse, + f"lsfg-vk uninstalled successfully. Removed {len(removed)} files.", + removed_files=removed, + ) + except OSError as error: + return self._error_response(UninstallationResponse, str(error), message="", removed_files=None) + def cleanup_on_uninstall(self) -> None: - """Clean up lsfg-vk files when the plugin is uninstalled - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - """ try: - 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" Config file: {self.config_file_path} (preserved)") - self.log.info(f" Launch script: {self.lsfg_launch_script_path}") - self.log.info(f" Old script file: {self.lsfg_script_path}") - - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path, self.lsfg_script_path] - - for file_path in files_to_remove: - try: - if self._remove_if_exists(file_path): - removed_files.append(str(file_path)) - except OSError as e: - self.log.error(f"Failed to remove {file_path}: {e}") - - # Don't remove config directory since we're preserving the config file - - if removed_files: - self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}") - else: - self.log.info("No lsfg-vk files found to clean up during plugin uninstall") - - except Exception as e: - self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {str(e)}") - self.log.error(f"Traceback: {traceback.format_exc()}") - - def _merge_config_with_defaults(self, existing_profile_data, dll_service): - """Merge existing user config with current schema defaults - - This ensures that: - 1. User's custom profiles and values are preserved - 2. Any new fields added to the schema get their default values - 3. Global settings like DLL path are updated as needed - - Args: - existing_profile_data: The user's existing ProfileData - dll_service: DLL detection service for updating DLL path - - Returns: - ProfileData with merged configuration - """ - from .config_schema import ProfileData - - # Get current schema defaults - default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - default_global_config = { - "dll": default_config.get("dll", ""), - "no_fp16": False - } - - # Start with existing data - merged_data: ProfileData = { - "current_profile": existing_profile_data.get("current_profile", "decky-lsfg-vk"), - "global_config": existing_profile_data.get("global_config", {}).copy(), - "profiles": {} - } - - # Merge global config: preserve user values, add missing fields, update DLL - for key, default_value in default_global_config.items(): - if key not in merged_data["global_config"]: - merged_data["global_config"][key] = default_value - self.log.info(f"Added missing global field '{key}' with default value: {default_value}") - - # Update DLL path if detected - dll_result = dll_service.check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - old_dll = merged_data["global_config"].get("dll") - merged_data["global_config"]["dll"] = dll_result["path"] - if old_dll != dll_result["path"]: - self.log.info(f"Updated DLL path from '{old_dll}' to: {dll_result['path']}") - - # Merge each profile: preserve user values, add missing fields - existing_profiles = existing_profile_data.get("profiles", {}) - - for profile_name, existing_profile_config in existing_profiles.items(): - merged_profile_config = existing_profile_config.copy() - - # Add any missing fields from current schema with default values - added_fields = [] - for key, default_value in default_config.items(): - if key not in merged_profile_config and key not in ["dll", "no_fp16"]: # Skip global fields - merged_profile_config[key] = default_value - added_fields.append(key) - - if added_fields: - self.log.info(f"Profile '{profile_name}': Added missing fields {added_fields}") - - merged_data["profiles"][profile_name] = merged_profile_config - - # If no profiles exist, create the default one - if not merged_data["profiles"]: - merged_data["profiles"]["decky-lsfg-vk"] = { - k: v for k, v in default_config.items() - if k not in ["dll", "no_fp16"] # Exclude global fields - } - merged_data["current_profile"] = "decky-lsfg-vk" - self.log.info("No existing profiles found, created default profile") - - return merged_data + self.uninstall() + except Exception as error: + self.log.error("Error cleaning up lsfg-vk files during uninstall: %s", error) + self.log.error("Traceback: %s", traceback.format_exc()) diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index cb59b4f..aefec3e 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -433,43 +433,16 @@ class Plugin: Called when the plugin is uninstalled. This method is called by Decky Loader when the plugin is being uninstalled. - Performs cleanup of plugin files and flatpak extensions. + Removes plugin-owned layer files while leaving shared Flatpak extensions installed. """ decky.logger.info("decky-lsfg-vk plugin being uninstalled") # Clean up lsfg-vk files when the plugin is uninstalled self.installation_service.cleanup_on_uninstall() - # Also clean up flatpak extensions if they are installed - try: - decky.logger.info("Checking for flatpak extensions to uninstall") - - extension_status = self.flatpak_service.get_extension_status() - - if extension_status.get("success"): - if extension_status.get("installed_23_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 23.08") - result = self.flatpak_service.uninstall_extension("23.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 23.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 23.08: {result.get('error')}") - - if extension_status.get("installed_24_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 24.08") - result = self.flatpak_service.uninstall_extension("24.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 24.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 24.08: {result.get('error')}") - - decky.logger.info("Flatpak extension cleanup completed") - else: - decky.logger.info(f"Could not check flatpak status for cleanup: {extension_status.get('error')}") - - except Exception as e: - decky.logger.error(f"Error during flatpak cleanup: {e}") - + # Runtime extensions are shared by all Flatpak lsfg-vk installations. + # Do not remove them automatically: another application or plugin may use them. + decky.logger.info("Leaving shared Flatpak runtime extensions installed") decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 7b7ca2b..6f3cd05 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -37,6 +37,7 @@ class UninstallationResponse(BaseResponse): class InstallationCheckResponse(TypedDict): """Response for installation check""" installed: bool + legacy_installed: bool lib_exists: bool json_exists: bool script_exists: bool @@ -64,7 +65,7 @@ class ConfigurationResponse(BaseResponse): class ProfileConfig(TypedDict): """Configuration for a single profile""" - exe: str + name: str config: ConfigurationData diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py index a03aa2b..1445b79 100644 --- a/scripts/generate_python_boilerplate.py +++ b/scripts/generate_python_boilerplate.py @@ -34,6 +34,7 @@ def get_env_var_name(field_name: str) -> str: "enable_wow64": "PROTON_USE_WOW64", "disable_steamdeck_mode": "SteamDeck", "mangohud_workaround": "MANGOHUD", + "disable_lsfgvk": "DISABLE_LSFGVK", "disable_vkbasalt": "DISABLE_VKBASALT", "force_enable_vkbasalt": "ENABLE_VKBASALT", "enable_wsi": "ENABLE_GAMESCOPE_WSI", @@ -76,11 +77,9 @@ def generate_script_parsing() -> str: lines.append(f' elif key == "{env_var}":') lines.append(f' script_values["{field_name}"] = value == "0"') elif field_name == "enable_wsi": - # Special case: ENABLE_GAMESCOPE_WSI=0 or DXVK_HDR=0 means enable_wsi=False + # ENABLE_GAMESCOPE_WSI=0 means enable_wsi=False. lines.append(f' elif key == "{env_var}":') lines.append(f' script_values["{field_name}"] = value != "0"') - lines.append(f' elif key == "DXVK_HDR":') - lines.append(f' script_values["{field_name}"] = value != "0"') elif field_name == "enable_zink": # Special case: Zink uses multiple environment variables lines.append(f' elif key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa":') @@ -131,10 +130,9 @@ def generate_script_generation() -> str: lines.append(f' if config.get("{field_name}", False):') lines.append(f' lines.append("export {env_var}=0")') elif field_name == "enable_wsi": - # Special case: enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0 and DXVK_HDR=0 + # enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0. lines.append(f' if not config.get("{field_name}", False):') lines.append(f' lines.append("export {env_var}=0")') - lines.append(f' lines.append("export DXVK_HDR=0")') elif field_name == "enable_zink": # Special case: enable_zink=True should export multiple Zink environment variables lines.append(f' if config.get("{field_name}", False):') diff --git a/shared_config.py b/shared_config.py index 997717b..c87d01d 100644 --- a/shared_config.py +++ b/shared_config.py @@ -1,17 +1,10 @@ -""" -Shared configuration schema constants. +"""Canonical Decky configuration schema for lsfg-vk v2.""" -This file contains the canonical configuration schema that should be used -by both Python and TypeScript code. Any changes to the configuration -structure should be made here first. -""" - -from typing import Dict, Any, Union +from typing import Dict, Union from enum import Enum class ConfigFieldType(str, Enum): - """Configuration field types - must match TypeScript enum""" BOOLEAN = "boolean" INTEGER = "integer" FLOAT = "float" @@ -22,141 +15,132 @@ CONFIG_SCHEMA_DEF = { "dll": { "name": "dll", "fieldType": ConfigFieldType.STRING, - "default": "/games/Lossless Scaling/Lossless.dll", - "description": "specify where Lossless.dll is stored", - "location": "toml" + "default": "", + "description": "optional full path to Lossless.dll; leave blank for automatic discovery", + "location": "global", }, - - "no_fp16": { - "name": "no_fp16", + "allow_fp16": { + "name": "allow_fp16", "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "force-disable fp16 (use on older nvidia cards)", - "location": "toml" + "default": True, + "description": "allow FP16 acceleration (disable on older NVIDIA GPUs)", + "location": "global", }, - "multiplier": { "name": "multiplier", "fieldType": ConfigFieldType.INTEGER, - "default": 1, - "description": "change the fps multiplier", - "location": "toml" + "default": 2, + "description": "frame generation multiplier", + "location": "profile", }, - "flow_scale": { "name": "flow_scale", "fieldType": ConfigFieldType.FLOAT, - "default": 0.8, - "description": "change the flow scale", - "location": "toml" + "default": 0.9, + "description": "motion-estimation resolution scale", + "location": "profile", }, - "performance_mode": { "name": "performance_mode", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "use a lighter model for FG (recommended for most games)", - "location": "toml" + "description": "use the lighter frame-generation model", + "location": "profile", }, - - "hdr_mode": { - "name": "hdr_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable HDR mode (only for games that support HDR)", - "location": "toml" + "pacing": { + "name": "pacing", + "fieldType": ConfigFieldType.STRING, + "default": "none", + "description": "frame pacing mode (currently only none is supported)", + "location": "profile", }, - - "experimental_present_mode": { - "name": "experimental_present_mode", + "active_in": { + "name": "active_in", "fieldType": ConfigFieldType.STRING, - "default": "fifo", - "description": "override Vulkan present mode (may cause crashes)", - "location": "toml" + "default": "", + "description": "optional executable or process names, separated by commas", + "location": "profile", + }, + "gpu": { + "name": "gpu", + "fieldType": ConfigFieldType.STRING, + "default": "", + "description": "optional GPU name, vendor:device ID, or PCI bus ID", + "location": "profile", + }, + "disable_lsfgvk": { + "name": "disable_lsfgvk", + "fieldType": ConfigFieldType.BOOLEAN, + "default": False, + "description": "disable lsfg-vk on the next game launch", + "location": "script", }, - "dxvk_frame_rate": { "name": "dxvk_frame_rate", "fieldType": ConfigFieldType.INTEGER, "default": 0, "description": "base framerate cap for DirectX games before frame multiplier", - "location": "script" + "location": "script", }, - "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)", - "location": "script" + "description": "enable PROTON_USE_WOW64=1 for 32-bit games", + "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)", - "location": "script" + "description": "disable Steam Deck mode", + "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" + "description": "enable a transparent MangoHud overlay workaround", + "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" + "description": "disable vkBasalt for games where it conflicts with lsfg-vk", + "location": "script", }, - "force_enable_vkbasalt": { "name": "force_enable_vkbasalt", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "location": "script" + "description": "force-enable vkBasalt for games that require it", + "location": "script", }, - "enable_wsi": { "name": "enable_wsi", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)", - "location": "script" + "description": "enable the Gamescope WSI layer", + "location": "script", }, - "enable_zink": { "name": "enable_zink", "fieldType": ConfigFieldType.BOOLEAN, "default": False, - "description": "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games", - "location": "script" - } + "description": "enable Zink for OpenGL games", + "location": "script", + }, } def get_field_names() -> list[str]: - """Get ordered list of configuration field names""" return list(CONFIG_SCHEMA_DEF.keys()) def get_defaults() -> Dict[str, Union[bool, int, float, str]]: - """Get default configuration values""" - return { - field_name: field_def["default"] - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - } + return {name: definition["default"] for name, definition in CONFIG_SCHEMA_DEF.items()} def get_field_types() -> Dict[str, str]: - """Get field type mapping""" - return { - field_name: field_def["fieldType"].value - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - } + return {name: definition["fieldType"].value for name, definition in CONFIG_SCHEMA_DEF.items()} diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 82f37c8..8378a7f 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -11,6 +11,7 @@ export interface InstallationResult { export interface InstallationStatus { installed: boolean; + legacy_installed?: boolean; lib_exists: boolean; json_exists: boolean; script_exists: boolean; diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index 0734297..7143bb1 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,10 +1,10 @@ -import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui"; +import { PanelSectionRow, ToggleField, SliderField, ButtonItem, TextField } from "@decky/ui"; import { useState, useEffect } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { ConfigurationData } from "../config/configSchema"; import { - FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, + ACTIVE_IN, ALLOW_FP16, DISABLE_LSFGVK, DLL, FLOW_SCALE, GPU, + PERFORMANCE_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK } from "../config/generatedConfigSchema"; @@ -114,6 +114,15 @@ export function ConfigurationSection({ {!configCollapsed && ( <> <PanelSectionRow> + <TextField + label="Lossless.dll Path" + description="Optional full path to Lossless.dll. Leave blank for lsfg-vk automatic discovery." + value={config.dll} + onChange={(event) => onConfigChange(DLL, event.currentTarget.value)} + /> + </PanelSectionRow> + + <PanelSectionRow> <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} description="Lowers internal motion estimation resolution, improving performance slightly" @@ -126,6 +135,42 @@ export function ConfigurationSection({ </PanelSectionRow> <PanelSectionRow> + <ToggleField + label="Disable Frame Generation" + description="Disables lsfg-vk on the next game launch. Requires a game restart." + checked={config.disable_lsfgvk} + onChange={(value) => onConfigChange(DISABLE_LSFGVK, value)} + /> + </PanelSectionRow> + + <PanelSectionRow> + <ToggleField + label="Allow FP16" + description="Improves performance on AMD; disable for older NVIDIA GPUs." + checked={config.allow_fp16} + onChange={(value) => onConfigChange(ALLOW_FP16, value)} + /> + </PanelSectionRow> + + <PanelSectionRow> + <TextField + label="GPU" + description="Optional GPU name, vendor:device ID, or PCI bus ID." + value={config.gpu} + onChange={(event) => onConfigChange(GPU, event.currentTarget.value)} + /> + </PanelSectionRow> + + <PanelSectionRow> + <TextField + label="Active In" + description="Executable/process names separated by commas. When set, lsfg-vk matches profiles automatically." + value={config.active_in} + onChange={(event) => onConfigChange(ACTIVE_IN, event.currentTarget.value)} + /> + </PanelSectionRow> + + <PanelSectionRow> <SliderField label={`Base FPS Cap${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : " (Off)"}`} description="Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)" @@ -139,15 +184,6 @@ export function ConfigurationSection({ <PanelSectionRow> <ToggleField - label={`Present Mode (${(config.experimental_present_mode || "fifo") === "fifo" ? "FIFO - VSync" : "Mailbox"})`} - description="Toggle between FIFO - VSync (default) and Mailbox presentation modes for better performance or compatibility" - checked={(config.experimental_present_mode || "fifo") === "fifo"} - onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField label="Performance Mode" description="Uses a lighter model for FG (Recommended for most games)" checked={config.performance_mode} @@ -155,14 +191,6 @@ export function ConfigurationSection({ /> </PanelSectionRow> - <PanelSectionRow> - <ToggleField - label="HDR Mode" - description="Enables HDR mode (only for games that support HDR)" - checked={config.hdr_mode} - onChange={(value) => onConfigChange(HDR_MODE, value)} - /> - </PanelSectionRow> </> )} diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx index a80fff0..0ab715a 100644 --- a/src/components/FlatpaksModal.tsx +++ b/src/components/FlatpaksModal.tsx @@ -175,7 +175,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { <PanelSectionRow> <Field label="Runtime 23.08" - description={extensionStatus.installed_23_08 ? "Installed" : "Not installed"} + description={extensionStatus.installed_23_08 ? "Installed (manual)" : "Manual installation only; use runtime 24.08 or 25.08 when possible"} icon={extensionStatus.installed_23_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} > <ButtonItem @@ -194,7 +194,7 @@ export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { action(); } }} - disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} + disabled={!extensionStatus.installed_23_08 || operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} > {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( <Spinner /> diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 5ac31bb..3f559ea 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -33,8 +33,8 @@ export function FpsMultiplierControl({ padding: "5px 0px 0px 0px", minWidth: "40px", }} - onClick={() => onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} + onClick={() => onConfigChange(MULTIPLIER, Math.max(2, config.multiplier - 1))} + disabled={config.multiplier <= 2} > − </DialogButton> @@ -49,7 +49,7 @@ export function FpsMultiplierControl({ textAlign: "center" }} > - {config.multiplier < 2 ? "OFF" : `${config.multiplier}X`} + {`${config.multiplier}X`} </div> <DialogButton style={{ diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx index d0f2ce5..40ba955 100644 --- a/src/components/InstallationButton.tsx +++ b/src/components/InstallationButton.tsx @@ -45,7 +45,7 @@ export function InstallationButton({ return ( <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <FaDownload /> - <div>Install LSFG-VK</div> + <div>Install / Update LSFG-VK</div> </div> ); }; diff --git a/src/components/UsageInstructions.tsx b/src/components/UsageInstructions.tsx index 5f032b8..599d02a 100644 --- a/src/components/UsageInstructions.tsx +++ b/src/components/UsageInstructions.tsx @@ -60,7 +60,7 @@ export function UsageInstructions() { marginTop: "8px" }} > -The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running. +The configuration is stored in ~/.config/lsfg-vk/conf.toml. Multiplier, flow scale, and performance mode hot-reload while games are running. </div> </PanelSectionRow> </> diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 8b4fc1e..9c4437d 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -6,8 +6,8 @@ export { getFieldNames, getDefaults, getFieldTypes, - DLL, NO_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64, + DLL, ALLOW_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, PACING, + ACTIVE_IN, GPU, DISABLE_LSFGVK, DXVK_FRAME_RATE, ENABLE_WOW64, DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK } from './generatedConfigSchema'; diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 3c5d34e..9b7e6d1 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -9,12 +9,14 @@ export enum ConfigFieldType { // Field name constants for type-safe access export const DLL = "dll" as const; -export const NO_FP16 = "no_fp16" as const; +export const ALLOW_FP16 = "allow_fp16" 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 PACING = "pacing" as const; +export const ACTIVE_IN = "active_in" as const; +export const GPU = "gpu" as const; +export const DISABLE_LSFGVK = "disable_lsfgvk" 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; @@ -37,44 +39,56 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = { dll: { name: "dll", fieldType: ConfigFieldType.STRING, - default: "/games/Lossless Scaling/Lossless.dll", - description: "specify where Lossless.dll is stored" + default: "", + description: "optional full path to Lossless.dll; leave blank for automatic discovery" }, - no_fp16: { - name: "no_fp16", + allow_fp16: { + name: "allow_fp16", fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "force-disable fp16 (use on older nvidia cards)" + default: true, + description: "allow FP16 acceleration (disable on older NVIDIA GPUs)" }, multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, - default: 1, - description: "change the fps multiplier" + default: 2, + description: "frame generation multiplier" }, flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, - default: 0.8, - description: "change the flow scale" + default: 0.9, + description: "motion-estimation resolution scale" }, performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "use a lighter model for FG (recommended for most games)" + description: "use the lighter frame-generation model" }, - hdr_mode: { - name: "hdr_mode", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "enable HDR mode (only for games that support HDR)" + pacing: { + name: "pacing", + fieldType: ConfigFieldType.STRING, + default: "none", + description: "frame pacing mode (currently only none is supported)" }, - experimental_present_mode: { - name: "experimental_present_mode", + active_in: { + name: "active_in", fieldType: ConfigFieldType.STRING, - default: "fifo", - description: "override Vulkan present mode (may cause crashes)" + default: "", + description: "optional executable or process names, separated by commas" + }, + gpu: { + name: "gpu", + fieldType: ConfigFieldType.STRING, + default: "", + description: "optional GPU name, vendor:device ID, or PCI bus ID" + }, + disable_lsfgvk: { + name: "disable_lsfgvk", + fieldType: ConfigFieldType.BOOLEAN, + default: false, + description: "disable lsfg-vk on the next game launch" }, dxvk_frame_rate: { name: "dxvk_frame_rate", @@ -86,55 +100,57 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = { 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" }, 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" }, 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" + description: "enable a transparent MangoHud overlay workaround" }, disable_vkbasalt: { name: "disable_vkbasalt", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)" + description: "disable vkBasalt for games where it conflicts with lsfg-vk" }, force_enable_vkbasalt: { name: "force_enable_vkbasalt", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Force vkBasalt to engage to fix framepacing issues in gamemode" + description: "force-enable vkBasalt for games that require it" }, enable_wsi: { name: "enable_wsi", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)" + description: "enable the Gamescope WSI layer" }, enable_zink: { name: "enable_zink", fieldType: ConfigFieldType.BOOLEAN, default: false, - description: "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games" + description: "enable Zink for OpenGL games" }, }; // Type-safe configuration data structure export interface ConfigurationData { dll: string; - no_fp16: boolean; + allow_fp16: boolean; multiplier: number; flow_scale: number; performance_mode: boolean; - hdr_mode: boolean; - experimental_present_mode: string; + pacing: string; + active_in: string; + gpu: string; + disable_lsfgvk: boolean; dxvk_frame_rate: number; enable_wow64: boolean; disable_steamdeck_mode: boolean; @@ -152,13 +168,15 @@ export function getFieldNames(): string[] { export function getDefaults(): ConfigurationData { return { - dll: "/games/Lossless Scaling/Lossless.dll", - no_fp16: false, - multiplier: 1, - flow_scale: 0.8, + dll: "", + allow_fp16: true, + multiplier: 2, + flow_scale: 0.9, performance_mode: false, - hdr_mode: false, - experimental_present_mode: "fifo", + pacing: "none", + active_in: "", + gpu: "", + disable_lsfgvk: false, dxvk_frame_rate: 0, enable_wow64: false, disable_steamdeck_mode: false, @@ -173,12 +191,14 @@ export function getDefaults(): ConfigurationData { export function getFieldTypes(): Record<string, ConfigFieldType> { return { dll: ConfigFieldType.STRING, - no_fp16: ConfigFieldType.BOOLEAN, + allow_fp16: ConfigFieldType.BOOLEAN, multiplier: ConfigFieldType.INTEGER, flow_scale: ConfigFieldType.FLOAT, performance_mode: ConfigFieldType.BOOLEAN, - hdr_mode: ConfigFieldType.BOOLEAN, - experimental_present_mode: ConfigFieldType.STRING, + pacing: ConfigFieldType.STRING, + active_in: ConfigFieldType.STRING, + gpu: ConfigFieldType.STRING, + disable_lsfgvk: ConfigFieldType.BOOLEAN, dxvk_frame_rate: ConfigFieldType.INTEGER, enable_wow64: ConfigFieldType.BOOLEAN, disable_steamdeck_mode: ConfigFieldType.BOOLEAN, diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index d9bbe3e..d452a9a 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -18,7 +18,9 @@ export function useInstallationStatus() { const status = await checkLsfgVkInstalled(); setIsInstalled(status.installed); if (status.installed) { - setInstallationStatus("lsfg-vk Installed"); + setInstallationStatus("lsfg-vk v2 Installed"); + } else if (status.legacy_installed) { + setInstallationStatus("lsfg-vk v1 detected — update available"); } else { setInstallationStatus("lsfg-vk Not Installed"); } diff --git a/tests/test_lsfg_v2_migration.py b/tests/test_lsfg_v2_migration.py new file mode 100644 index 0000000..4909d8b --- /dev/null +++ b/tests/test_lsfg_v2_migration.py @@ -0,0 +1,156 @@ +import logging +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT / "py_modules")) +sys.modules.setdefault("decky", types.SimpleNamespace(logger=logging.getLogger("decky-test"))) + +from lsfg_vk.base_service import BaseService +from lsfg_vk.config_schema import ConfigurationManager +from lsfg_vk.configuration import ConfigurationService + + +LEGACY_CONFIG = '''\ +version = 1 + +[global] +current_profile = "decky-lsfg-vk" +dll = "/games/Lossless Scaling/Lossless.dll" +no_fp16 = true + +[[game]] +exe = "decky-lsfg-vk" +multiplier = 1 +flow_scale = 0.9 +performance_mode = true +hdr_mode = true +experimental_present_mode = "mailbox" +''' + + +class ConfigurationMigrationTests(unittest.TestCase): + def test_v1_configuration_is_converted_to_valid_v2_toml(self): + migrated = ConfigurationManager.parse_toml_content_multi_profile(LEGACY_CONFIG) + serialized = ConfigurationManager.generate_toml_content_multi_profile(migrated) + + self.assertIn("version = 2", serialized) + self.assertIn("allow_fp16 = false", serialized) + self.assertIn("[[profile]]", serialized) + self.assertIn('name = "decky-lsfg-vk"', serialized) + self.assertIn("multiplier = 2", serialized) + self.assertNotIn("[[game]]", serialized) + self.assertNotIn("no_fp16", serialized) + self.assertNotIn("hdr_mode", serialized) + self.assertNotIn("experimental_present_mode", serialized) + + def test_v2_normalization_is_idempotent(self): + migrated = ConfigurationManager.parse_toml_content_multi_profile(LEGACY_CONFIG) + first_write = ConfigurationManager.generate_toml_content_multi_profile(migrated) + second_write = ConfigurationManager.generate_toml_content_multi_profile( + ConfigurationManager.parse_toml_content_multi_profile(first_write) + ) + + self.assertEqual(second_write, first_write) + + def test_launch_script_uses_v2_profile_override(self): + service = ConfigurationService.__new__(ConfigurationService) + service.local_share_dir = Path("/home/deck/.local/share/vulkan/implicit_layer.d") + service.config_file_path = Path("/home/deck/.config/lsfg-vk/conf.toml") + + profile_data = { + "current_profile": "decky-lsfg-vk", + "profiles": {"decky-lsfg-vk": ConfigurationManager.get_defaults()}, + "global_config": {"dll": "/games/Lossless Scaling/Lossless.dll", "allow_fp16": True}, + } + script = service._generate_script_content_for_profile(profile_data) + + self.assertIn("LSFGVK_CONFIG=/home/deck/.config/lsfg-vk/conf.toml", script) + self.assertIn("LSFGVK_PROFILE=decky-lsfg-vk", script) + self.assertNotIn("LSFG_PROCESS", script) + + def test_active_in_uses_native_v2_tracking_instead_of_forcing_a_profile(self): + config = ConfigurationManager.get_defaults() + config["active_in"] = "Game.exe, GameThread" + self.assertEqual( + ConfigurationService._profile_selection_lines("decky-lsfg-vk", config), + ["# active_in is configured; lsfg-vk will select a matching profile automatically."], + ) + + +class InstallerInfrastructureTests(unittest.TestCase): + def test_layer_archive_selection_has_no_legacy_arm_override(self): + from lsfg_vk.constants import ( + LAYER_ARCHIVE_AARCH64, + LAYER_ARCHIVE_X86_64, + get_layer_archive_filename, + ) + + self.assertEqual(get_layer_archive_filename(False), LAYER_ARCHIVE_X86_64) + self.assertEqual(get_layer_archive_filename(True), LAYER_ARCHIVE_AARCH64) + self.assertNotEqual(LAYER_ARCHIVE_AARCH64, LAYER_ARCHIVE_X86_64) + + def test_install_migrates_v1_config_once_and_preserves_script_workarounds(self): + from lsfg_vk.installation import InstallationService + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + config_dir = home / ".config" / "lsfg-vk" + config_dir.mkdir(parents=True) + config_path = config_dir / "conf.toml" + config_path.write_text(LEGACY_CONFIG, encoding="utf-8") + script_path = home / "lsfg" + script_path.write_text("#!/bin/bash\nexport DXVK_FRAME_RATE=45\nexec \"$@\"\n", encoding="utf-8") + + service = InstallationService.__new__(InstallationService) + service.log = mock.Mock() + service.user_home = home + service.config_dir = config_dir + service.config_file_path = config_path + service.lsfg_script_path = script_path + service.lsfg_launch_script_path = script_path + + with mock.patch("lsfg_vk.dll_detection.DllDetectionService") as detection_service: + detection_service.return_value.check_lossless_scaling_dll.return_value = {"detected": False} + profile_data = service._create_config_file() + + self.assertEqual( + (config_dir / "conf.toml.v1.bak").read_text(encoding="utf-8"), + LEGACY_CONFIG, + ) + migrated = config_path.read_text(encoding="utf-8") + self.assertIn("version = 2", migrated) + self.assertIn("allow_fp16 = false", migrated) + self.assertEqual(profile_data["profiles"]["decky-lsfg-vk"]["dxvk_frame_rate"], 45) + + service._create_lsfg_launch_script(profile_data) + launch_script = script_path.read_text(encoding="utf-8") + self.assertIn("export DXVK_FRAME_RATE=45", launch_script) + self.assertIn("export DISABLE_LSFGVK=1", launch_script) + + with mock.patch("lsfg_vk.dll_detection.DllDetectionService") as detection_service: + detection_service.return_value.check_lossless_scaling_dll.return_value = {"detected": False} + service._create_config_file() + self.assertEqual(len(list(config_dir.glob("conf.toml.v1.bak*"))), 1) + + def test_writes_replace_the_config_atomically(self): + service = BaseService.__new__(BaseService) + service.log = mock.Mock() + + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "conf.toml" + target.write_text("old config", encoding="utf-8") + + with mock.patch("lsfg_vk.base_service.os.replace", wraps=__import__("os").replace) as replace: + service._write_file(target, "version = 2\n", 0o644) + + self.assertEqual(target.read_text(encoding="utf-8"), "version = 2\n") + replace.assert_called_once() + + +if __name__ == "__main__": + unittest.main() |
