diff --git a/.github/module-map.md b/.github/module-map.md
index 1b19935c..80fcfce6 100644
--- a/.github/module-map.md
+++ b/.github/module-map.md
@@ -31,6 +31,7 @@ This file captures how the control panel, storefront, agents, and helper scripts
| `dashboard` | `modules/dashboard/dashboard.php` | Landing page once authenticated. Pulls stats from homes, invoices, and support modules. Shows "Last updated" footer based on `modules/billing/timestamp.txt`. | Reads `billing_orders`, `game_homes`, `tickets`. |
| `gamemanager` | `modules/gamemanager/server_monitor.php`, `modules/gamemanager/game_monitor.php` | Shows owned homes, start/stop, update, reinstall, port usage. Uses XML to know command lines. | Relies on `lib_remote`, `config_games`, `user_games` assignments. |
| `config_games` | `modules/config_games/add_mod.php`, `server_config_parser.php`, XML files under `server_configs/` | Admin UI for XML definitions. Controls what appears in storefront/service catalog. | Feeds `gamemanager`, billing catalog, cron installers. |
+| `steam_workshop` | `modules/steam_workshop/admin.php`, `user.php`, `includes/functions.php`, `navigation.xml` | Admin profile defaults + per-home mod management. Profile defaults can now be refreshed from game XML and the user route is explicitly exposed via `p=user`. | Uses `config_games` XML metadata + `server_homes`/assignment tables; feeds workshop agent updater. |
| `user_games` | `modules/user_games/add_home.php`, `assign_home.php`, `edit_home.php` | Admin workflow to add homes manually or edit assignments. Shares DB tables with billing provisioner. | Uses `game_homes`, `remote_servers`, `billing_orders`. |
| `administration` / `user_admin` | CRUD around users, groups, permissions, expire dates. | Sets roles consumed by storefront admin guard and provisioning ACLs. |
| `server` | `modules/server/*` | Remote server management (agents, IPs, ports, reinstall keys). Billing uses these tables for available nodes/locations. |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2ab0265..87d83969 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
# Changelog
## 2026-05-06
+- **Panel settings language defaults:** Added missing English labels/help text for `login_ban_time`, `allow_setting_cpu_affinity`, `regex_invalid_file_name_chars`, `discord_invite_url`, `discord_webhook_main`, and `discord_webhook_admin`; language lookup now loads English fallback strings when the active locale is missing a key so settings pages stop rendering raw `_key_` tokens.
+- **Config XML section editor redesign:** Added a top-level section-based XML editor in `config_games` with per-section Validate/Save/Reset actions, optional-section add/remove controls, required-section removal protection, and schema validation for section updates; kept the existing detailed node editor and full raw XML editor for advanced edits.
+- **Steam Workshop admin and routing cleanup:** Reworked `steam_workshop` admin profile editing into clearer grouped dark-theme panels, added XML-based default detection/refresh flow with explicit overwrite confirmation, switched touched SQL to active DB prefix helpers, and re-enabled the `p=user` route in module navigation so valid user links no longer fail with “Invalid subpage given.”
- **Billing/admin provisioning hardening:** Styled the panel Migrate action like the other server action buttons, switched admin-created billing rows to the canonical monthly/31-day default, and made paid checkout fulfillment sync `billing_orders.home_id`, `billing_invoices.home_id`, and `billing_transactions.home_id` after provisioning so paid orders no longer stay at `home_id = 0`.
- **Billing cart data correctness:** `add_to_cart.php` now calculates invoice amounts from the selected slot count and duration, stores `subtotal`/`total_due` metadata, and replaces `ChangeMe` placeholders with securely generated passwords before anything is written to billing tables.
- **PayPal/coupon idempotency:** Cart checkout now stamps PayPal `custom_id` with the exact invoice IDs being purchased, capture/free/webhook handlers normalize month=31-day renewals, avoid duplicate transaction logs, and queue provisioning only for orders that still lack a home.
diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md
index e874dc62..642b5ffd 100644
--- a/docs/COPILOT_TODO.md
+++ b/docs/COPILOT_TODO.md
@@ -4,3 +4,4 @@
- Add an admin-facing toggle that makes it clear when the HTML scraper fallback is in use and lets staff force API-only mode if Valve ever objects.
- Add Workshop result preview thumbnails and author links in the picker for easier browsing.
- Add a lightweight admin UI report that flags remaining PHP files still relying on legacy PHP 7 constructs not covered by the automated compatibility pass.
+- Add a side-by-side before/after diff preview panel to the config_games top-level XML section editor before section saves.
diff --git a/includes/lang.php b/includes/lang.php
index a2bec0b3..596fad4a 100644
--- a/includes/lang.php
+++ b/includes/lang.php
@@ -85,6 +85,63 @@ function ogpLang()
}
}
+function ogp_load_english_fallbacks()
+{
+ static $coreLoaded = false;
+ static $loadedModules = array();
+ global $lang_modules;
+
+ $englishDir = "lang/English";
+ if (!is_dir($englishDir)) {
+ return;
+ }
+
+ if ($coreLoaded === false) {
+ $coreFiles = glob($englishDir . "/*.php");
+ if (is_array($coreFiles)) {
+ foreach ($coreFiles as $coreFile) {
+ ogp_include_lang_file_safely($coreFile);
+ }
+ }
+ $coreLoaded = true;
+ }
+
+ $modulesToLoad = array();
+ if (isset($_REQUEST['m']) && $_REQUEST['m'] !== '') {
+ $modulesToLoad[] = $_REQUEST['m'];
+ }
+ foreach ((array)$lang_modules as $moduleName) {
+ $modulesToLoad[] = $moduleName;
+ }
+
+ foreach (array_unique($modulesToLoad) as $moduleName) {
+ if (!preg_match('/^([a-z]|[0-9]|_|-)+$/i', (string)$moduleName)) {
+ continue;
+ }
+ if (isset($loadedModules[$moduleName])) {
+ continue;
+ }
+ $moduleFile = $englishDir . "/modules/" . $moduleName . ".php";
+ if (is_file($moduleFile)) {
+ ogp_include_lang_file_safely($moduleFile);
+ }
+ $loadedModules[$moduleName] = true;
+ }
+}
+
+function ogp_include_lang_file_safely($filePath)
+{
+ set_error_handler(function ($severity, $message) {
+ $isConstantRedefinition = (bool)preg_match('/^Constant\\s+.+\\s+already\\s+defined$/i', trim((string)$message));
+ if ($severity === E_WARNING && $isConstantRedefinition) {
+ return true;
+ }
+ return false;
+ });
+ include_once($filePath);
+ restore_error_handler();
+}
+
function get_lang($lang_index)
{
global $OGPLangPre;
@@ -94,13 +151,26 @@ function get_lang($lang_index)
return constant($lang_index);
}
- if(!startsWith($lang_index, $OGPLangPre)){
+ if(!startsWith($lang_index, $OGPLangPre)){
$newLangIndex = $OGPLangPre . $lang_index;
if (defined($newLangIndex))
{
return constant($newLangIndex);
}
+ ogp_load_english_fallbacks();
+ if (defined($newLangIndex))
+ {
+ return constant($newLangIndex);
+ }
}
+ else
+ {
+ ogp_load_english_fallbacks();
+ if (defined($lang_index))
+ {
+ return constant($lang_index);
+ }
+ }
// Any other case is error.
return "_".$lang_index."_";
@@ -117,13 +187,26 @@ function get_lang_f()
return vsprintf(constant($lang_index),$args);
}
- if(!startsWith($lang_index, $OGPLangPre)){
+ if(!startsWith($lang_index, $OGPLangPre)){
$newLangIndex = $OGPLangPre . $lang_index;
if (defined($newLangIndex))
{
return vsprintf(constant($newLangIndex),$args);
}
+ ogp_load_english_fallbacks();
+ if (defined($newLangIndex))
+ {
+ return vsprintf(constant($newLangIndex),$args);
+ }
}
+ else
+ {
+ ogp_load_english_fallbacks();
+ if (defined($lang_index))
+ {
+ return vsprintf(constant($lang_index),$args);
+ }
+ }
return "_".$lang_index."_".implode("_",$args)."_";
}
diff --git a/lang/English/modules/settings.php b/lang/English/modules/settings.php
index daa8d3f2..f8559ea2 100644
--- a/lang/English/modules/settings.php
+++ b/lang/English/modules/settings.php
@@ -86,6 +86,8 @@ define('LANG_recaptcha_use_login', "Use Recaptcha on Login");
define('LANG_recaptcha_use_login_info', "If enabled, users will have to solve the Not a Robot Recaptcha when attempting to login.");
define('LANG_login_attempts_before_banned', "Number of failed login attempts before user is banned");
define('LANG_login_attempts_before_banned_info', "If a user tries to login with invalid credentials more than this many times, the user will be banned temporarily by the panel.");
+define('LANG_login_ban_time', "Login ban duration (seconds)");
+define('LANG_login_ban_time_info', "How long a user stays temporarily banned after reaching the failed login attempt limit.");
define('LANG_custom_github_update_username', "GitHub update username");
define('LANG_custom_github_update_username_info', "Enter your GitHub username ONLY to use your own forked repositories to update OGP. This should only be changed by developers who wish to use their own repos for development rather than checking in possibly buggy code into the main branch.");
define('LANG_remote_query', "Remote query");
@@ -129,6 +131,16 @@ define('LANG_default_game_server_home_path_prefix', "Default game server home di
define('LANG_default_game_server_home_path_prefix_info', "Enter a path prefix for where you want game server homes to be created by default. You can use \"{USERNAME}\" in the path which will be replaced with the OGP username the game server is being assigned to. You can use \"{GAMEKEY}\" in the path which will be replaced with a friendly lowercase name. You can use \"{SKIPID}\" anywhere in the path to skip appending the home ID to the path. Example: /ogp/games/{USERNAME}/{GAMEKEY}{SKIPID} will become /ogp/games/username/arkse/. Example 2: /ogp/games will become /ogp/games/1 where 1 is the game servers ID.");
define('LANG_use_authorized_hosts', "Limit API to Defined Authorized Hosts");
define('LANG_use_authorized_hosts_info', "Enable this setting to only allow API calls from pre-defined and approved IP addresses. Approved addresses can be set on this page once the setting has been enabled. If this setting is disabled, a user using a valid key will have access to the API from any IP address. Users using a valid key will be able to use the API to manage any game server they have permissions to administrate.");
+define('LANG_allow_setting_cpu_affinity', "Allow CPU affinity editing");
+define('LANG_allow_setting_cpu_affinity_info', "Allow users to set CPU affinity values for their game servers when supported by the host.");
+define('LANG_regex_invalid_file_name_chars', "Invalid filename characters regex");
+define('LANG_regex_invalid_file_name_chars_info', "Regular expression used by the file manager to block unsafe filename characters.");
+define('LANG_discord_invite_url', "Discord invite URL");
+define('LANG_discord_invite_url_info', "Invite URL used by panel links that send users to your Discord server.");
+define('LANG_discord_webhook_main', "Discord webhook (main)");
+define('LANG_discord_webhook_main_info', "Webhook URL used for general panel notifications.");
+define('LANG_discord_webhook_admin', "Discord webhook (admin)");
+define('LANG_discord_webhook_admin_info', "Webhook URL used for administrator-specific notifications.");
define('LANG_setup_api_authorized_hosts', "Setup API authorized hosts");
define('LANG_autohorized_hosts', "Authorized hosts");
define('LANG_add', "Add");
@@ -143,6 +155,7 @@ define('LANG_reset_game_server_order_info', "Resets game server ordering back to
// Debug level
define('LANG_debug_level', "Panel Debug Level");
+define('LANG_debug_level_info', "Controls how much PHP error output is shown in the panel.");
define('LANG_debug_off', "Off (production)");
define('LANG_debug_fatal_only', "Fatal errors only (page-breaking)");
define('LANG_debug_errors_warnings', "Errors & Warnings");
diff --git a/modules/config_games/config_servers.php b/modules/config_games/config_servers.php
index c910815a..902be6c8 100644
--- a/modules/config_games/config_servers.php
+++ b/modules/config_games/config_servers.php
@@ -273,6 +273,22 @@ function config_games_print_editor_css()
.xml-node__example code{color:#a0d0a0;background:rgba(30,150,50,0.1);padding:1px 4px;border-radius:3px}
.xml-jump-link{display:inline-block;margin-bottom:12px;padding:6px 14px;background:#1c6dd0;color:#fff;border-radius:4px;text-decoration:none;font-size:0.9rem}
.xml-jump-link:hover{background:#1f7aec;text-decoration:none}
+.xml-section-grid{display:flex;flex-direction:column;gap:14px;margin-bottom:18px}
+.xml-section-block{border:1px solid #303030;border-radius:6px;background:#141414;padding:12px}
+.xml-section-block__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px}
+.xml-section-block__title{font-size:1.02rem;color:#f0f0f0;font-weight:600}
+.xml-section-block__meta{font-size:0.8rem;color:#9f9f9f}
+.xml-section-block__desc{font-size:0.86rem;color:#b0b0b0;margin:0 0 10px}
+.xml-section-block textarea{width:100%;min-height:170px;background:#0f0f0f;border:1px solid #3c3c3c;border-radius:4px;color:#f7f7f7;padding:8px;font-family:monospace;font-size:0.84rem}
+.xml-section-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
+.xml-btn{border:1px solid #3f3f3f;background:#222;color:#fff;padding:6px 10px;border-radius:4px;cursor:pointer}
+.xml-btn:hover{background:#2a2a2a}
+.xml-btn--primary{background:#1c6dd0;border-color:#114b99}
+.xml-btn--primary:hover{background:#1f7aec}
+.xml-btn--danger{background:#6b1f1f;border-color:#8d2d2d}
+.xml-btn--danger:hover{background:#8d2d2d}
+.xml-add-section{border:1px dashed #3a3a3a;border-radius:6px;padding:10px;margin-bottom:16px}
+.xml-add-section select{min-width:260px}
CSS;
}
@@ -418,6 +434,305 @@ function config_games_render_editor(SimpleXMLElement $xml)
return $html;
}
+function config_games_get_config_file_path($db, $home_cfg_id)
+{
+ $cfgInfo = $db->getGameCfg((int)$home_cfg_id);
+ if ($cfgInfo === false) {
+ return false;
+ }
+ return SERVER_CONFIG_LOCATION . $cfgInfo['home_cfg_file'];
+}
+
+function config_games_parse_section_payload($sectionName, $sectionXml)
+{
+ $sectionName = trim((string)$sectionName);
+ if ($sectionName === '' || !preg_match('/^[A-Za-z0-9_\\-]+$/', $sectionName)) {
+ return array(false, 'Invalid section name.');
+ }
+ $sectionXml = trim((string)$sectionXml);
+ if ($sectionXml === '') {
+ return array(false, 'Section XML cannot be empty.');
+ }
+
+ $tmpDom = new DOMDocument();
+ $tmpDom->preserveWhiteSpace = true;
+ $tmpDom->formatOutput = false;
+ $wrapped = '' . $sectionXml . '';
+ $prev = libxml_use_internal_errors(true);
+ libxml_clear_errors();
+ $ok = $tmpDom->loadXML($wrapped);
+ $errors = libxml_get_errors();
+ libxml_clear_errors();
+ libxml_use_internal_errors($prev);
+ if (!$ok) {
+ $msg = 'Section XML is not well-formed.';
+ if (!empty($errors)) {
+ $msg = trim($errors[0]->message) . ' (line ' . $errors[0]->line . ')';
+ }
+ return array(false, $msg);
+ }
+
+ $elements = array();
+ foreach ($tmpDom->documentElement->childNodes as $child) {
+ if ($child instanceof DOMElement) {
+ $elements[] = $child;
+ }
+ }
+ if (count($elements) !== 1) {
+ return array(false, 'Section XML must contain exactly one top-level element.');
+ }
+ if ($elements[0]->tagName !== $sectionName) {
+ return array(false, 'Section XML root tag must be <' . htmlspecialchars($sectionName, ENT_QUOTES, 'UTF-8') . '>.');
+ }
+ return array($elements[0], '');
+}
+
+function config_games_get_top_level_sections($configFile)
+{
+ $sections = array();
+ if (!file_exists($configFile)) {
+ return $sections;
+ }
+ $dom = new DOMDocument();
+ $dom->preserveWhiteSpace = true;
+ $dom->formatOutput = false;
+ if (!$dom->load($configFile)) {
+ return $sections;
+ }
+ $schema = config_games_schema_order();
+ $descriptions = config_games_tag_descriptions();
+ foreach ($dom->documentElement->childNodes as $child) {
+ if (!($child instanceof DOMElement)) {
+ continue;
+ }
+ $name = $child->tagName;
+ $sections[] = array(
+ 'name' => $name,
+ 'required' => ($schema[$name] ?? null) === true,
+ 'optional' => ($schema[$name] ?? null) === false,
+ 'xml' => $dom->saveXML($child),
+ 'description' => $descriptions[$name]['desc'] ?? 'Top-level configuration section.',
+ );
+ }
+ return $sections;
+}
+
+function config_games_validate_document_or_errors(DOMDocument $dom)
+{
+ $tmp = tempnam(sys_get_temp_dir(), 'gsp_cfg_section_');
+ if ($tmp === false) {
+ return array('Could not create temporary file for validation.');
+ }
+ $dom->save($tmp);
+ $errors = config_games_validate_xml_file($tmp);
+ @unlink($tmp);
+ return $errors;
+}
+
+function config_games_validate_section_update($db, $home_cfg_id, $sectionName, $sectionXml)
+{
+ $configFile = config_games_get_config_file_path($db, $home_cfg_id);
+ if ($configFile === false || !file_exists($configFile)) {
+ return array(false, array('Configuration file not found.'));
+ }
+
+ list($sectionNode, $parseError) = config_games_parse_section_payload($sectionName, $sectionXml);
+ if ($sectionNode === false) {
+ return array(false, array($parseError));
+ }
+
+ $dom = new DOMDocument();
+ $dom->preserveWhiteSpace = true;
+ $dom->formatOutput = false;
+ if ($dom->load($configFile) === false) {
+ return array(false, array('Could not parse configuration XML.'));
+ }
+ $root = $dom->documentElement;
+ $import = $dom->importNode($sectionNode, true);
+ $replaced = false;
+ foreach ($root->childNodes as $child) {
+ if ($child instanceof DOMElement && $child->tagName === $sectionName) {
+ $root->replaceChild($import, $child);
+ $replaced = true;
+ break;
+ }
+ }
+ if (!$replaced) {
+ $root->appendChild($import);
+ }
+
+ $errors = config_games_validate_document_or_errors($dom);
+ if (!empty($errors)) {
+ return array(false, $errors);
+ }
+ return array(true, array());
+}
+
+function config_games_save_dom_and_refresh_cfg($db, $configFile, DOMDocument $dom)
+{
+ if ($dom->save($configFile) === false) {
+ return array(false, array('Failed to write configuration file.'));
+ }
+ $config = read_server_config($configFile);
+ if ($config !== false) {
+ $db->addGameCfg($config);
+ }
+ return array(true, array());
+}
+
+function config_games_upsert_top_level_section($db, $home_cfg_id, $sectionName, $sectionXml)
+{
+ $configFile = config_games_get_config_file_path($db, $home_cfg_id);
+ if ($configFile === false || !file_exists($configFile)) {
+ return array(false, array('Configuration file not found.'));
+ }
+
+ list($sectionNode, $parseError) = config_games_parse_section_payload($sectionName, $sectionXml);
+ if ($sectionNode === false) {
+ return array(false, array($parseError));
+ }
+
+ $dom = new DOMDocument();
+ $dom->preserveWhiteSpace = true;
+ $dom->formatOutput = true;
+ if ($dom->load($configFile) === false) {
+ return array(false, array('Could not parse configuration XML.'));
+ }
+ $import = $dom->importNode($sectionNode, true);
+ $root = $dom->documentElement;
+ $replaced = false;
+ foreach ($root->childNodes as $child) {
+ if ($child instanceof DOMElement && $child->tagName === $sectionName) {
+ $root->replaceChild($import, $child);
+ $replaced = true;
+ break;
+ }
+ }
+
+ if (!$replaced) {
+ $schemaKeys = array_keys(config_games_schema_order());
+ $targetIndex = array_search($sectionName, $schemaKeys, true);
+ $inserted = false;
+ if ($targetIndex !== false) {
+ foreach ($root->childNodes as $child) {
+ if (!($child instanceof DOMElement)) {
+ continue;
+ }
+ $childIndex = array_search($child->tagName, $schemaKeys, true);
+ if ($childIndex !== false && $childIndex > $targetIndex) {
+ $root->insertBefore($import, $child);
+ $inserted = true;
+ break;
+ }
+ }
+ }
+ if (!$inserted) {
+ $root->appendChild($import);
+ }
+ }
+
+ $errors = config_games_validate_document_or_errors($dom);
+ if (!empty($errors)) {
+ return array(false, $errors);
+ }
+ return config_games_save_dom_and_refresh_cfg($db, $configFile, $dom);
+}
+
+function config_games_remove_optional_section($db, $home_cfg_id, $sectionName)
+{
+ $schema = config_games_schema_order();
+ if (($schema[$sectionName] ?? null) === true) {
+ return array(false, array('Required sections cannot be removed: ' . $sectionName));
+ }
+
+ $configFile = config_games_get_config_file_path($db, $home_cfg_id);
+ if ($configFile === false || !file_exists($configFile)) {
+ return array(false, array('Configuration file not found.'));
+ }
+
+ $dom = new DOMDocument();
+ $dom->preserveWhiteSpace = true;
+ $dom->formatOutput = true;
+ if ($dom->load($configFile) === false) {
+ return array(false, array('Could not parse configuration XML.'));
+ }
+ $root = $dom->documentElement;
+ $removed = false;
+ foreach ($root->childNodes as $child) {
+ if ($child instanceof DOMElement && $child->tagName === $sectionName) {
+ $root->removeChild($child);
+ $removed = true;
+ break;
+ }
+ }
+ if (!$removed) {
+ return array(false, array('Section not found: ' . $sectionName));
+ }
+
+ $errors = config_games_validate_document_or_errors($dom);
+ if (!empty($errors)) {
+ return array(false, $errors);
+ }
+ return config_games_save_dom_and_refresh_cfg($db, $configFile, $dom);
+}
+
+function config_games_render_top_level_editor($home_cfg_id, $configFile)
+{
+ $sections = config_games_get_top_level_sections($configFile);
+ $schema = config_games_schema_order();
+ $presentNames = array_map(function ($section) {
+ return $section['name'];
+ }, $sections);
+ $optionalMissing = array();
+ foreach ($schema as $name => $required) {
+ if ($required === false && !in_array($name, $presentNames, true)) {
+ $optionalMissing[] = $name;
+ }
+ }
+
+ echo "
Section Editor
";
+ $sectionEditorNote = "Edit one top-level section at a time. Validate a block before saving. Required sections cannot be removed. Optional sections can be added or removed safely.";
+ echo "
⚠ Warning: Saving raw XML bypasses the guided editor. The file will be validated against the schema before saving. Invalid XML will be rejected.
";
echo "";
echo "
";
+ echo "
";
echo "";
+ echo "
";
echo ""; // #xml-editor-section
}
}
diff --git a/modules/steam_workshop/admin.php b/modules/steam_workshop/admin.php
index 5f66c836..09977c00 100644
--- a/modules/steam_workshop/admin.php
+++ b/modules/steam_workshop/admin.php
@@ -2,18 +2,9 @@
/*
* GSP – Steam Workshop: Admin profile management
* Copyright (C) 2025 WDS / GameServerPanel
- *
- * Accessible via: home.php?m=steam_workshop&p=admin
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
*/
require_once __DIR__ . '/includes/functions.php';
-
-// Load the XML config parser so sw_sync_profiles() can read game configs.
if (!defined('SERVER_CONFIG_LOCATION')) {
require_once __DIR__ . '/../../config_games/server_config_parser.php';
}
@@ -22,23 +13,68 @@ function exec_ogp_module()
{
global $db;
- echo '
- Each game config XML gets one Workshop profile.
- Use Sync Profiles to auto-create rows for new game configs.
- Enable and configure each profile to activate Steam Workshop for that game.
-
+
+
+ Profiles map game XML configs to Steam Workshop defaults. Sync to create missing profiles, then edit each profile for game-specific paths and launch templates.
+
-
+
-
-
-
-
No profiles yet. Click Sync Profiles to create them from the installed game configs.