summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md13
-rw-r--r--justfile9
-rw-r--r--py_modules/lsfg_vk/config_schema.py376
-rw-r--r--py_modules/lsfg_vk/config_schema_generated.py123
-rw-r--r--py_modules/lsfg_vk/configuration.py289
-rw-r--r--py_modules/lsfg_vk/installation.py52
-rw-r--r--py_modules/lsfg_vk/plugin.py89
-rw-r--r--py_modules/lsfg_vk/runtime_service.py2
-rw-r--r--py_modules/lsfg_vk/steam_service.py30
-rw-r--r--py_modules/lsfg_vk/types.py11
-rw-r--r--scripts/generate_python_boilerplate.py252
-rw-r--r--scripts/generate_ts_schema.py158
-rw-r--r--shared_config.py123
-rw-r--r--src/api/lsfgApi.ts54
-rw-r--r--src/components/ConfigurationSection.tsx307
-rw-r--r--src/components/Content.tsx48
-rw-r--r--src/components/FpsMultiplierControl.tsx6
-rw-r--r--src/components/GameConfigurationSelector.tsx29
-rw-r--r--src/components/ProfileManagement.tsx477
-rw-r--r--src/components/index.ts2
-rw-r--r--src/config/configSchema.ts6
-rw-r--r--src/config/generatedConfigSchema.ts193
-rw-r--r--src/hooks/useGameConfiguration.ts68
-rw-r--r--src/hooks/useProfileManagement.ts194
24 files changed, 503 insertions, 2408 deletions
diff --git a/README.md b/README.md
index 3d9ee7e..4be9cca 100644
--- a/README.md
+++ b/README.md
@@ -33,18 +33,18 @@ 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, FP16 acceleration, performance mode, and launch workarounds
+4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings
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
-6. **Launch your game** - frame generation will activate automatically using your plugin configuration
+6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile
## Configuration Options
-The plugin provides several configuration options to optimize frame generation for your games:
+The plugin edits upstream lsfg-vk v2 profiles directly. Unassigned games leave the layer unloaded.
### Core Settings
-- **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation
+- **FPS Multiplier**: Choose 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
- **FP16 Acceleration**: Use half-precision acceleration when supported
@@ -79,8 +79,9 @@ The plugin:
- **Flow Scale**: Adjust motion estimation quality vs performance
- **Performance Mode**: Use lighter processing for better performance
- **FP16 Acceleration**: Use half-precision acceleration when supported
- - **Experimental Features**: Override present mode and set FPS limits
-- **Hot-reloading**: Multiplier, flow scale, and performance mode changes apply without restarting games
+- **Present Mode**: Override FIFO/VSync behavior
+- **Swapchain Image Count**: Preserve the application's swapchain image count
+- **Hot-reloading**: Upstream reloads settings for the active profile; profile assignment itself applies on the next launch
- Easy uninstallation that removes all installed files when no longer needed
## Credits
diff --git a/justfile b/justfile
index 54148ad..b190631 100644
--- a/justfile
+++ b/justfile
@@ -1,11 +1,8 @@
default:
- echo "Available recipes: build, test, clean, generate-schema"
-
-generate-schema:
- python3 scripts/generate_ts_schema.py
+ echo "Available recipes: build, test, clean"
build:
- python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh
+ .vscode/build.sh
test:
scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop
@@ -18,4 +15,4 @@ cef:
clean:
rm -rf node_modules dist
- sudo rm -rf /tmp/decky \ No newline at end of file
+ sudo rm -rf /tmp/decky
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index e39be54..4ae1f4c 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -1,49 +1,37 @@
+"""Small adapter for the upstream lsfg-vk v2 configuration format."""
+
import json
-import re
-import shlex
import sys
import tomllib
-from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, TypedDict, Union, cast
+from typing import Any, Dict, TypedDict, cast
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_defaults
-from .config_schema_generated import ConfigurationData, get_script_parsing_logic
-
-
-@dataclass
-class ConfigField:
- name: str
- field_type: ConfigFieldType
- default: Union[bool, int, float, str]
- description: str
-
-CONFIG_SCHEMA: Dict[str, ConfigField] = {
- name: ConfigField(
- name=definition["name"],
- field_type=ConfigFieldType(definition["fieldType"]),
- default=definition["default"],
- description=definition["description"],
- )
- for name, definition in CONFIG_SCHEMA_DEF.items()
-}
-
-SCRIPT_ONLY_FIELDS = {
- name
- for name, definition in CONFIG_SCHEMA_DEF.items()
- if definition["location"] == "script"
-}
-DEFAULT_PROFILE_NAME = "decky-lsfg-vk"
+DEFAULT_PROFILE_NAME = "default"
+ConfigurationData = Dict[str, Any]
class ProfileData(TypedDict):
+ # Internal compatibility field; the public API no longer exposes a
+ # currently selected profile.
current_profile: str
profiles: Dict[str, Dict[str, Any]]
global_config: Dict[str, Any]
+PROFILE_DEFAULTS: Dict[str, Any] = {
+ "active_in": [],
+ "pacing_mode": "vsync",
+ "multiplier": 2,
+ "flow_scale": 1.0,
+ "performance_mode": False,
+ "override_present_mode": True,
+ "preserve_swapchain_image_count": False,
+}
+GLOBAL_DEFAULTS: Dict[str, Any] = {"dll": "", "no_fp16": False}
+
+
def _toml_value(value: Any) -> str:
if isinstance(value, bool):
return str(value).lower()
@@ -54,41 +42,52 @@ def _toml_value(value: Any) -> str:
return str(value)
+def _normalize_active_in(value: Any) -> list[str]:
+ if value in (None, ""):
+ return []
+ if isinstance(value, str):
+ return [value]
+ if not isinstance(value, list):
+ raise ValueError("active_in must be a string or list of strings")
+ return [str(item) for item in value if str(item)]
+
+
class ConfigurationManager:
@staticmethod
- def get_defaults() -> ConfigurationData:
- return cast(ConfigurationData, dict(get_defaults()))
+ def get_defaults() -> Dict[str, Any]:
+ return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS}
@staticmethod
def get_field_names() -> list[str]:
- return list(CONFIG_SCHEMA)
+ return list(ConfigurationManager.get_defaults())
@staticmethod
- def get_field_types() -> Dict[str, ConfigFieldType]:
- return {name: field.field_type for name, field in CONFIG_SCHEMA.items()}
+ def get_field_types() -> Dict[str, str]:
+ return {
+ "dll": "string", "no_fp16": "boolean", "active_in": "array",
+ "pacing_mode": "string", "multiplier": "integer", "flow_scale": "float",
+ "performance_mode": "boolean", "override_present_mode": "boolean",
+ "preserve_swapchain_image_count": "boolean",
+ }
@staticmethod
- def validate_config(config: Dict[str, Any]) -> ConfigurationData:
- 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:
- value = str(value)
- validated[name] = value
-
- if validated["multiplier"] < 1:
+ def validate_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS}
+ result.update({key: value for key, value in config.items() if key in result})
+ result["active_in"] = _normalize_active_in(result.get("active_in"))
+ result["pacing_mode"] = str(result.get("pacing_mode", "vsync")).lower()
+ if result["pacing_mode"] != "vsync":
+ raise ValueError("pacing_mode must be vsync")
+ result["multiplier"] = int(result["multiplier"])
+ if result["multiplier"] < 1:
raise ValueError("multiplier must be 1 or greater")
- if not 0.25 <= validated["flow_scale"] <= 1.0:
+ result["flow_scale"] = float(result["flow_scale"])
+ if not 0.25 <= result["flow_scale"] <= 1.0:
raise ValueError("flow_scale must be between 0.25 and 1.0")
- if validated["experimental_present_mode"] not in {"fifo", "mailbox"}:
- raise ValueError("experimental_present_mode must be fifo or mailbox")
- return cast(ConfigurationData, validated)
+ for name in ("no_fp16", "performance_mode", "override_present_mode", "preserve_swapchain_image_count"):
+ result[name] = bool(result[name])
+ result["dll"] = str(result.get("dll") or "")
+ return result
@staticmethod
def _migrate_dll_path(value: Any) -> str:
@@ -102,246 +101,81 @@ class ConfigurationManager:
@staticmethod
def _config_from_profile(profile: Dict[str, Any], global_config: Dict[str, Any]) -> Dict[str, Any]:
- config: Dict[str, Any] = dict(ConfigurationManager.get_defaults())
- for field in ("multiplier", "flow_scale", "performance_mode"):
- if field in profile:
- config[field] = profile[field]
- config["experimental_present_mode"] = "fifo" if bool(profile.get("override_present_mode", True)) else "mailbox"
- config["dll"] = global_config.get("dll", "")
- config["no_fp16"] = global_config.get("no_fp16", False)
- for field in ("active_in", "pacing", "preserve_swapchain_image_count"):
- if field in profile:
- config[field] = profile[field]
- return {**config, **ConfigurationManager.validate_config(config)}
-
- @staticmethod
- def generate_toml_content(config: ConfigurationData) -> str:
- profile_data: ProfileData = {
+ raw = dict(profile)
+ if "pacing_mode" not in raw and "pacing" in raw:
+ raw["pacing_mode"] = raw["pacing"]
+ if "override_present_mode" not in raw and "experimental_present_mode" in raw:
+ raw["override_present_mode"] = raw["experimental_present_mode"] == "fifo"
+ raw["dll"] = global_config.get("dll", "")
+ raw["no_fp16"] = global_config.get("no_fp16", False)
+ return ConfigurationManager.validate_config(raw)
+
+ @staticmethod
+ def generate_toml_content(config: Dict[str, Any]) -> str:
+ data: ProfileData = {
"current_profile": DEFAULT_PROFILE_NAME,
"profiles": {DEFAULT_PROFILE_NAME: dict(config)},
- "global_config": {
- "dll": config.get("dll", ""),
- "no_fp16": config.get("no_fp16", False),
- },
+ "global_config": {"dll": config.get("dll", ""), "no_fp16": config.get("no_fp16", False)},
}
- return ConfigurationManager.generate_toml_content_multi_profile(profile_data)
+ return ConfigurationManager.generate_toml_content_multi_profile(data)
@staticmethod
def generate_toml_content_multi_profile(profile_data: ProfileData) -> str:
- global_config = profile_data["global_config"]
+ global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})}
lines = ["version = 2", "", "[global]"]
- dll = ConfigurationManager._migrate_dll_path(global_config.get("dll", ""))
+ dll = ConfigurationManager._migrate_dll_path(global_config.get("dll"))
if dll:
lines.append(f"dll = {_toml_value(dll)}")
lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}")
- if global_config.get("log_level"):
- lines.append(f"log_level = {_toml_value(global_config['log_level'])}")
- if global_config.get("log_file"):
- lines.append(f"log_file = {_toml_value(global_config['log_file'])}")
-
- profiles = sorted(
- profile_data["profiles"].items(),
- key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]),
- )
- for profile_name, raw_config in profiles:
- config = ConfigurationManager.validate_config(raw_config)
- lines.extend(["", "[[profile]]", f"name = {_toml_value(profile_name)}"])
- active_in = raw_config.get("active_in")
- if active_in not in (None, "", []):
- lines.append(f"active_in = {_toml_value(active_in)}")
- lines.extend(
- [
- f"multiplier = {config['multiplier']}",
- f"flow_scale = {config['flow_scale']}",
- f"performance_mode = {_toml_value(config['performance_mode'])}",
- f"pacing = {_toml_value(raw_config.get('pacing', 'vsync'))}",
- f"override_present_mode = {_toml_value(config['experimental_present_mode'] == 'fifo')}",
- f"preserve_swapchain_image_count = {_toml_value(bool(raw_config.get('preserve_swapchain_image_count', False)))}",
- ]
- )
+ profiles = sorted(profile_data["profiles"].items(), key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]))
+ for name, raw in profiles:
+ config = ConfigurationManager.validate_config({**raw, **global_config})
+ lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"])
+ if config["active_in"]:
+ lines.append(f"active_in = {_toml_value(config['active_in'])}")
+ lines.extend([
+ f"pacing_mode = {_toml_value(config['pacing_mode'])}",
+ f"multiplier = {config['multiplier']}",
+ f"flow_scale = {config['flow_scale']}",
+ f"performance_mode = {_toml_value(config['performance_mode'])}",
+ f"override_present_mode = {_toml_value(config['override_present_mode'])}",
+ f"preserve_swapchain_image_count = {_toml_value(config['preserve_swapchain_image_count'])}",
+ ])
return "\n".join(lines) + "\n"
@staticmethod
- def _profile_data_from_v1(data: Dict[str, Any]) -> ProfileData:
- old_global = dict(data.get("global", {}))
- global_config: Dict[str, Any] = {
- "dll": ConfigurationManager._migrate_dll_path(old_global.get("dll", "")),
- "no_fp16": bool(old_global.get("no_fp16", False)),
- }
- profiles: Dict[str, Dict[str, Any]] = {}
- for game in data.get("game", []):
- profile_name = str(game.get("exe", DEFAULT_PROFILE_NAME))
- config: Dict[str, Any] = dict(ConfigurationManager.get_defaults())
- for field in ("multiplier", "flow_scale", "performance_mode", "experimental_present_mode"):
- if field in game:
- config[field] = game[field]
- config["dll"] = global_config["dll"]
- config["no_fp16"] = global_config["no_fp16"]
- profiles[profile_name] = dict(ConfigurationManager.validate_config(config))
-
- if not profiles:
- profiles[DEFAULT_PROFILE_NAME] = dict(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:
data = tomllib.loads(content)
version = data.get("version")
- if version == 1:
- return ConfigurationManager._profile_data_from_v1(data)
- if version != 2:
+ if version not in (1, 2):
raise ValueError("unsupported lsfg-vk configuration version")
-
raw_global = dict(data.get("global", {}))
- global_config: Dict[str, Any] = {
+ global_config = {
"dll": ConfigurationManager._migrate_dll_path(raw_global.get("dll", "")),
"no_fp16": not bool(raw_global.get("allow_fp16", True)),
}
- for field in ("log_level", "log_file"):
- if field in raw_global:
- global_config[field] = raw_global[field]
-
profiles: Dict[str, Dict[str, Any]] = {}
- for profile in data.get("profile", []):
- profile_name = str(profile.get("name", DEFAULT_PROFILE_NAME))
- profiles[profile_name] = ConfigurationManager._config_from_profile(profile, global_config)
-
+ source_profiles = data.get("game", []) if version == 1 else data.get("profile", [])
+ for profile in source_profiles:
+ name = str(profile.get("exe" if version == 1 else "name", DEFAULT_PROFILE_NAME))
+ profiles[name] = ConfigurationManager._config_from_profile(profile, global_config)
if not profiles:
- default = dict(ConfigurationManager.get_defaults())
- default["dll"] = global_config["dll"]
- default["no_fp16"] = global_config["no_fp16"]
- profiles[DEFAULT_PROFILE_NAME] = default
-
- 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 parse_toml_content(content: str) -> ConfigurationData:
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(content)
- return cast(ConfigurationData, profile_data["profiles"][profile_data["current_profile"]])
-
- @staticmethod
- def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]:
- return get_script_parsing_logic()(script_content.splitlines())
-
- @staticmethod
- def parse_profile_selection(script_content: str) -> str | None:
- selected = None
- for line in script_content.splitlines():
- try:
- tokens = shlex.split(line)
- except ValueError:
- continue
- if len(tokens) != 2 or tokens[0] != "export" or "=" not in tokens[1]:
- continue
- key, value = tokens[1].split("=", 1)
- if key in {"LSFGVK_PROFILE", "LSFG_PROCESS"} and value:
- selected = value
- return selected
-
- @staticmethod
- def merge_config_with_script(
- toml_config: Dict[str, Any],
- script_values: Dict[str, Union[bool, int, str]],
- ) -> Dict[str, Any]:
- merged = dict(toml_config)
- for field in SCRIPT_ONLY_FIELDS:
- if field in script_values:
- merged[field] = script_values[field]
- return merged
+ profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.validate_config(global_config)
+ elif DEFAULT_PROFILE_NAME not in profiles:
+ source = profiles.get("decky-lsfg-vk", next(iter(profiles.values())))
+ profiles[DEFAULT_PROFILE_NAME] = {**source, "active_in": []}
+ if profiles.get("decky-lsfg-vk", {}).get("active_in", []) == []:
+ profiles.pop("decky-lsfg-vk", None)
+ return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": profiles, "global_config": global_config}
@staticmethod
- def normalize_profile_name(profile_name: str) -> str:
- return re.sub(r"\s+", "-", profile_name.strip()).strip("-")
-
- @staticmethod
- def validate_profile_name(profile_name: str) -> bool:
- normalized = ConfigurationManager.normalize_profile_name(profile_name)
- invalid = '\t\n\r\'"\\/$|&;()<>{}[]' + "`" + '*?'
- return (
- bool(normalized)
- and not any(character in invalid for character in normalized)
- and normalized.lower() not in {"global", "profile"}
- )
-
- @staticmethod
- def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData:
- if not ConfigurationManager.validate_profile_name(profile_name):
- raise ValueError(f"Invalid profile name: {profile_name}")
- 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"]
- profiles = dict(profile_data["profiles"])
- profiles[normalized] = dict(profiles[source])
- return ProfileData(
- current_profile=profile_data["current_profile"],
- profiles=profiles,
- global_config=dict(profile_data["global_config"]),
- )
-
- @staticmethod
- def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData:
- if profile_name == 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")
- 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"]),
- )
-
- @staticmethod
- def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData:
- if old_name == DEFAULT_PROFILE_NAME:
- 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"]),
- )
+ def is_legacy_v1(content: str) -> bool:
+ try:
+ return tomllib.loads(content).get("version") == 1
+ except tomllib.TOMLDecodeError:
+ return False
@staticmethod
- def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData:
- if profile_name not in profile_data["profiles"]:
- raise ValueError(f"Profile '{profile_name}' does not exist")
- return ProfileData(
- current_profile=profile_name,
- profiles=dict(profile_data["profiles"]),
- global_config=dict(profile_data["global_config"]),
- )
+ def parse_toml_content(content: str) -> Dict[str, Any]:
+ data = ConfigurationManager.parse_toml_content_multi_profile(content)
+ return cast(Dict[str, Any], data["profiles"][DEFAULT_PROFILE_NAME])
diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py
deleted file mode 100644
index 913609b..0000000
--- a/py_modules/lsfg_vk/config_schema_generated.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""
-Auto-generated configuration schema components from shared_config.py
-DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build
-"""
-
-from typing import TypedDict, Dict, Any, Union
-from enum import Enum
-import sys
-from pathlib import Path
-
-# Import shared configuration constants
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
-
-# Field name constants for type-safe access
-DLL = "dll"
-NO_FP16 = "no_fp16"
-MULTIPLIER = "multiplier"
-FLOW_SCALE = "flow_scale"
-PERFORMANCE_MODE = "performance_mode"
-EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode"
-DXVK_FRAME_RATE = "dxvk_frame_rate"
-ENABLE_WOW64 = "enable_wow64"
-DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode"
-MANGOHUD_WORKAROUND = "mangohud_workaround"
-DISABLE_VKBASALT = "disable_vkbasalt"
-FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt"
-ENABLE_WSI = "enable_wsi"
-ENABLE_ZINK = "enable_zink"
-
-
-class ConfigurationData(TypedDict):
- """Type-safe configuration data structure - AUTO-GENERATED"""
- dll: str
- no_fp16: bool
- multiplier: int
- flow_scale: float
- performance_mode: bool
- experimental_present_mode: str
- dxvk_frame_rate: int
- enable_wow64: bool
- disable_steamdeck_mode: bool
- mangohud_workaround: bool
- disable_vkbasalt: bool
- force_enable_vkbasalt: bool
- enable_wsi: bool
- enable_zink: bool
-
-
-def get_script_parsing_logic():
- """Return the script parsing logic as a callable"""
- def parse_script_values(lines):
- script_values = {}
- for line in lines:
- line = line.strip()
- if not line or line.startswith("#") or not line.startswith("export "):
- continue
- if "=" in line:
- export_line = line[len("export "):]
- key, value = export_line.split("=", 1)
- key = key.strip()
- value = value.strip()
-
- # Auto-generated parsing logic:
- if key == "DXVK_FRAME_RATE":
- try:
- script_values["dxvk_frame_rate"] = int(value)
- except ValueError:
- pass
- if key == "PROTON_USE_WOW64":
- script_values["enable_wow64"] = value == "1"
- if key == "SteamDeck":
- script_values["disable_steamdeck_mode"] = value == "0"
- if key == "MANGOHUD":
- script_values["mangohud_workaround"] = value == "1"
- if key == "DISABLE_VKBASALT":
- script_values["disable_vkbasalt"] = value == "1"
- if key == "ENABLE_VKBASALT":
- 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":
- script_values["enable_zink"] = True
- if key == "GALLIUM_DRIVER" and value == "zink":
- script_values["enable_zink"] = True
-
- return script_values
- return parse_script_values
-
-
-def get_script_generation_logic():
- """Return the script generation logic as a callable"""
- def generate_script_lines(config):
- lines = []
- dxvk_frame_rate = config.get("dxvk_frame_rate", 0)
- if dxvk_frame_rate > 0:
- lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}")
- if config.get("enable_wow64", False):
- lines.append("export PROTON_USE_WOW64=1")
- if config.get("disable_steamdeck_mode", False):
- lines.append("export SteamDeck=0")
- if config.get("mangohud_workaround", False):
- lines.append("export MANGOHUD=1")
- if config.get("disable_vkbasalt", False):
- lines.append("export DISABLE_VKBASALT=1")
- if config.get("force_enable_vkbasalt", False):
- 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")
- lines.append("export GALLIUM_DRIVER=zink")
- return lines
- return generate_script_lines
-
-
-ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'experimental_present_mode', '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 12710cc..2626a66 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -1,229 +1,134 @@
+import re
import shlex
+from typing import Any, Dict
from .base_service import BaseService
from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData
-from .config_schema_generated import ConfigurationData, get_script_generation_logic
from .runtime_service import RuntimeService
-from .types import ConfigurationResponse, ProfileResponse, ProfilesResponse
class ConfigurationService(BaseService):
+ """Controller-facing adapter over upstream lsfg-vk profiles."""
+
def __init__(self, logger=None, runtime_service: RuntimeService = None):
super().__init__(logger)
self.runtime_service = runtime_service or RuntimeService(logger=self.log)
- def get_config(self) -> ConfigurationResponse:
+ def _default_data(self) -> ProfileData:
+ defaults = ConfigurationManager.validate_config({})
+ return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: defaults}, "global_config": {"dll": "", "no_fp16": False}}
+
+ def _get_profile_data(self) -> ProfileData:
+ if not self.config_file_path.exists():
+ return self._default_data()
+ return ConfigurationManager.parse_toml_content_multi_profile(self.config_file_path.read_text(encoding="utf-8"))
+
+ def _save_profile_data(self, data: ProfileData) -> None:
+ content = ConfigurationManager.generate_toml_content_multi_profile(data)
+ self.runtime_service.validate_config_content(content)
+ self._write_file(self.config_file_path, content, 0o644)
+
+ @staticmethod
+ def _game_profile_name(appid: str) -> str:
+ if not re.fullmatch(r"[0-9]+", str(appid)):
+ raise ValueError("appid must be numeric")
+ return f"game-{appid}"
+
+ @staticmethod
+ def _public_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ return ConfigurationManager.validate_config(config)
+
+ def get_config(self) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
- current_profile = profile_data["current_profile"]
- config = profile_data["profiles"].get(current_profile, dict(ConfigurationManager.get_defaults()))
- return self._success_response(ConfigurationResponse, config=config)
+ data = self._get_profile_data()
+ return self._success_response(dict, config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]))
except Exception as error:
self.log.error(f"Error reading lsfg config: {error}")
- return self._error_response(ConfigurationResponse, str(error), config=None)
+ return self._error_response(dict, str(error), config=None)
- def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse:
+ def get_game_configs(self) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
- return self.update_profile_config(profile_data["current_profile"], config)
+ data = self._get_profile_data()
+ games = []
+ for name, raw in data["profiles"].items():
+ active_in = raw.get("active_in", [])
+ if len(active_in) != 1 or not str(active_in[0]).isdigit():
+ continue
+ games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)})
+ return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games)
except Exception as error:
- self.log.error(f"Error updating lsfg config: {error}")
- return self._error_response(ConfigurationResponse, str(error), config=None)
+ self.log.error(f"Error reading game configs: {error}")
+ return self._error_response(dict, str(error), default=None, games=[])
- def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse:
+ def get_game_config(self, appid: str) -> Dict[str, Any]:
try:
- profile_data: ProfileData = {
- "current_profile": DEFAULT_PROFILE_NAME,
- "profiles": {DEFAULT_PROFILE_NAME: dict(config)},
- "global_config": {
- "dll": config.get("dll", ""),
- "no_fp16": config.get("no_fp16", False),
- },
- }
- return self.update_lsfg_script_from_profile_data(profile_data)
+ data = self._get_profile_data()
+ name = self._game_profile_name(appid)
+ profile = data["profiles"].get(name)
+ if profile is None:
+ profile = next((value for value in data["profiles"].values() if str(appid) in value.get("active_in", [])), None)
+ return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME]))
except Exception as error:
- return self._error_response(ConfigurationResponse, str(error), config=None)
-
- def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str:
- current_profile = profile_data["current_profile"]
- config = dict(profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()))
- config["dll"] = profile_data["global_config"].get("dll", config.get("dll", ""))
- config["no_fp16"] = profile_data["global_config"].get("no_fp16", config.get("no_fp16", False))
-
- lines = ["#!/bin/bash"]
- lines.extend(get_script_generation_logic()(config))
- lines.extend(
- [
- f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}",
- f"export LSFGVK_PROFILE={shlex.quote(current_profile)}",
- 'exec "$@"',
- ]
- )
- return "\n".join(lines) + "\n"
-
- def _generate_script_content(self, config: ConfigurationData) -> str:
- profile_data: ProfileData = {
- "current_profile": DEFAULT_PROFILE_NAME,
- "profiles": {DEFAULT_PROFILE_NAME: dict(config)},
- "global_config": {
- "dll": config.get("dll", ""),
- "no_fp16": config.get("no_fp16", False),
- },
- }
- return self._generate_script_content_for_profile(profile_data)
+ return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None)
- def _get_profile_data(self) -> ProfileData:
- if not self.config_file_path.exists():
- default = ConfigurationManager.get_defaults()
- return ProfileData(
- current_profile=DEFAULT_PROFILE_NAME,
- profiles={DEFAULT_PROFILE_NAME: dict(default)},
- global_config={
- "dll": default.get("dll", ""),
- "no_fp16": default.get("no_fp16", False),
- },
- )
-
- profile_data = ConfigurationManager.parse_toml_content_multi_profile(
- self.config_file_path.read_text(encoding="utf-8")
- )
- if self.lsfg_script_path.exists():
- script_content = self.lsfg_script_path.read_text(encoding="utf-8")
- selected = ConfigurationManager.parse_profile_selection(script_content)
- if selected in profile_data["profiles"]:
- profile_data["current_profile"] = selected
- current_profile = profile_data["current_profile"]
- profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script(
- profile_data["profiles"][current_profile],
- ConfigurationManager.parse_script_content(script_content),
- )
- return profile_data
-
- def _save_profile_data(self, profile_data: ProfileData) -> None:
- content = ConfigurationManager.generate_toml_content_multi_profile(profile_data)
- self.runtime_service.validate_config_content(content)
- self._write_file(
- self.config_file_path,
- content,
- 0o644,
- )
-
- def get_profiles(self) -> ProfilesResponse:
- try:
- profile_data = self._get_profile_data()
- return self._success_response(
- ProfilesResponse,
- "Profiles retrieved successfully",
- profiles=list(profile_data["profiles"]),
- current_profile=profile_data["current_profile"],
- )
- except Exception as error:
- return self._error_response(
- ProfilesResponse,
- str(error),
- profiles=None,
- current_profile=None,
- )
-
- def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse:
+ def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
- profile_data = self._get_profile_data()
- new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile)
- self._save_profile_data(new_profile_data)
- normalized = ConfigurationManager.normalize_profile_name(profile_name)
- return self._success_response(
- ProfileResponse,
- f"Profile '{normalized}' created successfully",
- profile_name=normalized,
- )
+ data = self._get_profile_data()
+ name = self._game_profile_name(appid)
+ validated = self._public_config(config)
+ validated["active_in"] = [str(appid)]
+ data["profiles"][name] = validated
+ self._save_profile_data(data)
+ return self._success_response(dict, appid=str(appid), config=validated)
except Exception as error:
- return self._error_response(ProfileResponse, str(error), profile_name=None)
+ return self._error_response(dict, str(error), appid=str(appid), config=None)
- def delete_profile(self, profile_name: str) -> ProfileResponse:
+ def reset_game_config(self, appid: str) -> Dict[str, Any]:
try:
- profile_data = ConfigurationManager.delete_profile(self._get_profile_data(), profile_name)
- self._save_profile_data(profile_data)
- script_result = self.update_lsfg_script_from_profile_data(profile_data)
- if not script_result["success"]:
- raise OSError(script_result["error"])
- return self._success_response(
- ProfileResponse,
- f"Profile '{profile_name}' deleted successfully",
- profile_name=profile_name,
- )
+ data = self._get_profile_data()
+ name = self._game_profile_name(appid)
+ data["profiles"].pop(name, None)
+ for profile_name, profile in list(data["profiles"].items()):
+ if profile_name != DEFAULT_PROFILE_NAME and str(appid) in profile.get("active_in", []):
+ data["profiles"].pop(profile_name)
+ self._save_profile_data(data)
+ return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]))
except Exception as error:
- return self._error_response(ProfileResponse, str(error), profile_name=None)
+ return self._error_response(dict, str(error), appid=str(appid), config=None)
- def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse:
+ def reset_all_game_configs(self) -> Dict[str, Any]:
try:
- profile_data = ConfigurationManager.rename_profile(self._get_profile_data(), old_name, new_name)
- self._save_profile_data(profile_data)
- script_result = self.update_lsfg_script_from_profile_data(profile_data)
- if not script_result["success"]:
- raise OSError(script_result["error"])
- normalized = ConfigurationManager.normalize_profile_name(new_name)
- return self._success_response(
- ProfileResponse,
- f"Profile renamed to '{normalized}' successfully",
- profile_name=normalized,
- )
+ data = self._get_profile_data()
+ data["profiles"] = {DEFAULT_PROFILE_NAME: data["profiles"][DEFAULT_PROFILE_NAME]}
+ self._save_profile_data(data)
+ return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=[])
except Exception as error:
- return self._error_response(ProfileResponse, str(error), profile_name=None)
+ return self._error_response(dict, str(error), default=None, games=[])
- def set_current_profile(self, profile_name: str) -> ProfileResponse:
+ def update_config_from_dict(self, config: Dict[str, Any]) -> Dict[str, Any]:
try:
- profile_data = ConfigurationManager.set_current_profile(self._get_profile_data(), profile_name)
- script_result = self.update_lsfg_script_from_profile_data(profile_data)
- if not script_result["success"]:
- raise OSError(script_result["error"])
- return self._success_response(
- ProfileResponse,
- f"Current profile set to '{profile_name}' successfully",
- profile_name=profile_name,
- )
+ data = self._get_profile_data()
+ validated = self._public_config(config)
+ validated["active_in"] = []
+ data["profiles"][DEFAULT_PROFILE_NAME] = validated
+ data["global_config"] = {"dll": validated.get("dll", ""), "no_fp16": validated.get("no_fp16", False)}
+ self._save_profile_data(data)
+ return self._success_response(dict, config=validated)
except Exception as error:
- return self._error_response(ProfileResponse, str(error), profile_name=None)
+ return self._error_response(dict, str(error), config=None)
- def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse:
- try:
- profile_data = self._get_profile_data()
- if profile_name not in profile_data["profiles"]:
- raise ValueError(f"Profile '{profile_name}' does not exist")
-
- validated = ConfigurationManager.validate_config(config)
- profile_data["profiles"][profile_name] = {
- **profile_data["profiles"][profile_name],
- **validated,
- }
- profile_data["global_config"]["dll"] = validated.get("dll", "")
- profile_data["global_config"]["no_fp16"] = validated.get("no_fp16", False)
- self._save_profile_data(profile_data)
-
- if profile_name == profile_data["current_profile"]:
- script_result = self.update_lsfg_script_from_profile_data(profile_data)
- if not script_result["success"]:
- raise OSError(script_result["error"])
-
- return self._success_response(
- ConfigurationResponse,
- f"Profile '{profile_name}' configuration updated successfully",
- config=validated,
- )
- except Exception as error:
- return self._error_response(ConfigurationResponse, str(error), config=None)
+ def update_lsfg_script(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ return self.update_config_from_dict(config)
+
+ def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str:
+ return "#!/bin/bash\n" f"export LSFGVK_CONFIG={shlex.quote(str(self.config_file_path))}\n" 'exec "$@"\n'
+
+ def _generate_script_content(self, config: Dict[str, Any]) -> str:
+ return self._generate_script_content_for_profile(self._default_data())
- def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse:
+ def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> Dict[str, Any]:
try:
- script_content = self._generate_script_content_for_profile(profile_data)
- self._write_file(self.lsfg_script_path, script_content, 0o755)
- current_config = profile_data["profiles"].get(
- profile_data["current_profile"],
- dict(ConfigurationManager.get_defaults()),
- )
- return self._success_response(
- ConfigurationResponse,
- "Launch script updated successfully",
- config=current_config,
- )
+ self._write_file(self.lsfg_script_path, self._generate_script_content_for_profile(profile_data), 0o755)
+ return self._success_response(dict)
except Exception as error:
- return self._error_response(ConfigurationResponse, str(error), config=None)
+ return self._error_response(dict, str(error))
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index e979ae5..f7bfdaf 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -24,13 +24,20 @@ from .constants import (
UI_ICON_FILENAME,
)
from .runtime_service import RuntimeService
+from .steam_service import SteamService
from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse
class InstallationService(BaseService):
- def __init__(self, logger=None, runtime_service: RuntimeService = None):
+ def __init__(
+ self,
+ logger=None,
+ runtime_service: RuntimeService = None,
+ steam_service: SteamService = None,
+ ):
super().__init__(logger)
self.runtime_service = runtime_service or RuntimeService(logger=self.log)
+ self.steam_service = steam_service or SteamService(logger=self.log)
self.lib_file = self.local_lib_dir / LIB_FILENAME
self.lib_x86_file = self.local_lib_dir / LIB_X86_FILENAME
self.json_file = self.local_share_dir / JSON_FILENAME
@@ -143,33 +150,25 @@ class InstallationService(BaseService):
},
)
+ self._resolve_dll_path(profile_data)
defaults = dict(ConfigurationManager.get_defaults())
for profile_name, raw_profile in list(profile_data["profiles"].items()):
- validated = ConfigurationManager.validate_config({**defaults, **raw_profile})
- profile_data["profiles"][profile_name] = {**raw_profile, **validated}
-
- if self.lsfg_script_path.exists():
- script_content = self.lsfg_script_path.read_text(encoding="utf-8")
- selected = ConfigurationManager.parse_profile_selection(script_content)
- if selected in profile_data["profiles"]:
- profile_data["current_profile"] = selected
- current_profile = profile_data["current_profile"]
- profile_data["profiles"][current_profile] = ConfigurationManager.merge_config_with_script(
- profile_data["profiles"][current_profile],
- ConfigurationManager.parse_script_content(script_content),
+ profile_data["profiles"][profile_name] = ConfigurationManager.validate_config(
+ {**defaults, **raw_profile, **profile_data["global_config"]}
)
+ profile_data["current_profile"] = DEFAULT_PROFILE_NAME
+ return profile_data
- if profile_data["current_profile"] not in profile_data["profiles"]:
- profile_data["current_profile"] = (
- DEFAULT_PROFILE_NAME
- if DEFAULT_PROFILE_NAME in profile_data["profiles"]
- else next(iter(profile_data["profiles"]))
- )
+ def _resolve_dll_path(self, profile_data: ProfileData) -> bool:
+ current_path = str(profile_data["global_config"].get("dll") or "")
+ if current_path and Path(current_path).is_file():
+ return False
- for profile in profile_data["profiles"].values():
- profile["dll"] = profile_data["global_config"].get("dll", "")
- profile["no_fp16"] = profile_data["global_config"].get("no_fp16", False)
- return profile_data
+ dll_path = self.steam_service.find_lsfg_vk_dll()
+ if dll_path and current_path != dll_path:
+ profile_data["global_config"]["dll"] = dll_path
+ return True
+ return False
def _create_lsfg_launch_script(self, profile_data: ProfileData) -> None:
from .configuration import ConfigurationService
@@ -202,6 +201,13 @@ class InstallationService(BaseService):
if legacy_layer or legacy_config:
return True
try:
+ if self.config_file_path.exists():
+ data = ConfigurationManager.parse_toml_content_multi_profile(
+ self.config_file_path.read_text(encoding="utf-8")
+ )
+ configured = str(data["global_config"].get("dll") or "")
+ if (not configured or not Path(configured).is_file()) and self.steam_service.find_lsfg_vk_dll():
+ return True
return not self.runtime_service.is_healthy()
except Exception:
return True
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index f764086..3b9a97f 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -31,10 +31,13 @@ class Plugin:
def __init__(self):
"""Initialize the plugin with all necessary services"""
self.runtime_service = RuntimeService()
- self.installation_service = InstallationService(runtime_service=self.runtime_service)
+ self.steam_service = SteamService()
+ self.installation_service = InstallationService(
+ runtime_service=self.runtime_service,
+ steam_service=self.steam_service,
+ )
self.configuration_service = ConfigurationService(runtime_service=self.runtime_service)
self.flatpak_service = FlatpakService()
- self.steam_service = SteamService()
async def install_lsfg_vk(self) -> Dict[str, Any]:
"""Install the bundled lsfg-vk runtime to ~/.local
@@ -74,33 +77,11 @@ class Plugin:
Returns:
Dict with field names, types, defaults, and profile information
"""
- try:
- profiles_response = self.configuration_service.get_profiles()
-
- schema_data = {
- "field_names": ConfigurationManager.get_field_names(),
- "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()},
- "defaults": ConfigurationManager.get_defaults()
- }
-
- if profiles_response.get("success"):
- schema_data["profiles"] = profiles_response.get("profiles", [])
- schema_data["current_profile"] = profiles_response.get("current_profile")
- else:
- schema_data["profiles"] = ["decky-lsfg-vk"]
- schema_data["current_profile"] = "decky-lsfg-vk"
-
- return schema_data
-
- except (ValueError, KeyError, AttributeError) as e:
- self.configuration_service.log.warning(f"Failed to get full schema, using fallback: {e}")
- return {
- "field_names": ConfigurationManager.get_field_names(),
- "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()},
- "defaults": ConfigurationManager.get_defaults(),
- "profiles": ["decky-lsfg-vk"],
- "current_profile": "decky-lsfg-vk"
- }
+ return {
+ "field_names": ConfigurationManager.get_field_names(),
+ "field_types": ConfigurationManager.get_field_types(),
+ "defaults": ConfigurationManager.get_defaults(),
+ }
async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Update lsfg TOML configuration using object-based API (single source of truth)
@@ -111,19 +92,35 @@ class Plugin:
Returns:
ConfigurationResponse dict with success status
"""
- validated_config = ConfigurationManager.validate_config(config)
-
- return self.configuration_service.update_config_from_dict(validated_config)
+ return self.configuration_service.update_config_from_dict(config)
+
+ async def get_game_configs(self) -> Dict[str, Any]:
+ return self.configuration_service.get_game_configs()
+
+ async def get_installed_games(self) -> Dict[str, Any]:
+ return self.steam_service.get_installed_games()
- async def get_profiles(self) -> Dict[str, Any]:
+ async def get_game_config(self, appid: str) -> Dict[str, Any]:
+ return self.configuration_service.get_game_config(appid)
+
+ async def update_game_config(self, appid: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ return self.configuration_service.update_game_config(appid, config)
+
+ async def reset_game_config(self, appid: str) -> Dict[str, Any]:
+ return self.configuration_service.reset_game_config(appid)
+
+ async def reset_all_game_configs(self) -> Dict[str, Any]:
+ return self.configuration_service.reset_all_game_configs()
+
+ async def _legacy_get_profiles(self) -> Dict[str, Any]:
"""Get list of all profiles and current profile
Returns:
ProfilesResponse dict with profile list and current profile
"""
- return self.configuration_service.get_profiles()
+ return self.configuration_service.get_game_configs()
- async def create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]:
+ async def _legacy_create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]:
"""Create a new profile
Args:
@@ -133,9 +130,9 @@ class Plugin:
Returns:
ProfileResponse dict with success status
"""
- return self.configuration_service.create_profile(profile_name, source_profile)
+ return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
- async def delete_profile(self, profile_name: str) -> Dict[str, Any]:
+ async def _legacy_delete_profile(self, profile_name: str) -> Dict[str, Any]:
"""Delete a profile
Args:
@@ -144,9 +141,9 @@ class Plugin:
Returns:
ProfileResponse dict with success status
"""
- return self.configuration_service.delete_profile(profile_name)
+ return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
- async def rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]:
+ async def _legacy_rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]:
"""Rename a profile
Args:
@@ -156,9 +153,9 @@ class Plugin:
Returns:
ProfileResponse dict with success status
"""
- return self.configuration_service.rename_profile(old_name, new_name)
+ return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
- async def set_current_profile(self, profile_name: str) -> Dict[str, Any]:
+ async def _legacy_set_current_profile(self, profile_name: str) -> Dict[str, Any]:
"""Set the current active profile
Args:
@@ -167,9 +164,9 @@ class Plugin:
Returns:
ProfileResponse dict with success status
"""
- return self.configuration_service.set_current_profile(profile_name)
+ return {"success": False, "error": "There is no globally selected profile"}
- async def update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ async def _legacy_update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
"""Update configuration for a specific profile
Args:
@@ -179,9 +176,7 @@ class Plugin:
Returns:
ConfigurationResponse dict with success status
"""
- validated_config = ConfigurationManager.validate_config(config)
-
- return self.configuration_service.update_profile_config(profile_name, validated_config)
+ return {"success": False, "error": "Use update_game_config with a Steam AppID"}
async def get_launch_option(self) -> Dict[str, Any]:
"""Get the launch option that users need to set for their games
@@ -192,7 +187,7 @@ class Plugin:
return {
"launch_option": "~/lsfg %command%",
"instructions": "Add this to your game's launch options in Steam Properties",
- "explanation": "The lsfg script is created during installation and sets up the environment for the plugin"
+ "explanation": "The lsfg script points games at the upstream configuration; profiles are selected by Steam AppID"
}
async def get_config_file_content(self) -> Dict[str, Any]:
diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py
index a61220e..8785522 100644
--- a/py_modules/lsfg_vk/runtime_service.py
+++ b/py_modules/lsfg_vk/runtime_service.py
@@ -24,7 +24,7 @@ class RuntimeService(BaseService):
HOME=str(self.user_home),
XDG_CONFIG_HOME=str(self.user_home / ".config"),
)
- for name in ("LSFGVK_CONFIG", "LSFGVK_PROFILE", "LSFGVK_ENV"):
+ for name in ("LSFGVK_CONFIG", "LSFGVK_ENV"):
environment.pop(name, None)
return environment
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
index 867a135..58e0a20 100644
--- a/py_modules/lsfg_vk/steam_service.py
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -128,6 +128,16 @@ class SteamService(BaseService):
),
}
+ def find_lsfg_vk_dll(self) -> Optional[str]:
+ """Find the branch-specific upstream DLL in any Steam library."""
+ if self.get_branch_status().get("needs_switch"):
+ return None
+ for library_root in self._steam_library_roots():
+ dll_path = library_root / "steamapps/common/Lossless Scaling/lsfg-vk.dll"
+ if dll_path.is_file():
+ return str(dll_path)
+ return None
+
def get_branch_status(self) -> Dict[str, object]:
try:
manifest_path = self._manifest_path()
@@ -165,3 +175,23 @@ class SteamService(BaseService):
needs_switch=False,
restart_required=False,
)
+
+ def get_installed_games(self) -> Dict[str, object]:
+ """Return installed Steam app IDs and names for the Game Mode selector."""
+ try:
+ games = {}
+ for library_root in self._steam_library_roots():
+ for manifest in (library_root / "steamapps").glob("appmanifest_*.acf"):
+ match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name)
+ if not match:
+ continue
+ try:
+ content = manifest.read_text(encoding="utf-8")
+ except OSError:
+ continue
+ appid = match.group(1)
+ name = self._section_value(content, "AppState", "name") or f"App {appid}"
+ games[appid] = name
+ return self._success_response(dict, games=[{"appid": appid, "name": name} for appid, name in sorted(games.items(), key=lambda item: item[1].lower())])
+ except Exception as error:
+ return self._error_response(dict, str(error), games=[])
diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py
index 2c56a15..ef6bab7 100644
--- a/py_modules/lsfg_vk/types.py
+++ b/py_modules/lsfg_vk/types.py
@@ -68,15 +68,16 @@ class ProfileConfig(TypedDict):
class ProfilesResponse(BaseResponse):
- """Response for profile operations"""
- profiles: Optional[List[str]]
- current_profile: Optional[str]
+ """Response for per-game upstream profiles"""
+ default: Optional[ConfigurationData]
+ games: Optional[List[Dict[str, Any]]]
message: Optional[str]
error: Optional[str]
class ProfileResponse(BaseResponse):
- """Response for single profile operations"""
- profile_name: Optional[str]
+ """Response for a per-game upstream profile"""
+ appid: Optional[str]
+ config: Optional[ConfigurationData]
message: Optional[str]
error: Optional[str]
diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py
deleted file mode 100644
index d134337..0000000
--- a/scripts/generate_python_boilerplate.py
+++ /dev/null
@@ -1,252 +0,0 @@
-#!/usr/bin/env python3
-"""
-Generate Python boilerplate from shared_config.py
-
-This script generates repetitive Python code patterns from the canonical schema,
-reducing manual maintenance when adding/removing configuration fields.
-"""
-
-import sys
-from pathlib import Path
-
-# Add project root to path to import shared_config
-project_root = Path(__file__).parent.parent
-sys.path.insert(0, str(project_root))
-
-from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
-
-
-def get_python_type(field_type: ConfigFieldType) -> str:
- """Convert ConfigFieldType to Python type annotation"""
- type_map = {
- ConfigFieldType.BOOLEAN: "bool",
- ConfigFieldType.INTEGER: "int",
- ConfigFieldType.FLOAT: "float",
- ConfigFieldType.STRING: "str"
- }
- return type_map.get(field_type, "Any")
-
-
-def get_env_var_name(field_name: str) -> str:
- """Convert field name to environment variable name"""
- env_map = {
- "dxvk_frame_rate": "DXVK_FRAME_RATE",
- "enable_wow64": "PROTON_USE_WOW64",
- "disable_steamdeck_mode": "SteamDeck",
- "mangohud_workaround": "MANGOHUD",
- "disable_vkbasalt": "DISABLE_VKBASALT",
- "force_enable_vkbasalt": "ENABLE_VKBASALT",
- "enable_wsi": "ENABLE_GAMESCOPE_WSI",
- "enable_zink": "ZINK_ENABLE"
- }
- return env_map.get(field_name, field_name.upper())
-
-
-def generate_typed_dict() -> str:
- """Generate ConfigurationData TypedDict"""
- lines = [
- "class ConfigurationData(TypedDict):",
- " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\""
- ]
-
- for field_name, field_def in CONFIG_SCHEMA_DEF.items():
- python_type = get_python_type(ConfigFieldType(field_def["fieldType"]))
- lines.append(f" {field_name}: {python_type}")
-
- return "\n".join(lines)
-
-
-def generate_script_parsing() -> str:
- """Generate script content parsing logic"""
- lines = []
-
- script_fields = [
- (field_name, field_def)
- for field_name, field_def in CONFIG_SCHEMA_DEF.items()
- if field_def.get("location") == "script"
- ]
-
- for field_name, field_def in script_fields:
- env_var = get_env_var_name(field_name)
- field_type = ConfigFieldType(field_def["fieldType"])
-
- if field_type == ConfigFieldType.BOOLEAN:
- if field_name == "disable_steamdeck_mode":
- # Special case: SteamDeck=0 means disable_steamdeck_mode=True
- lines.append(f' elif key == "{env_var}":')
- lines.append(f' script_values["{field_name}"] = value == "0"')
- elif field_name == "enable_wsi":
- # Special case: ENABLE_GAMESCOPE_WSI=0 or DXVK_HDR=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":')
- lines.append(f' script_values["{field_name}"] = True')
- lines.append(f' elif key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink":')
- lines.append(f' script_values["{field_name}"] = True')
- lines.append(f' elif key == "GALLIUM_DRIVER" and value == "zink":')
- lines.append(f' script_values["{field_name}"] = True')
- else:
- lines.append(f' elif key == "{env_var}":')
- lines.append(f' script_values["{field_name}"] = value == "1"')
- elif field_type == ConfigFieldType.INTEGER:
- lines.append(f' elif key == "{env_var}":')
- lines.append(' try:')
- lines.append(f' script_values["{field_name}"] = int(value)')
- lines.append(' except ValueError:')
- lines.append(' pass')
- elif field_type == ConfigFieldType.FLOAT:
- lines.append(f' elif key == "{env_var}":')
- lines.append(' try:')
- lines.append(f' script_values["{field_name}"] = float(value)')
- lines.append(' except ValueError:')
- lines.append(' pass')
- elif field_type == ConfigFieldType.STRING:
- lines.append(f' elif key == "{env_var}":')
- lines.append(f' script_values["{field_name}"] = value')
-
- return "\n".join(lines)
-
-
-def generate_script_generation() -> str:
- """Generate script content generation logic"""
- lines = []
-
- script_fields = [
- (field_name, field_def)
- for field_name, field_def in CONFIG_SCHEMA_DEF.items()
- if field_def.get("location") == "script"
- ]
-
- for field_name, field_def in script_fields:
- env_var = get_env_var_name(field_name)
- field_type = ConfigFieldType(field_def["fieldType"])
-
- if field_type == ConfigFieldType.BOOLEAN:
- if field_name == "disable_steamdeck_mode":
- # Special case: disable_steamdeck_mode=True should export SteamDeck=0
- lines.append(f' if config.get("{field_name}", False):')
- lines.append(f' lines.append("export {env_var}=0")')
- elif field_name == "enable_wsi":
- # Special case: enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0 and DXVK_HDR=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):')
- lines.append(f' lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa")')
- lines.append(f' lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink")')
- lines.append(f' lines.append("export GALLIUM_DRIVER=zink")')
- else:
- lines.append(f' if config.get("{field_name}", False):')
- lines.append(f' lines.append("export {env_var}=1")')
- elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]:
- default = field_def["default"]
- if field_name == "dxvk_frame_rate":
- # Special handling for DXVK_FRAME_RATE (only export if > 0)
- lines.append(f' {field_name} = config.get("{field_name}", {default})')
- lines.append(f' if {field_name} > 0:')
- lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
- else:
- lines.append(f' {field_name} = config.get("{field_name}", {default})')
- lines.append(f' if {field_name} != {default}:')
- lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
- elif field_type == ConfigFieldType.STRING:
- lines.append(f' {field_name} = config.get("{field_name}", "")')
- lines.append(f' if {field_name}:')
- lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")')
-
- return "\n".join(lines)
-
-
-def generate_complete_schema_file() -> str:
- """Generate complete config_schema_generated.py file"""
-
- # Generate field name constants
- field_constants = []
- for field_name in CONFIG_SCHEMA_DEF.keys():
- const_name = field_name.upper()
- field_constants.append(f'{const_name} = "{field_name}"')
-
- lines = [
- '"""',
- 'Auto-generated configuration schema components from shared_config.py',
- 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build',
- '"""',
- '',
- 'from typing import TypedDict, Dict, Any, Union',
- 'from enum import Enum',
- 'import sys',
- 'from pathlib import Path',
- '',
- '# Import shared configuration constants',
- 'sys.path.insert(0, str(Path(__file__).parent.parent.parent))',
- 'from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType',
- '',
- '# Field name constants for type-safe access',
- ] + field_constants + [
- '',
- '',
- generate_typed_dict(),
- '',
- '',
- 'def get_script_parsing_logic():',
- ' """Return the script parsing logic as a callable"""',
- ' def parse_script_values(lines):',
- ' script_values = {}',
- ' for line in lines:',
- ' line = line.strip()',
- ' if not line or line.startswith("#") or not line.startswith("export "):',
- ' continue',
- ' if "=" in line:',
- ' export_line = line[len("export "):]',
- ' key, value = export_line.split("=", 1)',
- ' key = key.strip()',
- ' value = value.strip()',
- '',
- ' # Auto-generated parsing logic:',
- f'{generate_script_parsing().replace(" elif", " if")}',
- '',
- ' return script_values',
- ' return parse_script_values',
- '',
- '',
- 'def get_script_generation_logic():',
- ' """Return the script generation logic as a callable"""',
- ' def generate_script_lines(config):',
- ' lines = []',
- f'{generate_script_generation()}',
- ' return lines',
- ' return generate_script_lines',
- '',
- '',
- f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}',
- ''
- ]
-
- return '\n'.join(lines)
-
-
-def main():
- """Generate complete Python configuration files"""
- try:
- # Create generated files in py_modules/lsfg_vk/
- target_dir = project_root / "py_modules" / "lsfg_vk"
-
- # Generate the complete schema file
- schema_content = generate_complete_schema_file()
- schema_file = target_dir / "config_schema_generated.py"
- schema_file.write_text(schema_content)
- print(f"Generated {schema_file.relative_to(project_root)}")
-
- except Exception as e:
- print(f"Error generating Python files: {e}")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py
deleted file mode 100644
index 27969f1..0000000
--- a/scripts/generate_ts_schema.py
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env python3
-"""
-Generate TypeScript schema from Python shared_config.py
-
-This script reads the canonical schema from shared_config.py and generates
-the corresponding TypeScript files, ensuring single source of truth.
-"""
-
-import sys
-from pathlib import Path
-
-# Add project root to path to import shared_config
-project_root = Path(__file__).parent.parent
-sys.path.insert(0, str(project_root))
-
-from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType
-
-
-def generate_typescript_schema():
- """Generate generatedConfigSchema.ts from Python schema"""
-
- # Generate field name constants
- field_constants = []
- for field_name in CONFIG_SCHEMA_DEF.keys():
- const_name = field_name.upper()
- field_constants.append(f'export const {const_name} = "{field_name}" as const;')
-
- # Generate enum
- enum_lines = [
- "// src/config/generatedConfigSchema.ts",
- "// Configuration field type enum - matches Python",
- "export enum ConfigFieldType {",
- " BOOLEAN = \"boolean\",",
- " INTEGER = \"integer\",",
- " FLOAT = \"float\",",
- " STRING = \"string\"",
- "}",
- "",
- "// Field name constants for type-safe access",
- ] + field_constants + [
- "",
- "// Configuration field definition",
- "export interface ConfigField {",
- " name: string;",
- " fieldType: ConfigFieldType;",
- " default: boolean | number | string;",
- " description: string;",
- "}",
- "",
- "// Configuration schema - auto-generated from Python",
- "export const CONFIG_SCHEMA: Record<string, ConfigField> = {"
- ]
-
- # Generate schema entries
- schema_entries = []
- interface_fields = []
- defaults_fields = []
- field_types = []
-
- for field_name, field_def in CONFIG_SCHEMA_DEF.items():
- # Schema entry
- default_value = field_def["default"]
- if isinstance(default_value, str):
- default_str = f'"{default_value}"'
- elif isinstance(default_value, bool):
- default_str = "true" if default_value else "false"
- else:
- default_str = str(default_value)
-
- schema_entries.append(f' {field_name}: {{')
- schema_entries.append(f' name: "{field_def["name"]}",')
- schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},')
- schema_entries.append(f' default: {default_str},')
- schema_entries.append(f' description: "{field_def["description"]}"')
- schema_entries.append(' },')
-
- # Interface field
- if field_def["fieldType"] == ConfigFieldType.BOOLEAN:
- ts_type = "boolean"
- elif field_def["fieldType"] == ConfigFieldType.INTEGER:
- ts_type = "number"
- elif field_def["fieldType"] == ConfigFieldType.FLOAT:
- ts_type = "number"
- elif field_def["fieldType"] == ConfigFieldType.STRING:
- ts_type = "string"
- else:
- ts_type = "any"
-
- interface_fields.append(f' {field_name}: {ts_type};')
- defaults_fields.append(f' {field_name}: {default_str},')
- field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},')
-
- # Complete the file
- all_lines = enum_lines + schema_entries + [
- "};",
- "",
- "// Type-safe configuration data structure",
- "export interface ConfigurationData {",
- ] + interface_fields + [
- "}",
- "",
- "// Helper functions",
- "export function getFieldNames(): string[] {",
- " return Object.keys(CONFIG_SCHEMA);",
- "}",
- "",
- "export function getDefaults(): ConfigurationData {",
- " return {",
- ] + defaults_fields + [
- " };",
- "}",
- "",
- "export function getFieldTypes(): Record<string, ConfigFieldType> {",
- " return {",
- ] + field_types + [
- " };",
- "}",
- "",
- ""
- ]
-
- return "\n".join(all_lines)
-
-
-def main():
- """Main function to generate TypeScript schema and Python boilerplate"""
- try:
- # Generate the TypeScript content
- ts_content = generate_typescript_schema()
-
- # Write to the target file
- target_file = project_root / "src" / "config" / "generatedConfigSchema.ts"
- target_file.write_text(ts_content)
-
- print(f"Generated {target_file} from shared_config.py")
- print(f" Fields: {len(CONFIG_SCHEMA_DEF)}")
-
- # Also generate Python boilerplate
- print("\nGenerating Python boilerplate...")
- from pathlib import Path
- import subprocess
-
- boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py"
- result = subprocess.run([sys.executable, str(boilerplate_script)],
- capture_output=True, text=True)
-
- if result.returncode == 0:
- print(result.stdout)
- else:
- print(f"Warning: Python boilerplate generation had issues:\n{result.stderr}")
-
- except Exception as e:
- print(f"Error generating schema: {e}")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/shared_config.py b/shared_config.py
deleted file mode 100644
index bb7eea6..0000000
--- a/shared_config.py
+++ /dev/null
@@ -1,123 +0,0 @@
-from enum import Enum
-from typing import Dict, Union
-
-
-class ConfigFieldType(str, Enum):
- BOOLEAN = "boolean"
- INTEGER = "integer"
- FLOAT = "float"
- STRING = "string"
-
-
-CONFIG_SCHEMA_DEF = {
- "dll": {
- "name": "dll",
- "fieldType": ConfigFieldType.STRING,
- "default": "",
- "description": "override the lsfg-vk.dll path",
- "location": "toml",
- },
- "no_fp16": {
- "name": "no_fp16",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "description": "disable FP16 acceleration",
- "location": "toml",
- },
- "multiplier": {
- "name": "multiplier",
- "fieldType": ConfigFieldType.INTEGER,
- "default": 1,
- "description": "frame generation multiplier",
- "location": "toml",
- },
- "flow_scale": {
- "name": "flow_scale",
- "fieldType": ConfigFieldType.FLOAT,
- "default": 1.0,
- "description": "motion estimation resolution scale",
- "location": "toml",
- },
- "performance_mode": {
- "name": "performance_mode",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "description": "use the lighter frame generation model",
- "location": "toml",
- },
- "experimental_present_mode": {
- "name": "experimental_present_mode",
- "fieldType": ConfigFieldType.STRING,
- "default": "fifo",
- "description": "control the v2 present mode override",
- "location": "toml",
- },
- "dxvk_frame_rate": {
- "name": "dxvk_frame_rate",
- "fieldType": ConfigFieldType.INTEGER,
- "default": 0,
- "description": "base framerate cap for DirectX games before frame multiplier",
- "location": "script",
- },
- "enable_wow64": {
- "name": "enable_wow64",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "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",
- "location": "script",
- },
- "mangohud_workaround": {
- "name": "mangohud_workaround",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "description": "enable a transparent MangoHud overlay workaround",
- "location": "script",
- },
- "disable_vkbasalt": {
- "name": "disable_vkbasalt",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "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-enable vkBasalt",
- "location": "script",
- },
- "enable_wsi": {
- "name": "enable_wsi",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "description": "enable the Gamescope WSI layer",
- "location": "script",
- },
- "enable_zink": {
- "name": "enable_zink",
- "fieldType": ConfigFieldType.BOOLEAN,
- "default": False,
- "description": "enable Zink for OpenGL games",
- "location": "script",
- },
-}
-
-
-def get_field_names() -> list[str]:
- return list(CONFIG_SCHEMA_DEF)
-
-
-def get_defaults() -> Dict[str, Union[bool, int, float, str]]:
- return {name: definition["default"] for name, definition in CONFIG_SCHEMA_DEF.items()}
-
-
-def get_field_types() -> Dict[str, str]:
- 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 c0d6528..b38fa3b 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -44,12 +44,31 @@ export interface ConfigUpdateResult {
error?: string;
}
+export interface GameConfigEntry {
+ appid: string;
+ profile: string;
+ config: LsfgConfig;
+}
+export interface InstalledGame { appid: string; name: string; }
+export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; }
+
+export interface GameConfigsResult {
+ success: boolean;
+ default?: LsfgConfig;
+ games?: GameConfigEntry[];
+ error?: string;
+}
+
+export interface GameConfigResult extends ConfigUpdateResult {
+ appid?: string;
+ exists?: boolean;
+ config?: LsfgConfig;
+}
+
export interface ConfigSchemaResult {
field_names: string[];
field_types: Record<string, string>;
defaults: ConfigurationData;
- profiles?: string[];
- current_profile?: string;
}
export interface LaunchOptionResult {
@@ -105,22 +124,6 @@ export interface FlatpakOperationResult {
operation?: string;
}
-// Profile management interfaces
-export interface ProfilesResult {
- success: boolean;
- profiles?: string[];
- current_profile?: string;
- message?: string;
- error?: string;
-}
-
-export interface ProfileResult {
- success: boolean;
- profile_name?: string;
- message?: string;
- error?: string;
-}
-
// API functions
export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk");
export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk");
@@ -146,17 +149,14 @@ export const updateLsfgConfig = callable<
[ConfigurationData],
ConfigUpdateResult
>("update_lsfg_config");
+export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs");
+export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
+export const getGameConfig = callable<[string], GameConfigResult>("get_game_config");
+export const updateGameConfig = callable<[string, LsfgConfig], GameConfigResult>("update_game_config");
+export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config");
+export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs");
// Legacy helper function for backward compatibility
export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => {
return updateLsfgConfig(config);
};
-
-// Self-updater API functions
-// Profile management API functions
-export const getProfiles = callable<[], ProfilesResult>("get_profiles");
-export const createProfile = callable<[string, string?], ProfileResult>("create_profile");
-export const deleteProfile = callable<[string], ProfileResult>("delete_profile");
-export const renameProfile = callable<[string, string], ProfileResult>("rename_profile");
-export const setCurrentProfile = callable<[string], ProfileResult>("set_current_profile");
-export const updateProfileConfig = callable<[string, ConfigurationData], ConfigUpdateResult>("update_profile_config");
diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx
index e83bd58..c2bba7e 100644
--- a/src/components/ConfigurationSection.tsx
+++ b/src/components/ConfigurationSection.tsx
@@ -1,293 +1,28 @@
-import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui";
-import { useState, useEffect } from "react";
-import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
-import {
- FLOW_SCALE, NO_FP16, PERFORMANCE_MODE,
- EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE,
- MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK
-} from "../config/generatedConfigSchema";
-import t from '../i18n/i18n';
+import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema";
interface ConfigurationSectionProps {
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>;
+ onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
}
-const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed";
-const CONFIG_COLLAPSED_KEY = "lsfg-config-collapsed";
-
-export function ConfigurationSection({
- config,
- onConfigChange
-}: ConfigurationSectionProps) {
- // Initialize with localStorage value, fallback to true if not found
- const [configCollapsed, setConfigCollapsed] = useState(() => {
- try {
- const saved = localStorage.getItem(CONFIG_COLLAPSED_KEY);
- return saved !== null ? JSON.parse(saved) : false;
- } catch {
- return false;
- }
- });
-
- const [workaroundsCollapsed, setWorkaroundsCollapsed] = useState(() => {
- try {
- const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY);
- return saved !== null ? JSON.parse(saved) : true;
- } catch {
- return true;
- }
- });
-
- // Persist workarounds collapse state to localStorage
- useEffect(() => {
- try {
- localStorage.setItem(CONFIG_COLLAPSED_KEY, JSON.stringify(configCollapsed));
- } catch (error) {
- console.warn("Failed to save config collapse state:", error);
- }
- }, [configCollapsed]);
-
- useEffect(() => {
- try {
- localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(workaroundsCollapsed));
- } catch (error) {
- console.warn("Failed to save workarounds collapse state:", error);
- }
- }, [workaroundsCollapsed]);
-
- return (
- <>
- <style>
- {`
- .LSFG_ConfigCollapseButton_Container > div > div > div > button,
- .LSFG_ConfigCollapseButton_Container > div > div > div > div > button,
- .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button {
- height: 10px !important;
- }
- .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button {
- height: 10px !important;
- }
- `}
- </style>
-
- {/* Config Section */}
- <PanelSectionRow>
- <div
- style={{
- fontSize: "14px",
- fontWeight: "bold",
- marginTop: "8px",
- marginBottom: "6px",
- borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
- paddingBottom: "3px",
- color: "white"
- }}
- >
- {t('CONFIG_SECTION_TITLE', 'Config')}
- </div>
- </PanelSectionRow>
-
- <PanelSectionRow>
- <div
- className="LSFG_ConfigCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={configCollapsed ? "standard" : "none"}
- onClick={() => setConfigCollapsed(!configCollapsed)}
- >
- {configCollapsed ? (
- <RiArrowDownSFill
- style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }}
- />
- ) : (
- <RiArrowUpSFill
- style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }}
- />
- )}
- </ButtonItem>
- </div>
- </PanelSectionRow>
-
- {!configCollapsed && (
- <>
- <PanelSectionRow>
- <SliderField
- label={`${t('CONFIG_FLOW_SCALE', 'Flow Scale')} (${Math.round(config.flow_scale * 100)}%)`}
- description={t('CONFIG_FLOW_SCALE_DESC', 'Lowers internal motion estimation resolution, improving performance slightly')}
- value={config.flow_scale}
- min={0.25}
- max={1.0}
- step={0.01}
- onChange={(value) => onConfigChange(FLOW_SCALE, value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label="FP16 Acceleration"
- description="Use FP16 shaders when supported"
- checked={!config.no_fp16}
- onChange={(value) => onConfigChange(NO_FP16, !value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <SliderField
- label={`${t('CONFIG_BASE_FPS_CAP', 'Base FPS Cap')}${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : ` (${t('CONFIG_BASE_FPS_CAP_OFF', 'Off')})`}`}
- description={t('CONFIG_BASE_FPS_CAP_DESC', 'Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)')}
- value={config.dxvk_frame_rate}
- min={0}
- max={60}
- step={1}
- onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={`${t('CONFIG_PRESENT_MODE', 'Present Mode Override')} (${(config.experimental_present_mode || "fifo") === "fifo" ? t('CONFIG_PRESENT_MODE_FIFO', 'FIFO - VSync') : 'App Default'})`}
- description={t('CONFIG_PRESENT_MODE_DESC', 'Force FIFO/VSync for v2 frame pacing, or leave the game present mode unchanged')}
- checked={(config.experimental_present_mode || "fifo") === "fifo"}
- onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_PERFORMANCE_MODE', 'Performance Mode')}
- description={t('CONFIG_PERFORMANCE_MODE_DESC', 'Uses a lighter model for FG (Recommended for most games)')}
- checked={config.performance_mode}
- onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)}
- />
- </PanelSectionRow>
-
- </>
- )}
-
- {/* Workarounds Section */}
- <PanelSectionRow>
- <div
- style={{
- fontSize: "14px",
- fontWeight: "bold",
- marginTop: "8px",
- marginBottom: "6px",
- borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
- paddingBottom: "3px",
- color: "white"
- }}
- >
- {t('CONFIG_WORKAROUNDS_TITLE', 'Workarounds')}
- </div>
- </PanelSectionRow>
-
- <PanelSectionRow>
- <div
- className="LSFG_WorkaroundsCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={workaroundsCollapsed ? "standard" : "none"}
- onClick={() => setWorkaroundsCollapsed(!workaroundsCollapsed)}
- >
- {workaroundsCollapsed ? (
- <RiArrowDownSFill
- style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }}
- />
- ) : (
- <RiArrowUpSFill
- style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }}
- />
- )}
- </ButtonItem>
- </div>
- </PanelSectionRow>
-
- {!workaroundsCollapsed && (
- <>
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_ENABLE_WSI', 'Enable WSI')}
- description={t('CONFIG_ENABLE_WSI_DESC', 'Re-Enable Gamescope WSI Layer. Requires game restart to apply.')}
- checked={config.enable_wsi}
- onChange={(value) => onConfigChange(ENABLE_WSI, value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_ENABLE_WOW64', 'Enable WOW64 for 32-bit games')}
- description={t('CONFIG_ENABLE_WOW64_DESC', 'Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)')}
- checked={config.enable_wow64}
- onChange={(value) => onConfigChange('enable_wow64', value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_DISABLE_STEAMDECK_MODE', 'Disable Steam Deck Mode')}
- description={t('CONFIG_DISABLE_STEAMDECK_MODE_DESC', 'Disables Steam Deck mode (Unlocks hidden settings in some games)')}
- checked={config.disable_steamdeck_mode}
- onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_MANGOHUD_WORKAROUND', 'MangoHud Workaround')}
- description={t('CONFIG_MANGOHUD_WORKAROUND_DESC', 'Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode')}
- checked={config.mangohud_workaround}
- onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_DISABLE_VKBASALT', 'Disable vkBasalt')}
- description={t('CONFIG_DISABLE_VKBASALT_DESC', 'Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)')}
- checked={config.disable_vkbasalt}
- disabled={config.force_enable_vkbasalt}
- onChange={(value) => {
- if (value && config.force_enable_vkbasalt) {
- // Turn off force enable when enabling disable
- onConfigChange(FORCE_ENABLE_VKBASALT, false);
- }
- onConfigChange(DISABLE_VKBASALT, value);
- }}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_FORCE_ENABLE_VKBASALT', 'Force Enable vkBasalt')}
- description={t('CONFIG_FORCE_ENABLE_VKBASALT_DESC', 'Force vkBasalt to engage to fix framepacing issues in gamemode')}
- checked={config.force_enable_vkbasalt}
- disabled={config.disable_vkbasalt}
- onChange={(value) => {
- if (value && config.disable_vkbasalt) {
- // Turn off disable when enabling force enable
- onConfigChange(DISABLE_VKBASALT, false);
- }
- onConfigChange(FORCE_ENABLE_VKBASALT, value);
- }}
- />
- </PanelSectionRow>
-
- <PanelSectionRow>
- <ToggleField
- label={t('CONFIG_ENABLE_ZINK', 'Enable Zink for OpenGL Games')}
- description={t('CONFIG_ENABLE_ZINK_DESC', 'Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)')}
- checked={config.enable_zink}
- onChange={(value) => onConfigChange(ENABLE_ZINK, value)}
- />
- </PanelSectionRow>
- </>
- )}
- </>
- );
+export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) {
+ return <>
+ <PanelSectionRow>
+ <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} description="Motion estimation resolution scale" value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField label="FP16 Acceleration" description="Use FP16 shaders when supported" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField label="Performance Mode" description="Use the lighter frame generation model" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField label="Present Mode Override" description="Force FIFO/VSync pacing" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ToggleField label="Preserve Swapchain Image Count" description="Do not change the application's swapchain image count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} />
+ </PanelSectionRow>
+ </>;
}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 01f94c9..bdb3a04 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,12 +1,12 @@
import { useEffect } from "react";
import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui";
-import { useInstallationStatus, useLsfgConfig } from "../hooks/useLsfgHooks";
-import { useProfileManagement } from "../hooks/useProfileManagement";
+import { useInstallationStatus } from "../hooks/useLsfgHooks";
+import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallationActions } from "../hooks/useInstallationActions";
import { StatusDisplay } from "./StatusDisplay";
import { InstallationButton } from "./InstallationButton";
import { ConfigurationSection } from "./ConfigurationSection";
-import { ProfileManagement } from "./ProfileManagement";
+import { GameConfigurationSelector } from "./GameConfigurationSelector";
import { UsageInstructions } from "./UsageInstructions";
import { SmartClipboardButton } from "./SmartClipboardButton";
import { FgmodClipboardButton } from "./FgmodClipboardButton";
@@ -28,40 +28,20 @@ export function Content() {
checkInstallation
} = useInstallationStatus();
- const {
- config,
- loadLsfgConfig,
- updateField
- } = useLsfgConfig();
-
- const {
- currentProfile,
- updateProfileConfig,
- loadProfiles
- } = useProfileManagement();
+ const { config, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload } = useGameConfiguration();
const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions();
useEffect(() => {
- if (isInstalled) {
- loadLsfgConfig();
- }
- }, [isInstalled, loadLsfgConfig]);
-
- const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => {
- if (currentProfile) {
- const newConfig = { ...config, [fieldName]: value };
- const result = await updateProfileConfig(currentProfile, newConfig);
- if (result.success) {
- await loadLsfgConfig();
- }
- } else {
- await updateField(fieldName, value);
- }
+ if (isInstalled) void reload();
+ }, [isInstalled, reload]);
+
+ const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => {
+ await save({ ...config, [fieldName]: value });
};
const onInstall = () => {
- handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig, checkInstallation);
+ handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation);
};
const onUninstall = () => {
@@ -124,13 +104,7 @@ export function Content() {
)}
{isInstalled && (
- <ProfileManagement
- currentProfile={currentProfile}
- onProfileChange={async () => {
- await loadProfiles();
- await loadLsfgConfig();
- }}
- />
+ <GameConfigurationSelector targets={targets} runningGame={runningGame} selectedAppId={selectedAppId} onSelect={setSelectedAppId} onReset={resetSelected} onResetAll={resetAll} />
)}
{isInstalled && (
diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx
index 206643e..5069c9a 100644
--- a/src/components/FpsMultiplierControl.tsx
+++ b/src/components/FpsMultiplierControl.tsx
@@ -1,11 +1,11 @@
import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { MULTIPLIER } from "../config/generatedConfigSchema";
-import t from '../i18n/i18n';
+import t from "../i18n/i18n";
interface FpsMultiplierControlProps {
config: ConfigurationData;
- onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>;
+ onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
}
export function FpsMultiplierControl({
@@ -50,7 +50,7 @@ export function FpsMultiplierControl({
textAlign: "center"
}}
>
- {config.multiplier < 2 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`}
+ {config.multiplier === 1 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`}
</div>
<DialogButton
style={{
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
new file mode 100644
index 0000000..8a0df6c
--- /dev/null
+++ b/src/components/GameConfigurationSelector.tsx
@@ -0,0 +1,29 @@
+import { Dropdown, DropdownOption, PanelSectionRow, ButtonItem } from "@decky/ui";
+import { GameTarget } from "../hooks/useGameConfiguration";
+
+interface Props {
+ targets: GameTarget[];
+ runningGame: GameTarget | null;
+ selectedAppId: string;
+ onSelect: (appid: string) => void;
+ onReset: () => Promise<void>;
+ onResetAll: () => Promise<void>;
+}
+
+export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) {
+ const options: DropdownOption[] = [
+ { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" },
+ ...targets.map((target) => ({ data: target.appid, label: `${target.name} · ${target.appid}` })),
+ ];
+ return <>
+ <PanelSectionRow>
+ <Dropdown rgOptions={options} selectedOption={selectedAppId} onChange={(option) => onSelect(String(option.data))} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => void onReset()} disabled={!selectedAppId}>Reset selected game</ButtonItem>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => void onResetAll()} disabled={!targets.some((target) => target.configured)}>Reset all game profiles</ButtonItem>
+ </PanelSectionRow>
+ </>;
+}
diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx
deleted file mode 100644
index 626d54c..0000000
--- a/src/components/ProfileManagement.tsx
+++ /dev/null
@@ -1,477 +0,0 @@
-import { useState, useEffect } from "react";
-import {
- PanelSectionRow,
- Dropdown,
- DropdownOption,
- showModal,
- ConfirmModal,
- Field,
- DialogButton,
- ButtonItem,
- ModalRoot,
- TextField,
- Focusable,
- AppOverview,
- Router
-} from "@decky/ui";
-import { RiArrowDownSFill, RiArrowUpSFill, RiEditLine, RiDeleteBinLine } from "react-icons/ri";
-import {
- getProfiles,
- createProfile,
- deleteProfile,
- renameProfile,
- setCurrentProfile,
- ProfilesResult,
- ProfileResult
-} from "../api/lsfgApi";
-import { showSuccessToast, showErrorToast } from "../utils/toastUtils";
-import t from '../i18n/i18n';
-
-const PROFILES_COLLAPSED_KEY = 'lsfg-profiles-collapsed';
-
-interface TextInputModalProps {
- title: string;
- description: string;
- defaultValue?: string;
- okText?: string;
- cancelText?: string;
- onOK: (value: string) => void;
- closeModal?: () => void;
-}
-
-function TextInputModal({
- title,
- description,
- defaultValue = "",
- okText = "OK",
- cancelText = "Cancel",
- onOK,
- closeModal
-}: TextInputModalProps) {
- const [value, setValue] = useState(defaultValue);
-
- const handleOK = () => {
- if (value.trim()) {
- onOK(value);
- closeModal?.();
- }
- };
-
- return (
- <ModalRoot>
- <div style={{ padding: "16px", minWidth: "400px" }}>
- <h2 style={{ marginBottom: "16px" }}>{title}</h2>
- <p style={{ marginBottom: "24px" }}>{description}</p>
-
- <div style={{ marginBottom: "24px" }}>
- <Field
- label={t('PROFILE_NAME_LABEL', 'Name')}
- childrenLayout="below"
- childrenContainerWidth="max"
- >
- <TextField
- value={value}
- onChange={(e) => setValue(e?.target?.value || "")}
- style={{ width: "100%" }}
- />
- </Field>
- </div>
-
- <Focusable
- style={{
- display: "flex",
- justifyContent: "flex-end",
- gap: "8px",
- marginTop: "16px"
- }}
- flow-children="horizontal"
- >
- <DialogButton onClick={closeModal}>
- {cancelText}
- </DialogButton>
- <DialogButton
- onClick={handleOK}
- disabled={!value.trim()}
- >
- {okText}
- </DialogButton>
- </Focusable>
- </div>
- </ModalRoot>
- );
-}
-
-interface ProfileManagementProps {
- currentProfile?: string;
- onProfileChange?: (profileName: string) => void;
-}
-
-export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) {
- const [profiles, setProfiles] = useState<string[]>([]);
- const [selectedProfile, setSelectedProfile] = useState<string>(currentProfile || "decky-lsfg-vk");
- const [isLoading, setIsLoading] = useState(false);
- const [mainRunningApp, setMainRunningApp] = useState<AppOverview | undefined>(undefined);
-
- // Initialize with localStorage value, fallback to false (expanded) if not found
- const [profilesCollapsed, setProfilesCollapsed] = useState(() => {
- try {
- const saved = localStorage.getItem(PROFILES_COLLAPSED_KEY);
- return saved !== null ? JSON.parse(saved) : false;
- } catch {
- return false;
- }
- });
-
- // Persist profiles collapse state to localStorage
- useEffect(() => {
- try {
- localStorage.setItem(PROFILES_COLLAPSED_KEY, JSON.stringify(profilesCollapsed));
- } catch (error) {
- console.warn('Failed to save profiles collapse state:', error);
- }
- }, [profilesCollapsed]);
-
- // Load profiles on component mount
- useEffect(() => {
- loadProfiles();
- }, []);
-
- // Update selected profile when prop changes
- useEffect(() => {
- if (currentProfile) {
- setSelectedProfile(currentProfile);
- }
- }, [currentProfile]);
-
- // Poll for running app every 2 seconds
- useEffect(() => {
- const checkRunningApp = () => {
- setMainRunningApp(Router.MainRunningApp);
- };
-
- // Check immediately
- checkRunningApp();
-
- // Set up polling interval
- const interval = setInterval(checkRunningApp, 2000);
-
- // Cleanup interval on unmount
- return () => clearInterval(interval);
- }, []);
-
- const loadProfiles = async () => {
- try {
- const result: ProfilesResult = await getProfiles();
- if (result.success && result.profiles) {
- setProfiles(result.profiles);
- if (result.current_profile) {
- setSelectedProfile(result.current_profile);
- }
- } else {
- console.error("Failed to load profiles:", result.error);
- showErrorToast("Failed to load profiles", result.error || "Unknown error");
- }
- } catch (error) {
- console.error("Error loading profiles:", error);
- showErrorToast("Error loading profiles", String(error));
- }
- };
-
- const handleProfileChange = async (profileName: string) => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await setCurrentProfile(profileName);
- if (result.success) {
- setSelectedProfile(profileName);
- showSuccessToast("Profile switched", `Switched to profile: ${profileName}`);
- onProfileChange?.(profileName);
- } else {
- console.error("Failed to switch profile:", result.error);
- showErrorToast("Failed to switch profile", result.error || "Unknown error");
- }
- } catch (error) {
- console.error("Error switching profile:", error);
- showErrorToast("Error switching profile", String(error));
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleCreateProfile = () => {
- showModal(
- <TextInputModal
- title={t('PROFILE_CREATE_TITLE', 'Create New Profile')}
- description={t('PROFILE_CREATE_DESC', "Enter a name for the new profile. The current profile's settings will be copied.")}
- okText={t('PROFILE_CREATE_BTN', 'Create')}
- cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')}
- onOK={(name: string) => {
- if (name.trim()) {
- createNewProfile(name.trim());
- }
- }}
- />
- );
- };
-
- const createNewProfile = async (profileName: string) => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await createProfile(profileName, selectedProfile);
- if (result.success) {
- // Use the normalized name returned from backend (spaces converted to dashes)
- const actualProfileName = result.profile_name || profileName;
- showSuccessToast("Profile created", `Created profile: ${actualProfileName}`);
- await loadProfiles();
- // Automatically switch to the newly created profile using the normalized name
- await handleProfileChange(actualProfileName);
- } else {
- console.error("Failed to create profile:", result.error);
- showErrorToast("Failed to create profile", result.error || "Unknown error");
- }
- } catch (error) {
- console.error("Error creating profile:", error);
- showErrorToast("Error creating profile", String(error));
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleDeleteProfile = () => {
- if (selectedProfile === "decky-lsfg-vk") {
- showErrorToast(t('PROFILE_CANNOT_DELETE_TITLE', 'Cannot delete default profile'), t('PROFILE_CANNOT_DELETE_MSG', 'The default profile cannot be deleted'));
- return;
- }
-
- showModal(
- <ConfirmModal
- strTitle={t('PROFILE_DELETE_TITLE', 'Delete Profile')}
- strDescription={`${t('PROFILE_DELETE_DESC_PREFIX', 'Are you sure you want to delete the profile')} "${selectedProfile}"${t('PROFILE_DELETE_DESC_SUFFIX', '? This action cannot be undone.')}`}
- strOKButtonText={t('PROFILE_DELETE_BTN', 'Delete')}
- strCancelButtonText={t('PROFILE_CANCEL_BTN', 'Cancel')}
- onOK={() => deleteSelectedProfile()}
- />
- );
- };
-
- const deleteSelectedProfile = async () => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await deleteProfile(selectedProfile);
- if (result.success) {
- showSuccessToast("Profile deleted", `Deleted profile: ${selectedProfile}`);
- await loadProfiles();
- // If we deleted the current profile, it should have switched to default
- setSelectedProfile("decky-lsfg-vk");
- onProfileChange?.("decky-lsfg-vk");
- } else {
- console.error("Failed to delete profile:", result.error);
- showErrorToast("Failed to delete profile", result.error || "Unknown error");
- }
- } catch (error) {
- console.error("Error deleting profile:", error);
- showErrorToast("Error deleting profile", String(error));
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleDropdownChange = (option: DropdownOption) => {
- if (option.data === "__NEW_PROFILE__") {
- handleCreateProfile();
- } else {
- handleProfileChange(option.data);
- }
- };
-
- const handleRenameProfile = () => {
- if (selectedProfile === "decky-lsfg-vk") {
- showErrorToast(t('PROFILE_CANNOT_RENAME_TITLE', 'Cannot rename default profile'), t('PROFILE_CANNOT_RENAME_MSG', 'The default profile cannot be renamed'));
- return;
- }
-
- showModal(
- <TextInputModal
- title={t('PROFILE_RENAME_TITLE', 'Rename Profile')}
- description={`${t('PROFILE_RENAME_DESC_PREFIX', 'Enter a new name for the profile')} "${selectedProfile}".`}
- defaultValue={selectedProfile}
- okText={t('PROFILE_RENAME_BTN', 'Rename')}
- cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')}
- onOK={(newName: string) => {
- if (newName.trim() && newName.trim() !== selectedProfile) {
- renameSelectedProfile(newName.trim());
- }
- }}
- />
- );
- };
-
- const renameSelectedProfile = async (newName: string) => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await renameProfile(selectedProfile, newName);
- if (result.success) {
- // Use the normalized name returned from backend (spaces converted to dashes)
- const actualNewName = result.profile_name || newName;
- showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`);
- await loadProfiles();
- setSelectedProfile(actualNewName);
- onProfileChange?.(actualNewName);
- } else {
- console.error("Failed to rename profile:", result.error);
- showErrorToast("Failed to rename profile", result.error || "Unknown error");
- }
- } catch (error) {
- console.error("Error renaming profile:", error);
- showErrorToast("Error renaming profile", String(error));
- } finally {
- setIsLoading(false);
- }
- };
-
- const profileOptions: DropdownOption[] = [
- ...profiles.map((profile: string) => ({
- data: profile,
- label: profile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : profile
- })),
- {
- data: "__NEW_PROFILE__",
- label: t('PROFILE_NEW', 'New Profile')
- }
- ];
-
- return (
- <>
- <style>
- {`
- .LSFG_ProfilesCollapseButton_Container > div > div > div > button {
- height: 10px !important;
- }
- .LSFG_ProfilesCollapseButton_Container > div > div > div > div > button {
- height: 10px !important;
- }
- `}
- </style>
-
- {/* Display currently running game info - always visible */}
- {mainRunningApp && (
- <PanelSectionRow>
- <div style={{
- padding: "8px 12px",
- backgroundColor: "rgba(0, 255, 0, 0.1)",
- borderRadius: "4px",
- border: "1px solid rgba(0, 255, 0, 0.3)",
- fontSize: "13px"
- }}>
- <strong>{mainRunningApp.display_name}</strong> running. {t('PROFILE_CLOSE_GAME', 'Close game to change profile.')}
- </div>
- </PanelSectionRow>
- )}
-
- <PanelSectionRow>
- <div
- style={{
- fontSize: "14px",
- fontWeight: "bold",
- marginTop: "8px",
- marginBottom: "6px",
- borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
- paddingBottom: "3px",
- color: "white"
- }}
- >
- {t('PROFILE_SECTION_TITLE', 'Profile:')} {selectedProfile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : selectedProfile}
- </div>
- </PanelSectionRow>
-
- <PanelSectionRow>
- <div
- className="LSFG_ProfilesCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={profilesCollapsed ? "standard" : "none"}
- onClick={() => setProfilesCollapsed(!profilesCollapsed)}
- >
- {profilesCollapsed ? (
- <RiArrowDownSFill
- style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }}
- />
- ) : (
- <RiArrowUpSFill
- style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }}
- />
- )}
- </ButtonItem>
- </div>
- </PanelSectionRow>
-
- {!profilesCollapsed && (
- <>
- <PanelSectionRow>
- <Field
- label=""
- childrenLayout="below"
- childrenContainerWidth="max"
- bottomSeparator="none"
- >
- <Dropdown
- rgOptions={profileOptions}
- selectedOption={selectedProfile}
- onChange={handleDropdownChange}
- disabled={isLoading || !!mainRunningApp}
- />
- </Field>
- </PanelSectionRow>
-
- <PanelSectionRow>
- <Focusable
- style={{
- display: "flex",
- alignItems: "center",
- gap: "8px",
- width: "100%",
- padding: "0",
- margin: "0",
- marginTop: "8px"
- }}
- flow-children="horizontal"
- >
- <DialogButton
- style={{
- height: "40px",
- flex: 1,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: "10px",
- minWidth: "0",
- }}
- onClick={handleRenameProfile}
- disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp}
- >
- <RiEditLine size={20} />
- </DialogButton>
-
- <DialogButton
- style={{
- height: "40px",
- flex: 1,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: "10px",
- minWidth: "0",
- }}
- onClick={handleDeleteProfile}
- disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp}
- >
- <RiDeleteBinLine size={20} />
- </DialogButton>
- </Focusable>
- </PanelSectionRow>
- </>
- )}
- </>
- );
-}
diff --git a/src/components/index.ts b/src/components/index.ts
index bec45ae..4284aee 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -8,4 +8,4 @@ export { SmartClipboardButton } from "./SmartClipboardButton";
export { FgmodClipboardButton } from "./FgmodClipboardButton";
export { NerdStuffModal } from "./NerdStuffModal";
export { FlatpaksModal } from "./FlatpaksModal";
-export { ProfileManagement } from "./ProfileManagement";
+export { GameConfigurationSelector } from "./GameConfigurationSelector";
diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts
index befbd8d..bd72e8c 100644
--- a/src/config/configSchema.ts
+++ b/src/config/configSchema.ts
@@ -6,8 +6,6 @@ export {
getFieldNames,
getDefaults,
getFieldTypes,
- DLL, NO_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE,
- EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64,
- DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT,
- FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK
+ DLL, NO_FP16, ACTIVE_IN, PACING_MODE, MULTIPLIER, FLOW_SCALE,
+ PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT
} from './generatedConfigSchema';
diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts
index edbde88..71459eb 100644
--- a/src/config/generatedConfigSchema.ts
+++ b/src/config/generatedConfigSchema.ts
@@ -1,182 +1,31 @@
-// src/config/generatedConfigSchema.ts
-// Configuration field type enum - matches Python
-export enum ConfigFieldType {
- BOOLEAN = "boolean",
- INTEGER = "integer",
- FLOAT = "float",
- STRING = "string"
-}
+export enum ConfigFieldType { BOOLEAN = "boolean", INTEGER = "integer", FLOAT = "float", STRING = "string", ARRAY = "array" }
-// Field name constants for type-safe access
export const DLL = "dll" as const;
export const NO_FP16 = "no_fp16" as const;
+export const ACTIVE_IN = "active_in" as const;
+export const PACING_MODE = "pacing_mode" 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 EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const;
-export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const;
-export const ENABLE_WOW64 = "enable_wow64" as const;
-export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const;
-export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const;
-export const DISABLE_VKBASALT = "disable_vkbasalt" as const;
-export const FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" as const;
-export const ENABLE_WSI = "enable_wsi" as const;
-export const ENABLE_ZINK = "enable_zink" as const;
+export const OVERRIDE_PRESENT_MODE = "override_present_mode" as const;
+export const PRESERVE_SWAPCHAIN_IMAGE_COUNT = "preserve_swapchain_image_count" as const;
-// Configuration field definition
-export interface ConfigField {
- name: string;
- fieldType: ConfigFieldType;
- default: boolean | number | string;
- description: string;
+export interface ConfigField { name: string; fieldType: ConfigFieldType; default: boolean | number | string | string[]; description: string; }
+export interface ConfigurationData {
+ dll: string; no_fp16: boolean; active_in: string[]; pacing_mode: string; multiplier: number;
+ flow_scale: number; performance_mode: boolean; override_present_mode: boolean; preserve_swapchain_image_count: boolean;
}
-
-// Configuration schema - auto-generated from Python
export const CONFIG_SCHEMA: Record<string, ConfigField> = {
- dll: {
- name: "dll",
- fieldType: ConfigFieldType.STRING,
- default: "",
- description: "override the lsfg-vk.dll path"
- },
- no_fp16: {
- name: "no_fp16",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "disable FP16 acceleration"
- },
- multiplier: {
- name: "multiplier",
- fieldType: ConfigFieldType.INTEGER,
- default: 1,
- description: "frame generation multiplier"
- },
- flow_scale: {
- name: "flow_scale",
- fieldType: ConfigFieldType.FLOAT,
- default: 1,
- description: "motion estimation resolution scale"
- },
- performance_mode: {
- name: "performance_mode",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "use the lighter frame generation model"
- },
- experimental_present_mode: {
- name: "experimental_present_mode",
- fieldType: ConfigFieldType.STRING,
- default: "fifo",
- description: "control the v2 present mode override"
- },
- dxvk_frame_rate: {
- name: "dxvk_frame_rate",
- fieldType: ConfigFieldType.INTEGER,
- default: 0,
- description: "base framerate cap for DirectX games before frame multiplier"
- },
- enable_wow64: {
- name: "enable_wow64",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- 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"
- },
- mangohud_workaround: {
- name: "mangohud_workaround",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "enable a transparent MangoHud overlay workaround"
- },
- disable_vkbasalt: {
- name: "disable_vkbasalt",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- 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-enable vkBasalt"
- },
- enable_wsi: {
- name: "enable_wsi",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "enable the Gamescope WSI layer"
- },
- enable_zink: {
- name: "enable_zink",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "enable Zink for OpenGL games"
- },
+ dll: { name: "dll", fieldType: ConfigFieldType.STRING, default: "", description: "Override the lsfg-vk.dll path" },
+ no_fp16: { name: "no_fp16", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Disable FP16 acceleration" },
+ active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" },
+ pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" },
+ multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" },
+ flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 1, description: "Motion estimation resolution scale" },
+ performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" },
+ override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" },
+ preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" },
};
-
-// Type-safe configuration data structure
-export interface ConfigurationData {
- dll: string;
- no_fp16: boolean;
- multiplier: number;
- flow_scale: number;
- performance_mode: boolean;
- experimental_present_mode: string;
- dxvk_frame_rate: number;
- enable_wow64: boolean;
- disable_steamdeck_mode: boolean;
- mangohud_workaround: boolean;
- disable_vkbasalt: boolean;
- force_enable_vkbasalt: boolean;
- enable_wsi: boolean;
- enable_zink: boolean;
-}
-
-// Helper functions
-export function getFieldNames(): string[] {
- return Object.keys(CONFIG_SCHEMA);
-}
-
-export function getDefaults(): ConfigurationData {
- return {
- dll: "",
- no_fp16: false,
- multiplier: 1,
- flow_scale: 1,
- performance_mode: false,
- experimental_present_mode: "fifo",
- dxvk_frame_rate: 0,
- enable_wow64: false,
- disable_steamdeck_mode: false,
- mangohud_workaround: false,
- disable_vkbasalt: false,
- force_enable_vkbasalt: false,
- enable_wsi: false,
- enable_zink: false,
- };
-}
-
-export function getFieldTypes(): Record<string, ConfigFieldType> {
- return {
- dll: ConfigFieldType.STRING,
- no_fp16: ConfigFieldType.BOOLEAN,
- multiplier: ConfigFieldType.INTEGER,
- flow_scale: ConfigFieldType.FLOAT,
- performance_mode: ConfigFieldType.BOOLEAN,
- experimental_present_mode: ConfigFieldType.STRING,
- dxvk_frame_rate: ConfigFieldType.INTEGER,
- enable_wow64: ConfigFieldType.BOOLEAN,
- disable_steamdeck_mode: ConfigFieldType.BOOLEAN,
- mangohud_workaround: ConfigFieldType.BOOLEAN,
- disable_vkbasalt: ConfigFieldType.BOOLEAN,
- force_enable_vkbasalt: ConfigFieldType.BOOLEAN,
- enable_wsi: ConfigFieldType.BOOLEAN,
- enable_zink: ConfigFieldType.BOOLEAN,
- };
-}
-
+export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); }
+export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 1, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; }
+export function getFieldTypes(): Record<string, ConfigFieldType> { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); }
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
new file mode 100644
index 0000000..e0ba360
--- /dev/null
+++ b/src/hooks/useGameConfiguration.ts
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Router } from "@decky/ui";
+import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi";
+import { ConfigurationData, getDefaults } from "../config/configSchema";
+
+export interface GameTarget { appid: string; name: string; configured: boolean; }
+
+export function useGameConfiguration() {
+ const [defaultConfig, setDefaultConfig] = useState<ConfigurationData>(getDefaults());
+ const [games, setGames] = useState<GameConfigEntry[]>([]);
+ const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]);
+ const [selectedAppId, setSelectedAppId] = useState("");
+ const [runningGame, setRunningGame] = useState<GameTarget | null>(null);
+ const autoSelected = useRef(false);
+
+ const load = useCallback(async () => {
+ const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]);
+ if (result.success) {
+ setDefaultConfig(result.default || getDefaults());
+ setGames(result.games || []);
+ }
+ if (installed.success) setInstalledGames(installed.games || []);
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+ useEffect(() => {
+ const poll = () => {
+ const app = Router.MainRunningApp as any;
+ if (app?.appid) setRunningGame({ appid: String(app.appid), name: app.display_name || `App ${app.appid}`, configured: games.some((game) => game.appid === String(app.appid)) });
+ else setRunningGame(null);
+ };
+ poll();
+ const interval = window.setInterval(poll, 2000);
+ return () => window.clearInterval(interval);
+ }, [games]);
+ useEffect(() => {
+ if (!autoSelected.current && runningGame) {
+ autoSelected.current = true;
+ setSelectedAppId(runningGame.appid);
+ }
+ }, [runningGame]);
+
+ const targets = useMemo<GameTarget[]>(() => {
+ const configured = installedGames.map((game) => ({ appid: game.appid, name: game.name, configured: games.some((item) => item.appid === game.appid) }));
+ for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: `App ${game.appid}`, configured: true });
+ if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
+ return configured;
+ }, [games, installedGames, runningGame]);
+ const selected = selectedAppId ? games.find((game) => game.appid === selectedAppId)?.config : defaultConfig;
+ const config = selected || defaultConfig;
+
+ const save = useCallback(async (next: ConfigurationData) => {
+ if (!selectedAppId) {
+ const result = await updateLsfgConfig(next);
+ if (result.success) setDefaultConfig(next);
+ return;
+ }
+ const result = await updateGameConfig(selectedAppId, next);
+ if (result.success) await load();
+ }, [load, selectedAppId]);
+
+ const resetSelected = useCallback(async () => {
+ if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); }
+ }, [load, selectedAppId]);
+ const resetAll = useCallback(async () => { await resetAllGameConfigs(); setSelectedAppId(""); await load(); }, [load]);
+
+ return { config, defaultConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload: load };
+}
diff --git a/src/hooks/useProfileManagement.ts b/src/hooks/useProfileManagement.ts
deleted file mode 100644
index a5f2a07..0000000
--- a/src/hooks/useProfileManagement.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { useState, useEffect, useCallback } from "react";
-import {
- getProfiles,
- createProfile,
- deleteProfile,
- renameProfile,
- setCurrentProfile,
- updateProfileConfig,
- type ProfilesResult,
- type ProfileResult,
- type ConfigUpdateResult
-} from "../api/lsfgApi";
-import { ConfigurationData } from "../config/configSchema";
-import { showSuccessToast, showErrorToast } from "../utils/toastUtils";
-
-export function useProfileManagement() {
- const [profiles, setProfiles] = useState<string[]>([]);
- const [currentProfile, setCurrentProfileState] = useState<string>("decky-lsfg-vk");
- const [isLoading, setIsLoading] = useState(false);
-
- // Load profiles on hook initialization
- const loadProfiles = useCallback(async () => {
- try {
- const result: ProfilesResult = await getProfiles();
- if (result.success && result.profiles) {
- setProfiles(result.profiles);
- if (result.current_profile) {
- setCurrentProfileState(result.current_profile);
- }
- return result;
- } else {
- console.error("Failed to load profiles:", result.error);
- showErrorToast("Failed to load profiles", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error loading profiles:", error);
- showErrorToast("Error loading profiles", String(error));
- return { success: false, error: String(error) };
- }
- }, []);
-
- // Create a new profile
- const handleCreateProfile = useCallback(async (profileName: string, sourceProfile?: string) => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await createProfile(profileName, sourceProfile || currentProfile);
- if (result.success) {
- // Use the normalized name returned from backend (spaces converted to dashes)
- const actualProfileName = result.profile_name || profileName;
- showSuccessToast("Profile created", `Created profile: ${actualProfileName}`);
- await loadProfiles();
- return result;
- } else {
- console.error("Failed to create profile:", result.error);
- showErrorToast("Failed to create profile", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error creating profile:", error);
- showErrorToast("Error creating profile", String(error));
- return { success: false, error: String(error) };
- } finally {
- setIsLoading(false);
- }
- }, [currentProfile, loadProfiles]);
-
- // Delete a profile
- const handleDeleteProfile = useCallback(async (profileName: string) => {
- if (profileName === "decky-lsfg-vk") {
- showErrorToast("Cannot delete default profile", "The default profile cannot be deleted");
- return { success: false, error: "Cannot delete default profile" };
- }
-
- setIsLoading(true);
- try {
- const result: ProfileResult = await deleteProfile(profileName);
- if (result.success) {
- showSuccessToast("Profile deleted", `Deleted profile: ${profileName}`);
- await loadProfiles();
- // If we deleted the current profile, it should have switched to default
- if (currentProfile === profileName) {
- setCurrentProfileState("decky-lsfg-vk");
- }
- return result;
- } else {
- console.error("Failed to delete profile:", result.error);
- showErrorToast("Failed to delete profile", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error deleting profile:", error);
- showErrorToast("Error deleting profile", String(error));
- return { success: false, error: String(error) };
- } finally {
- setIsLoading(false);
- }
- }, [currentProfile, loadProfiles]);
-
- // Rename a profile
- const handleRenameProfile = useCallback(async (oldName: string, newName: string) => {
- if (oldName === "decky-lsfg-vk") {
- showErrorToast("Cannot rename default profile", "The default profile cannot be renamed");
- return { success: false, error: "Cannot rename default profile" };
- }
-
- setIsLoading(true);
- try {
- const result: ProfileResult = await renameProfile(oldName, newName);
- if (result.success) {
- // Use the normalized name returned from backend (spaces converted to dashes)
- const actualNewName = result.profile_name || newName;
- showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`);
- await loadProfiles();
- // Update current profile if it was renamed
- if (currentProfile === oldName) {
- setCurrentProfileState(actualNewName);
- }
- return result;
- } else {
- console.error("Failed to rename profile:", result.error);
- showErrorToast("Failed to rename profile", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error renaming profile:", error);
- showErrorToast("Error renaming profile", String(error));
- return { success: false, error: String(error) };
- } finally {
- setIsLoading(false);
- }
- }, [currentProfile, loadProfiles]);
-
- // Set the current active profile
- const handleSetCurrentProfile = useCallback(async (profileName: string) => {
- setIsLoading(true);
- try {
- const result: ProfileResult = await setCurrentProfile(profileName);
- if (result.success) {
- setCurrentProfileState(profileName);
- showSuccessToast("Profile switched", `Switched to profile: ${profileName}`);
- return result;
- } else {
- console.error("Failed to switch profile:", result.error);
- showErrorToast("Failed to switch profile", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error switching profile:", error);
- showErrorToast("Error switching profile", String(error));
- return { success: false, error: String(error) };
- } finally {
- setIsLoading(false);
- }
- }, []);
-
- // Update configuration for a specific profile
- const handleUpdateProfileConfig = useCallback(async (profileName: string, config: ConfigurationData) => {
- setIsLoading(true);
- try {
- const result: ConfigUpdateResult = await updateProfileConfig(profileName, config);
- if (result.success) {
- return result;
- } else {
- console.error("Failed to update profile config:", result.error);
- showErrorToast("Failed to update profile config", result.error || "Unknown error");
- return result;
- }
- } catch (error) {
- console.error("Error updating profile config:", error);
- showErrorToast("Error updating profile config", String(error));
- return { success: false, error: String(error) };
- } finally {
- setIsLoading(false);
- }
- }, [currentProfile]);
-
- // Initialize profiles on mount
- useEffect(() => {
- loadProfiles();
- }, [loadProfiles]);
-
- return {
- profiles,
- currentProfile,
- isLoading,
- loadProfiles,
- createProfile: handleCreateProfile,
- deleteProfile: handleDeleteProfile,
- renameProfile: handleRenameProfile,
- setCurrentProfile: handleSetCurrentProfile,
- updateProfileConfig: handleUpdateProfileConfig
- };
-}