summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-07-30 16:26:27 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-07-30 16:26:27 -0400
commit4390d600ffd35184c4c30bc64480f58e468627de (patch)
treea99fbdadacd15395125e5344a462abbe5327e17e
parent6c5e8a8ef81b7b3d2bf19b1783250e6b0cefedf7 (diff)
downloaddecky-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.md24
-rw-r--r--package.json31
-rw-r--r--py_modules/lsfg_vk/base_service.py27
-rw-r--r--py_modules/lsfg_vk/config_schema.py734
-rw-r--r--py_modules/lsfg_vk/config_schema_generated.py25
-rw-r--r--py_modules/lsfg_vk/configuration.py36
-rw-r--r--py_modules/lsfg_vk/constants.py24
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py85
-rw-r--r--py_modules/lsfg_vk/installation.py597
-rw-r--r--py_modules/lsfg_vk/plugin.py35
-rw-r--r--py_modules/lsfg_vk/types.py3
-rw-r--r--scripts/generate_python_boilerplate.py8
-rw-r--r--shared_config.py138
-rw-r--r--src/api/lsfgApi.ts1
-rw-r--r--src/components/ConfigurationSection.tsx68
-rw-r--r--src/components/FlatpaksModal.tsx4
-rw-r--r--src/components/FpsMultiplierControl.tsx6
-rw-r--r--src/components/InstallationButton.tsx2
-rw-r--r--src/components/UsageInstructions.tsx2
-rw-r--r--src/config/configSchema.ts4
-rw-r--r--src/config/generatedConfigSchema.ts104
-rw-r--r--src/hooks/useLsfgHooks.ts4
-rw-r--r--tests/test_lsfg_v2_migration.py156
23 files changed, 929 insertions, 1189 deletions
diff --git a/README.md b/README.md
index e8bf1fa..6481ffa 100644
--- a/README.md
+++ b/README.md
@@ -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()