From 9a5658c038547895f361cbe6d544d304a0fc9d1e Mon Sep 17 00:00:00 2001 From: nmzik Date: Wed, 7 Oct 2026 00:39:50 +0400 Subject: [PATCH] launcher: add per-game config import and export --- .../forms/configuration_edit_dialog.ui | 9 +- .../forms/configuration_list_widget.ui | 4 +- src/launcher/include/configuration.h | 98 ++---------- .../include/configurationEditDialog.h | 2 + .../include/configurationListWidget.h | 2 + src/launcher/src/configuration.cpp | 145 ++++++++++++++++++ src/launcher/src/configurationEditDialog.cpp | 7 + src/launcher/src/configurationListWidget.cpp | 145 +++++++++++++++++- 8 files changed, 316 insertions(+), 96 deletions(-) create mode 100644 src/launcher/src/configuration.cpp diff --git a/src/launcher/forms/configuration_edit_dialog.ui b/src/launcher/forms/configuration_edit_dialog.ui index 82fdd0072..5b5feb5d9 100644 --- a/src/launcher/forms/configuration_edit_dialog.ui +++ b/src/launcher/forms/configuration_edit_dialog.ui @@ -6,7 +6,7 @@ 0 0 - 840 + 900 620 @@ -400,6 +400,13 @@ To use your own sound, replace the emulator's assets/sounds/trophy-unlock.wav wi Restore default settings + + + 8025 + Game Configs + Import or export game configs + + Qt::Horizontal diff --git a/src/launcher/forms/configuration_list_widget.ui b/src/launcher/forms/configuration_list_widget.ui index c49cf6830..e41f1b51d 100644 --- a/src/launcher/forms/configuration_list_widget.ui +++ b/src/launcher/forms/configuration_list_widget.ui @@ -125,7 +125,7 @@ - <html><head/><body><p>Edit configuration</p></body></html> + Edit game config @@ -150,7 +150,7 @@ - <html><head/><body><p>Delete configuration</p></body></html> + Clear game config diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index 5b88a28e6..fc7568d50 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -14,9 +15,6 @@ #include #include -#define KYTY_CFG_SET(n) s->setValue(#n, QVariant::fromValue(n).toString()); -#define KYTY_CFG_GET(n) n = s->value(#n).value(); - template inline QStringList EnumToList() { QStringList ret; @@ -126,7 +124,7 @@ public: int vblank_frequency = 60; int console_language = DEFAULT_CONSOLE_LANGUAGE; bool vulkan_validation_enabled = false; - bool shader_validation_enabled = true; + bool shader_validation_enabled = false; ShaderOptimizationType shader_optimization_type = ShaderOptimizationType::Performance; LogDirection shader_log_direction = LogDirection::Silent; QString shader_log_folder = "_Shaders"; @@ -188,90 +186,16 @@ public: game_comment = other.game_comment; } - void WriteSettings(QSettings* s) const { - KYTY_CFG_SET(name); - KYTY_CFG_SET(basedir); - KYTY_CFG_SET(game_path); - KYTY_CFG_SET(custom_settings); - KYTY_CFG_SET(screen_resolution); - KYTY_CFG_SET(user_name); - KYTY_CFG_SET(user_id); - KYTY_CFG_SET(audio_input_device); - KYTY_CFG_SET(present_mode); - KYTY_CFG_SET(gpu_index); - KYTY_CFG_SET(fullscreen_enabled); - KYTY_CFG_SET(hide_cursor_enabled); - KYTY_CFG_SET(readback_linear_images); - KYTY_CFG_SET(tessellation_enabled); - KYTY_CFG_SET(trophy_enabled); - KYTY_CFG_SET(vblank_frequency); - KYTY_CFG_SET(console_language); - KYTY_CFG_SET(vulkan_validation_enabled); - KYTY_CFG_SET(shader_validation_enabled); - KYTY_CFG_SET(shader_optimization_type); - KYTY_CFG_SET(shader_log_direction); - KYTY_CFG_SET(shader_log_folder); - KYTY_CFG_SET(command_buffer_dump_enabled); - KYTY_CFG_SET(command_buffer_dump_folder); - KYTY_CFG_SET(printf_direction); - KYTY_CFG_SET(printf_output_file); - KYTY_CFG_SET(profiler_enabled); - KYTY_CFG_SET(renderdoc_enabled); - KYTY_CFG_SET(amd_cpu_enabled); -#if defined(_WIN32) - KYTY_CFG_SET(red_zone_protection_enabled); -#endif - s->setValue("host_input_mapping", host_input_mapping); - KYTY_CFG_SET(elf); - } + void WriteSettings(QSettings* s) const; + void ReadSettings(QSettings* s); - void ReadSettings(QSettings* s) { - KYTY_CFG_GET(name); - KYTY_CFG_GET(basedir); - KYTY_CFG_GET(game_path); - KYTY_CFG_GET(custom_settings); - KYTY_CFG_GET(screen_resolution); - user_name = s->value("user_name", user_name).toString(); - bool user_id_ok = false; - auto saved_user_id = s->value("user_id", user_id).toInt(&user_id_ok); - user_id = user_id_ok && Config::IsConfiguredUserIdValid(saved_user_id) - ? saved_user_id - : Config::DEFAULT_USER_ID; - audio_input_device = s->value("audio_input_device", audio_input_device).toString(); - KYTY_CFG_GET(present_mode); - gpu_index = s->value("gpu_index", -1).toInt(); - if (EnumToText(present_mode).isEmpty()) { - present_mode = PresentMode::Mailbox; - } - KYTY_CFG_GET(fullscreen_enabled); - KYTY_CFG_GET(hide_cursor_enabled); - KYTY_CFG_GET(readback_linear_images); - KYTY_CFG_GET(tessellation_enabled); - trophy_enabled = s->value("trophy_enabled", trophy_enabled).toBool(); - vblank_frequency = s->value("vblank_frequency", vblank_frequency).toInt(); - console_language = s->value("console_language", console_language).toInt(); - if (console_language < 0 || console_language > MAX_CONSOLE_LANGUAGE) { - console_language = DEFAULT_CONSOLE_LANGUAGE; - } - KYTY_CFG_GET(vulkan_validation_enabled); - KYTY_CFG_GET(shader_validation_enabled); - KYTY_CFG_GET(shader_optimization_type); - KYTY_CFG_GET(shader_log_direction); - KYTY_CFG_GET(shader_log_folder); - KYTY_CFG_GET(command_buffer_dump_enabled); - KYTY_CFG_GET(command_buffer_dump_folder); - KYTY_CFG_GET(printf_direction); - KYTY_CFG_GET(printf_output_file); - KYTY_CFG_GET(profiler_enabled); - KYTY_CFG_GET(renderdoc_enabled); - amd_cpu_enabled = s->value("amd_cpu_enabled", false).toBool(); -#if defined(_WIN32) - red_zone_protection_enabled = - s->value("red_zone_protection_enabled", red_zone_protection_enabled).toBool(); -#endif - host_input_mapping = s->value("host_input_mapping", host_input_mapping).toStringList(); - elf = s->value("elf", elf).toString(); - } + [[nodiscard]] QVariantMap GameSettings() const; + // Call on a temporary configuration: validation can fail after loading values. + bool SetGameSettings(const QJsonObject& settings, QString& error); + +private: + template + void ReadGameSettingsValues(const Settings& s); }; Q_DECLARE_METATYPE(Configuration*) diff --git a/src/launcher/include/configurationEditDialog.h b/src/launcher/include/configurationEditDialog.h index cc441243f..c9292d4e7 100644 --- a/src/launcher/include/configurationEditDialog.h +++ b/src/launcher/include/configurationEditDialog.h @@ -33,6 +33,8 @@ public: signals: void PreviewControllerColor(const QString& color); + void ImportGameSettings(); + void ExportGameSettings(); private: Ui::ConfigurationEditDialog* m_ui = nullptr; diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h index de0bee954..2adf4052a 100644 --- a/src/launcher/include/configurationListWidget.h +++ b/src/launcher/include/configurationListWidget.h @@ -74,6 +74,8 @@ private: void SelectItem(QTreeWidgetItem* witem); void ApplyCompatibility(); void UpdateToolbarIcons(); + void ImportGameSettings(QWidget* parent); + void ExportGameSettings(QWidget* parent) const; [[nodiscard]] bool HasValidGameDirectory() const; ConfigurationItem* m_selected_item = nullptr; diff --git a/src/launcher/src/configuration.cpp b/src/launcher/src/configuration.cpp new file mode 100644 index 000000000..d5c6bc703 --- /dev/null +++ b/src/launcher/src/configuration.cpp @@ -0,0 +1,145 @@ +#include "configuration.h" + +QVariantMap Configuration::GameSettings() const { + QVariantMap values; +#define KYTY_CFG_SET(n) values.insert(QStringLiteral(#n), QVariant::fromValue(n).toString()) + KYTY_CFG_SET(screen_resolution); + KYTY_CFG_SET(user_name); + KYTY_CFG_SET(user_id); + KYTY_CFG_SET(audio_input_device); + KYTY_CFG_SET(present_mode); + KYTY_CFG_SET(gpu_index); + KYTY_CFG_SET(fullscreen_enabled); + KYTY_CFG_SET(hide_cursor_enabled); + KYTY_CFG_SET(readback_linear_images); + KYTY_CFG_SET(tessellation_enabled); + KYTY_CFG_SET(trophy_enabled); + KYTY_CFG_SET(vblank_frequency); + KYTY_CFG_SET(console_language); + KYTY_CFG_SET(vulkan_validation_enabled); + KYTY_CFG_SET(shader_validation_enabled); + KYTY_CFG_SET(shader_optimization_type); + KYTY_CFG_SET(shader_log_direction); + KYTY_CFG_SET(shader_log_folder); + KYTY_CFG_SET(command_buffer_dump_enabled); + KYTY_CFG_SET(command_buffer_dump_folder); + KYTY_CFG_SET(printf_direction); + KYTY_CFG_SET(printf_output_file); + KYTY_CFG_SET(profiler_enabled); + KYTY_CFG_SET(renderdoc_enabled); + KYTY_CFG_SET(amd_cpu_enabled); +#if defined(_WIN32) + KYTY_CFG_SET(red_zone_protection_enabled); +#endif + KYTY_CFG_SET(elf); +#undef KYTY_CFG_SET + return values; +} + +template +void Configuration::ReadGameSettingsValues(const Settings& s) { +#define KYTY_CFG_GET(n) n = s.value(QStringLiteral(#n)).template value() + KYTY_CFG_GET(screen_resolution); + user_name = s.value("user_name", user_name).toString(); + bool user_id_ok = false; + auto saved_user_id = s.value("user_id", user_id).toInt(&user_id_ok); + user_id = user_id_ok && Config::IsConfiguredUserIdValid(saved_user_id) + ? saved_user_id + : Config::DEFAULT_USER_ID; + audio_input_device = s.value("audio_input_device", audio_input_device).toString(); + KYTY_CFG_GET(present_mode); + gpu_index = s.value("gpu_index", -1).toInt(); + if (EnumToText(present_mode).isEmpty()) { + present_mode = PresentMode::Mailbox; + } + KYTY_CFG_GET(fullscreen_enabled); + KYTY_CFG_GET(hide_cursor_enabled); + KYTY_CFG_GET(readback_linear_images); + KYTY_CFG_GET(tessellation_enabled); + trophy_enabled = s.value("trophy_enabled", trophy_enabled).toBool(); + vblank_frequency = s.value("vblank_frequency", vblank_frequency).toInt(); + console_language = s.value("console_language", console_language).toInt(); + if (console_language < 0 || console_language > MAX_CONSOLE_LANGUAGE) { + console_language = DEFAULT_CONSOLE_LANGUAGE; + } + KYTY_CFG_GET(vulkan_validation_enabled); + KYTY_CFG_GET(shader_validation_enabled); + KYTY_CFG_GET(shader_optimization_type); + KYTY_CFG_GET(shader_log_direction); + KYTY_CFG_GET(shader_log_folder); + KYTY_CFG_GET(command_buffer_dump_enabled); + KYTY_CFG_GET(command_buffer_dump_folder); + KYTY_CFG_GET(printf_direction); + KYTY_CFG_GET(printf_output_file); + KYTY_CFG_GET(profiler_enabled); + KYTY_CFG_GET(renderdoc_enabled); + amd_cpu_enabled = s.value("amd_cpu_enabled", false).toBool(); +#if defined(_WIN32) + red_zone_protection_enabled = + s.value("red_zone_protection_enabled", red_zone_protection_enabled).toBool(); +#endif + elf = s.value("elf", elf).toString(); +#undef KYTY_CFG_GET +} + +void Configuration::WriteSettings(QSettings* s) const { + const auto values = GameSettings(); + for (auto it = values.constBegin(); it != values.constEnd(); ++it) { + s->setValue(it.key(), it.value()); + } + s->setValue("name", name); + s->setValue("basedir", basedir); + s->setValue("game_path", game_path); + s->setValue("custom_settings", QVariant::fromValue(custom_settings).toString()); + s->setValue("host_input_mapping", host_input_mapping); +} + +void Configuration::ReadSettings(QSettings* s) { + ReadGameSettingsValues(*s); + name = s->value("name").toString(); + basedir = s->value("basedir").toString(); + game_path = s->value("game_path").toString(); + custom_settings = s->value("custom_settings").toBool(); + host_input_mapping = s->value("host_input_mapping", host_input_mapping).toStringList(); +} + +bool Configuration::SetGameSettings(const QJsonObject& settings, QString& error) { + const auto fail = [&error](const QString& message) { + error = message; + return false; + }; + auto values = GameSettings(); + for (auto it = settings.constBegin(); it != settings.constEnd(); ++it) { + if (!values.contains(it.key())) { + return fail(tr("Unknown game setting: %1").arg(it.key())); + } + if (!it.value().isString()) { + return fail(tr("Game setting %1 must be a string.").arg(it.key())); + } + values.insert(it.key(), it.value().toString()); + } + + ReadGameSettingsValues(values); + const auto canonical = GameSettings(); + for (auto it = settings.constBegin(); it != settings.constEnd(); ++it) { + if (canonical.value(it.key()) != values.value(it.key())) { + return fail(tr("Invalid value for game setting: %1").arg(it.key())); + } + } + if (user_name.trimmed().isEmpty() || user_name.toUtf8().size() > Config::MAX_USER_NAME_LENGTH) { + return fail(tr("User name must contain 1-16 UTF-8 bytes.")); + } + if (gpu_index < -1) { + return fail(tr("GPU index must be -1 or greater.")); + } + if (vblank_frequency < 30 || vblank_frequency > 360) { + return fail(tr("Vblank frequency must be between 30 and 360.")); + } + if ((shader_log_direction == LogDirection::File && shader_log_folder.isEmpty()) || + (printf_direction == LogDirection::File && printf_output_file.isEmpty()) || + (command_buffer_dump_enabled && command_buffer_dump_folder.isEmpty())) { + return fail(tr("Enabled logging and command buffer dumps require an output path.")); + } + + return true; +} diff --git a/src/launcher/src/configurationEditDialog.cpp b/src/launcher/src/configurationEditDialog.cpp index 140f881a1..882a60f07 100644 --- a/src/launcher/src/configurationEditDialog.cpp +++ b/src/launcher/src/configurationEditDialog.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,7 @@ ConfigurationEditDialog::ConfigurationEditDialog(Configuration& info, QWidget* p setMinimumWidth(width()); InitGameDirectories(); m_ui->controller_group->setVisible(false); + m_ui->settings_button->setVisible(false); connect(m_ui->ok_button, &QPushButton::clicked, this, &ConfigurationEditDialog::save); connect(m_ui->cancel_button, &QPushButton::clicked, this, &QDialog::reject); @@ -354,6 +356,11 @@ void ConfigurationEditDialog::InitGameDirectories() { void ConfigurationEditDialog::SetGlobalSettings(const QStringList& dirs) { m_global_settings = true; + auto* menu = new QMenu(m_ui->settings_button); + menu->addAction(tr("Import..."), this, &ConfigurationEditDialog::ImportGameSettings); + menu->addAction(tr("Export..."), this, &ConfigurationEditDialog::ExportGameSettings); + m_ui->settings_button->setMenu(menu); + m_ui->settings_button->setVisible(true); m_game_dirs_list->clear(); m_game_dirs_group->setMinimumWidth(GLOBAL_SETTINGS_GAME_DIRS_MIN_WIDTH); diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index 05555835d..66ffc1031 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -24,9 +24,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -40,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -765,7 +768,7 @@ void ConfigurationListWidget::edit_configuration() { auto info = CreateConfiguration(*item); ConfigurationEditDialog dlg(*info, this); - dlg.setWindowTitle(tr("Edit game settings")); + dlg.setWindowTitle(tr("Edit game config")); connect(&dlg, &ConfigurationEditDialog::PreviewControllerColor, this, &ConfigurationListWidget::PreviewControllerColor); @@ -789,8 +792,8 @@ void ConfigurationListWidget::delete_configuartion() { return; } - if (QMessageBox::Yes == QMessageBox::question(this, tr("Clear custom settings"), - tr("Do you want to clear custom settings?"))) { + if (QMessageBox::Yes == QMessageBox::question(this, tr("Clear game config"), + tr("Clear this game's config and use global settings?"))) { delete m_custom_infos.take(item->GetInfo().game_path); item->GetInfo().custom_settings = false; WriteSettings(); @@ -810,6 +813,10 @@ void ConfigurationListWidget::edit_global_settings() { dlg.SetGlobalSettings(m_game_dirs); connect(&dlg, &ConfigurationEditDialog::PreviewControllerColor, this, &ConfigurationListWidget::PreviewControllerColor); + connect(&dlg, &ConfigurationEditDialog::ImportGameSettings, this, + [this, &dlg]() { ImportGameSettings(&dlg); }); + connect(&dlg, &ConfigurationEditDialog::ExportGameSettings, this, + [this, &dlg]() { ExportGameSettings(&dlg); }); if (dlg.exec() == QDialog::Accepted) { m_global_info.CopyEmulatorSettingsFrom(info); @@ -822,7 +829,133 @@ void ConfigurationListWidget::edit_global_settings() { ScanGameDirectory(); } } - emit Select(); + SelectItem(m_ui->cfgs_list->currentItem()); +} + +static bool IsGameSettingsTitleId(const QString& title_id) { + static const QRegularExpression pattern(QStringLiteral("\\APPSA[0-9]{5}\\z")); + return pattern.match(title_id).hasMatch(); +} + +void ConfigurationListWidget::ImportGameSettings(QWidget* parent) { + const auto path = QFileDialog::getOpenFileName(parent, tr("Import game configs"), {}, + tr("JSON files (*.json)")); + if (path.isEmpty()) { + return; + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(parent, tr("Import failed"), file.errorString()); + return; + } + const auto document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject() || document.object().isEmpty()) { + QMessageBox::warning( + parent, tr("Import failed"), + tr("Expected a JSON object containing game configs keyed by PPSA code.")); + return; + } + const auto settings = document.object(); + for (auto it = settings.constBegin(); it != settings.constEnd(); ++it) { + Configuration info; + QString error; + if (!IsGameSettingsTitleId(it.key()) || !it.value().isObject() || + it.value().toObject().isEmpty() || + !info.SetGameSettings(it.value().toObject(), error)) { + QMessageBox::warning(parent, tr("Import failed"), + tr("Invalid game config for %1. %2").arg(it.key(), error)); + return; + } + } + struct ImportedGame { + ConfigurationItem* item; + std::unique_ptr info; + }; + std::vector imported; + auto unmatched = settings.keys(); + for (int index = 0; index < m_ui->cfgs_list->topLevelItemCount(); ++index) { + auto* item = static_cast(m_ui->cfgs_list->topLevelItem(index)); + const auto title_id = item->GetInfo().title_id.trimmed().toUpper(); + if (!settings.contains(title_id)) { + continue; + } + auto info = CreateConfiguration(*item); + QString error; + if (!info->SetGameSettings(settings.value(title_id).toObject(), error)) { + QMessageBox::warning(parent, tr("Import failed"), + tr("Invalid game config for %1. %2").arg(title_id, error)); + return; + } + info->custom_settings = true; + imported.push_back({item, std::move(info)}); + unmatched.removeAll(title_id); + } + auto message = imported.empty() ? tr("No matching games found in the library.") + : tr("Configs will be updated for the following games:\n"); + for (const auto& game: imported) { + message += tr("\n%1 — %2").arg(game.info->title_id.trimmed().toUpper(), game.info->name); + } + if (!unmatched.isEmpty()) { + message += tr("\nGames not found: %1").arg(unmatched.join(QStringLiteral(", "))); + } + if (imported.empty()) { + QMessageBox::information(parent, tr("Import game configs"), message); + return; + } + QMessageBox confirmation(QMessageBox::Question, tr("Import game configs"), message, + QMessageBox::Apply | QMessageBox::Cancel, parent); + confirmation.setTextFormat(Qt::PlainText); + confirmation.setDefaultButton(QMessageBox::Cancel); + if (confirmation.exec() != QMessageBox::Apply) { + return; + } + for (auto& [item, info]: imported) { + const auto game_path = info->game_path; + delete m_custom_infos.take(game_path); + m_custom_infos.insert(game_path, info.release()); + item->GetInfo().custom_settings = true; + item->Update(); + } + WriteSettings(); +} + +void ConfigurationListWidget::ExportGameSettings(QWidget* parent) const { + QMap games; + for (int index = 0; index < m_ui->cfgs_list->topLevelItemCount(); ++index) { + const auto* item = static_cast(m_ui->cfgs_list->topLevelItem(index)); + const auto& game = item->GetInfo(); + const auto title_id = game.title_id.trimmed().toUpper(); + if (m_custom_infos.contains(game.game_path) && IsGameSettingsTitleId(title_id)) { + games.insert(tr("%1 — %2 (%3)").arg(title_id, game.name, game.game_path), item); + } + } + if (games.isEmpty()) { + QMessageBox::information(parent, tr("Export game config"), + tr("No game configs with a PPSA code were found.")); + return; + } + bool accepted = false; + const auto selected = QInputDialog::getItem(parent, tr("Export game config"), tr("Game:"), + games.keys(), 0, false, &accepted); + if (!accepted) { + return; + } + const auto& game = games.value(selected)->GetInfo(); + const auto title_id = game.title_id.trimmed().toUpper(); + const auto path = + QFileDialog::getSaveFileName(parent, tr("Export game config"), + title_id + QStringLiteral(".json"), tr("JSON files (*.json)")); + if (path.isEmpty()) { + return; + } + const auto settings = + QJsonObject::fromVariantMap(m_custom_infos.value(game.game_path)->GameSettings()); + const auto data = + QJsonDocument(QJsonObject {{title_id, settings}}).toJson(QJsonDocument::Indented); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + QMessageBox::warning(parent, tr("Export failed"), file.errorString()); + } } void ConfigurationListWidget::edit_input_mapping() { @@ -1040,11 +1173,11 @@ void ConfigurationListWidget::show_context_menu(const QPoint& pos) { tr("Remove save data..."), this, SLOT(remove_save_data())); menu.addSeparator(); QAction* action_edit = - menu.addAction(style()->standardIcon(QStyle::SP_FileIcon), tr("Edit game settings..."), + menu.addAction(style()->standardIcon(QStyle::SP_FileIcon), tr("Edit game config..."), this, SLOT(edit_configuration())); QAction* action_delete = menu.addAction(style()->standardIcon(QStyle::SP_DialogDiscardButton), - tr("Clear custom settings"), this, SLOT(delete_configuartion())); + tr("Clear game config"), this, SLOT(delete_configuartion())); if (item == nullptr) { menu.addSeparator();