diff --git a/.gitmodules b/.gitmodules index 59b198e8e..612921585 100644 --- a/.gitmodules +++ b/.gitmodules @@ -112,3 +112,4 @@ [submodule "third_party/game-patches"] path = third_party/game-patches url = https://github.com/xenia-canary/game-patches.git + branch = main diff --git a/src/xenia/ui/game_list_dialog_qt.cc b/src/xenia/ui/game_list_dialog_qt.cc index 2ba94b12e..035849d2e 100644 --- a/src/xenia/ui/game_list_dialog_qt.cc +++ b/src/xenia/ui/game_list_dialog_qt.cc @@ -46,6 +46,7 @@ #include "xenia/kernel/xam/xdbf/gpd_info_title.h" #include "xenia/ui/achievements_dialog_qt.h" #include "xenia/ui/game_config_dialog_qt.h" +#include "xenia/ui/patches_dialog_qt.h" #include "xenia/ui/profile_dialog_qt.h" #include "xenia/ui/profile_editor_dialog_qt.h" #include "xenia/ui/qt_util.h" @@ -873,6 +874,50 @@ void GameListDialogQt::OnGameRightClicked(const QPoint& pos) { } } + // Patches option (always shown if we have a valid title_id, greyed out if no + // patches) + QMenu* patches_menu = nullptr; + if (title_id != 0) { + auto available_patches = FindPatchesForTitle(title_id); + patches_menu = context_menu.addMenu("Patches"); + + if (available_patches.empty()) { + // No patches found - disable the menu + patches_menu->setEnabled(false); + } else { + // Patches found - populate the submenu + for (const auto& patch_path : available_patches) { + // Extract a display name from the filename + // Format: "TITLEID - Game Name (Version).patch.toml" + std::string filename = patch_path.filename().string(); + + // Remove the title ID prefix and " - " + std::string display_name = filename; + size_t dash_pos = filename.find(" - "); + if (dash_pos != std::string::npos) { + display_name = filename.substr(dash_pos + 3); + } + + // Remove ".patch.toml" suffix + size_t suffix_pos = display_name.rfind(".patch.toml"); + if (suffix_pos != std::string::npos) { + display_name = display_name.substr(0, suffix_pos); + } + + QAction* patch_action = + patches_menu->addAction(SafeQString(display_name)); + + // Open patch management dialog + connect(patch_action, &QAction::triggered, [=, this]() { + auto* dialog = + new PatchesDialogQt(this, emulator_window_, title_id, patch_path); + dialog->exec(); + delete dialog; + }); + } + } + } + // Achievements option (enabled if user is logged in and we have a valid // title_id) QAction* achievements_action = nullptr; @@ -1442,6 +1487,79 @@ void GameListDialogQt::HideScrollbar() { "}"); } +std::vector GameListDialogQt::FindPatchesForTitle( + uint32_t title_id) { + std::vector patches; + std::map unique_patches; + + if (title_id == 0) { + return patches; + } + + if (!emulator_window_ || !emulator_window_->emulator()) { + return patches; + } + + // Create the title ID prefix to search for (e.g., "4D5307D5") + std::string title_id_prefix = fmt::format("{:08X}", title_id); + + // Helper lambda to scan a directory for patches + auto scan_patch_directory = [&](const std::filesystem::path& dir, + bool allow_overwrite) { + if (!std::filesystem::exists(dir) || !std::filesystem::is_directory(dir)) { + return; + } + + try { + for (const auto& entry : std::filesystem::directory_iterator(dir)) { + if (!entry.is_regular_file()) { + continue; + } + + auto filename = xe::path_to_utf8(entry.path().filename()); + + // Check if filename starts with the title ID and ends with .toml + if (filename.size() >= title_id_prefix.size() && + filename.compare(0, title_id_prefix.size(), title_id_prefix) == 0 && + filename.ends_with(".toml")) { + // Only add if we're allowed to overwrite or if not already present + if (allow_overwrite || + unique_patches.find(filename) == unique_patches.end()) { + unique_patches[filename] = entry.path(); + } + } + } + } catch (const std::filesystem::filesystem_error& e) { + XELOGE("Error scanning patches directory {}: {}", dir.string(), e.what()); + } + }; + + // First, scan storage_root/patches (writable, user-managed patches) + auto storage_patches_dir = + emulator_window_->emulator()->storage_root() / "patches"; + scan_patch_directory(storage_patches_dir, true); + + // Second, scan executable_dir/game_patches (bundled, read-only patches) + auto executable_path = xe::filesystem::GetExecutablePath(); + auto executable_dir = executable_path.parent_path(); + auto bundled_patches_dir = executable_dir / "game_patches"; + scan_patch_directory(bundled_patches_dir, + false); // Don't overwrite storage_root patches + + // Convert map to vector and sort by filename + for (const auto& [filename, path] : unique_patches) { + patches.push_back(path); + } + + std::sort(patches.begin(), patches.end(), + [](const std::filesystem::path& a, const std::filesystem::path& b) { + return xe::path_to_utf8(a.filename()) < + xe::path_to_utf8(b.filename()); + }); + + return patches; +} + void GameListDialogQt::ShowAchievementsDialog(uint64_t xuid, uint32_t title_id, const QString& title_name) { if (!emulator_window_ || !emulator_window_->emulator()) { diff --git a/src/xenia/ui/game_list_dialog_qt.h b/src/xenia/ui/game_list_dialog_qt.h index 338d3f591..a3b2139b0 100644 --- a/src/xenia/ui/game_list_dialog_qt.h +++ b/src/xenia/ui/game_list_dialog_qt.h @@ -86,6 +86,7 @@ class GameListDialogQt : public QWidget { void ShowAchievementsDialog(uint64_t xuid, uint32_t title_id, const QString& title_name = ""); std::string FormatLastPlayed(time_t timestamp); + std::vector FindPatchesForTitle(uint32_t title_id); EmulatorWindow* emulator_window_; std::vector game_entries_; diff --git a/src/xenia/ui/moc_patches_dialog_qt.cc b/src/xenia/ui/moc_patches_dialog_qt.cc new file mode 100644 index 000000000..9e8cc6e8c --- /dev/null +++ b/src/xenia/ui/moc_patches_dialog_qt.cc @@ -0,0 +1,87 @@ +/**************************************************************************** +** Meta object code from reading C++ file 'patches_dialog_qt.h' +** +** Created by: The Qt Meta Object Compiler version 69 (Qt 6.9.2) +** +** WARNING! All changes made in this file will be lost! +*****************************************************************************/ + +#include +#include "patches_dialog_qt.h" + +#include + +#include + +#include +#if !defined(Q_MOC_OUTPUT_REVISION) +#error "The header file 'patches_dialog_qt.h' doesn't include ." +#elif Q_MOC_OUTPUT_REVISION != 69 +#error "This file was generated using the moc from 6.9.2. It" +#error "cannot be used with the include files from this version of Qt." +#error "(The moc has changed too much.)" +#endif + +#ifndef Q_CONSTINIT +#define Q_CONSTINIT +#endif + +QT_WARNING_PUSH +QT_WARNING_DISABLE_DEPRECATED +QT_WARNING_DISABLE_GCC("-Wuseless-cast") +namespace { +struct qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t {}; +} // unnamed namespace + +template <> +constexpr inline auto xe::app::PatchesDialogQt::qt_create_metaobjectdata< + qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>() { + namespace QMC = QtMocConstants; + QtMocHelpers::StringRefStorage qt_stringData{"xe::app::PatchesDialogQt"}; + + QtMocHelpers::UintData qt_methods{}; + QtMocHelpers::UintData qt_properties{}; + QtMocHelpers::UintData qt_enums{}; + return QtMocHelpers::metaObjectData< + PatchesDialogQt, qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>( + QMC::MetaObjectFlag{}, qt_stringData, qt_methods, qt_properties, + qt_enums); +} +Q_CONSTINIT const QMetaObject xe::app::PatchesDialogQt::staticMetaObject = { { + QMetaObject::SuperData::link(), + qt_staticMetaObjectStaticContent.stringdata, + qt_staticMetaObjectStaticContent.data, + qt_static_metacall, + nullptr, + qt_staticMetaObjectRelocatingContent.metaTypes, + nullptr +} }; + +void xe::app::PatchesDialogQt::qt_static_metacall(QObject* _o, + QMetaObject::Call _c, int _id, + void** _a) { + auto* _t = static_cast(_o); + (void)_t; + (void)_c; + (void)_id; + (void)_a; +} + +const QMetaObject* xe::app::PatchesDialogQt::metaObject() const { + return QObject::d_ptr->metaObject ? QObject::d_ptr->dynamicMetaObject() + : &staticMetaObject; +} + +void* xe::app::PatchesDialogQt::qt_metacast(const char* _clname) { + if (!_clname) return nullptr; + if (!strcmp(_clname, qt_staticMetaObjectStaticContent.strings)) + return static_cast(this); + return QDialog::qt_metacast(_clname); +} + +int xe::app::PatchesDialogQt::qt_metacall(QMetaObject::Call _c, int _id, + void** _a) { + _id = QDialog::qt_metacall(_c, _id, _a); + return _id; +} +QT_WARNING_POP diff --git a/src/xenia/ui/patches_dialog_qt.cc b/src/xenia/ui/patches_dialog_qt.cc new file mode 100644 index 000000000..69b15fab2 --- /dev/null +++ b/src/xenia/ui/patches_dialog_qt.cc @@ -0,0 +1,390 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Xenia Canary. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/patches_dialog_qt.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/fmt/include/fmt/format.h" +#include "third_party/tomlplusplus/toml.hpp" +#include "xenia/app/emulator_window.h" +#include "xenia/base/logging.h" +#include "xenia/ui/qt_util.h" + +namespace xe { +namespace app { + +using xe::ui::SafeQString; + +PatchesDialogQt::PatchesDialogQt(QWidget* parent, + EmulatorWindow* emulator_window, + uint32_t title_id, + const std::filesystem::path& patch_file) + : QDialog(parent), + emulator_window_(emulator_window), + title_id_(title_id), + patch_file_(patch_file) { + SetupUI(); + LoadPatchFile(); +} + +PatchesDialogQt::~PatchesDialogQt() = default; + +void PatchesDialogQt::SetupUI() { + setWindowTitle("Patch Manager"); + setMinimumSize(600, 400); + resize(700, 500); + + auto* main_layout = new QVBoxLayout(this); + + // Extract display name from patch file + std::string filename = patch_file_.filename().string(); + std::string display_name = filename; + + // Remove title ID prefix and " - " + size_t dash_pos = filename.find(" - "); + if (dash_pos != std::string::npos) { + display_name = filename.substr(dash_pos + 3); + } + + // Remove ".patch.toml" suffix + size_t suffix_pos = display_name.rfind(".patch.toml"); + if (suffix_pos != std::string::npos) { + display_name = display_name.substr(0, suffix_pos); + } + + // Title label + auto* title_label = new QLabel(SafeQString(display_name), this); + QFont title_font = title_label->font(); + title_font.setPointSize(title_font.pointSize() + 2); + title_font.setBold(true); + title_label->setFont(title_font); + main_layout->addWidget(title_label); + + // Info label + info_label_ = new QLabel( + "Enable or disable individual patches. Changes take effect on next game " + "launch.", + this); + info_label_->setWordWrap(true); + info_label_->setStyleSheet("color: gray;"); + main_layout->addWidget(info_label_); + + // Scroll area for patches + scroll_area_ = new QScrollArea(this); + scroll_area_->setWidgetResizable(true); + scroll_area_->setFrameShape(QFrame::NoFrame); + + patches_container_ = new QWidget(this); + patches_layout_ = new QVBoxLayout(patches_container_); + patches_layout_->setAlignment(Qt::AlignTop); + + scroll_area_->setWidget(patches_container_); + main_layout->addWidget(scroll_area_, 1); + + // Close button + auto* button_layout = new QHBoxLayout(); + button_layout->addStretch(); + + auto* close_button = new QPushButton("Close", this); + connect(close_button, &QPushButton::clicked, this, &QDialog::accept); + button_layout->addWidget(close_button); + + main_layout->addLayout(button_layout); +} + +std::filesystem::path PatchesDialogQt::GetStorageRootPatchPath() { + if (!emulator_window_ || !emulator_window_->emulator()) { + return patch_file_; + } + + auto storage_patches_dir = + emulator_window_->emulator()->storage_root() / "patches"; + return storage_patches_dir / patch_file_.filename(); +} + +void PatchesDialogQt::LoadPatchFile() { + patches_.clear(); + + if (!std::filesystem::exists(patch_file_)) { + XELOGE("Patch file does not exist: {}", patch_file_.filename().string()); + return; + } + + try { + auto patch_toml = toml::parse_file(patch_file_.string()); + + // Check if there's a patch array + if (!patch_toml.contains("patch")) { + XELOGE("No patches found in file: {}", patch_file_.filename().string()); + return; + } + + auto patches_array = patch_toml["patch"].as_array(); + if (!patches_array) { + XELOGE("'patch' is not an array in file: {}", + patch_file_.filename().string()); + return; + } + + size_t patch_index = 0; + for (const auto& patch_entry : *patches_array) { + PatchInfo info; + info.patch_index = patch_index++; + + // Cast to table to access fields + auto* patch_table = patch_entry.as_table(); + if (!patch_table) { + continue; + } + + // Get patch name + if (auto name = (*patch_table)["name"].value()) { + info.name = *name; + } else { + info.name = fmt::format("Patch #{}", info.patch_index + 1); + } + + // Get patch description + if (auto desc = (*patch_table)["desc"].value()) { + info.description = *desc; + } + + // Get patch author + if (auto author = (*patch_table)["author"].value()) { + info.author = *author; + } + + // Get enabled state (default to false if not specified) + if (auto enabled = (*patch_table)["is_enabled"].value()) { + info.is_enabled = *enabled; + } else { + info.is_enabled = false; + } + + // Create checkbox for this patch + info.checkbox = new QCheckBox(SafeQString(info.name), patches_container_); + info.checkbox->setChecked(info.is_enabled); + + // Connect checkbox to save handler + connect(info.checkbox, &QCheckBox::toggled, this, + [this, patch_index = info.patch_index](bool checked) { + SavePatchToggle(patch_index, checked); + }); + + patches_layout_->addWidget(info.checkbox); + + // Add description/author as a sub-label if available + if (!info.description.empty() || !info.author.empty()) { + auto* detail_label = new QLabel(patches_container_); + QString detail_text; + + if (!info.description.empty()) { + detail_text = SafeQString(info.description); + } + + if (!info.author.empty()) { + if (!detail_text.isEmpty()) { + detail_text += "\n"; + } + detail_text += QString("Author: %1").arg(SafeQString(info.author)); + } + + detail_label->setText(detail_text); + detail_label->setWordWrap(true); + detail_label->setStyleSheet( + "color: gray; margin-left: 20px; margin-bottom: 10px;"); + patches_layout_->addWidget(detail_label); + } + + patches_.push_back(std::move(info)); + } + + if (patches_.empty()) { + auto* no_patches_label = + new QLabel("No patches found in this file.", patches_container_); + no_patches_label->setStyleSheet("color: gray;"); + patches_layout_->addWidget(no_patches_label); + } + + } catch (const toml::parse_error& err) { + XELOGE("Failed to parse patch file {}: {}", patch_file_.filename().string(), + err.what()); + + auto* error_label = + new QLabel(QString("Error loading patches: %1").arg(err.what()), + patches_container_); + error_label->setStyleSheet("color: red;"); + patches_layout_->addWidget(error_label); + } +} + +bool PatchesDialogQt::UpdateSinglePatchEnabledLine( + std::vector& lines, size_t patch_index, bool new_value) { + // Regex patterns for matching TOML structures + static const std::regex patch_header_regex(R"(^\s*\[\[patch\]\]\s*$)"); + static const std::regex section_header_regex(R"(^\s*\[([^\[\]]+)\]\s*$)"); + static const std::regex is_enabled_regex(R"(^\s*is_enabled\s*=\s*(.+))"); + static const std::regex comment_line_regex(R"(^\s*#.*)"); + + // Find all is_enabled lines in patch blocks + std::vector enabled_lines; + bool in_patch_block = false; + + for (size_t i = 0; i < lines.size(); ++i) { + const std::string& line = lines[i]; + + // Skip comment lines + if (std::regex_match(line, comment_line_regex)) { + continue; + } + + // Check for patch section marker + if (std::regex_match(line, patch_header_regex)) { + in_patch_block = true; + continue; + } + + // Check if we've hit another top-level section (not an array element) + if (std::regex_match(line, section_header_regex)) { + in_patch_block = false; + continue; + } + + // Look for is_enabled field within patch blocks + if (in_patch_block) { + if (std::regex_search(line, is_enabled_regex)) { + enabled_lines.push_back(i); + } + } + } + + // Check if the patch index is valid + if (patch_index >= enabled_lines.size()) { + XELOGE("Patch index {} out of range (found {} is_enabled fields)", + patch_index, enabled_lines.size()); + return false; + } + + // Update only the specific is_enabled line for this patch + size_t line_idx = enabled_lines[patch_index]; + std::string& enabled_line = lines[line_idx]; + + // Use regex to replace the value while preserving formatting + static const std::regex value_regex( + R"((^\s*is_enabled\s*=\s*)(true|false)(.*)$)"); + std::smatch match; + + if (std::regex_match(enabled_line, match, value_regex)) { + // match[1] = everything before the value + // match[2] = the current value (true/false) + // match[3] = everything after the value (comments) + lines[line_idx] = + match[1].str() + (new_value ? "true" : "false") + match[3].str(); + return true; + } else { + XELOGE("Failed to parse is_enabled line for patch #{}", patch_index + 1); + return false; + } +} + +void PatchesDialogQt::SavePatchToggle(size_t patch_index, bool new_value) { + if (!std::filesystem::exists(patch_file_)) { + XELOGE("Patch file does not exist: {}", patch_file_.filename().string()); + return; + } + + try { + auto storage_patch_path = GetStorageRootPatchPath(); + bool is_bundled_patch = (patch_file_ != storage_patch_path); + + // If this is a bundled patch, copy it to storage_root first + if (is_bundled_patch && !std::filesystem::exists(storage_patch_path)) { + auto storage_patches_dir = storage_patch_path.parent_path(); + + // Create storage patches directory if it doesn't exist + if (!std::filesystem::exists(storage_patches_dir)) { + std::filesystem::create_directories(storage_patches_dir); + } + + // Copy the bundled patch to storage_root + std::filesystem::copy_file( + patch_file_, storage_patch_path, + std::filesystem::copy_options::overwrite_existing); + + XELOGI("Copied bundled patch to storage_root: {}", + patch_file_.filename().string()); + } + + // Now read from and write to the storage_root version + auto write_path = is_bundled_patch ? storage_patch_path : patch_file_; + + // Read the file content to preserve comments and formatting + std::ifstream infile(write_path); + if (!infile.is_open()) { + XELOGE("Failed to open patch file for reading: {}", + write_path.filename().string()); + return; + } + + std::vector lines; + std::string line; + while (std::getline(infile, line)) { + lines.push_back(line); + } + infile.close(); + + // Update only the specific patch's is_enabled value + if (!UpdateSinglePatchEnabledLine(lines, patch_index, new_value)) { + XELOGE("Failed to update patch #{} enabled state in {}", patch_index + 1, + write_path.filename().string()); + return; + } + + // Write the modified content back to file + std::ofstream outfile(write_path, std::ios::out | std::ios::trunc); + if (!outfile.is_open()) { + XELOGE("Failed to open patch file for writing: {}", + write_path.filename().string()); + return; + } + + for (size_t i = 0; i < lines.size(); ++i) { + outfile << lines[i]; + if (i < lines.size() - 1) { + outfile << "\n"; + } + } + outfile.close(); + + XELOGI("Updated patch #{} in {}", patch_index + 1, + write_path.filename().string()); + + // Update info label to show changes pending + info_label_->setText( + "Changes saved. They will take effect on next game launch."); + info_label_->setStyleSheet("color: #107C10;"); // Green color + + } catch (const std::exception& e) { + XELOGE("Failed to save patch toggle: {}", e.what()); + + info_label_->setText(QString("Error saving changes: %1").arg(e.what())); + info_label_->setStyleSheet("color: red;"); + } +} + +} // namespace app +} // namespace xe diff --git a/src/xenia/ui/patches_dialog_qt.h b/src/xenia/ui/patches_dialog_qt.h new file mode 100644 index 000000000..68e32ba97 --- /dev/null +++ b/src/xenia/ui/patches_dialog_qt.h @@ -0,0 +1,69 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Xenia Canary. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_PATCHES_DIALOG_QT_H_ +#define XENIA_UI_PATCHES_DIALOG_QT_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xe { +namespace app { + +class EmulatorWindow; + +class PatchesDialogQt : public QDialog { + Q_OBJECT + + public: + PatchesDialogQt(QWidget* parent, EmulatorWindow* emulator_window, + uint32_t title_id, const std::filesystem::path& patch_file); + ~PatchesDialogQt() override; + + private: + struct PatchInfo { + std::string name; + std::string description; + std::string author; + bool is_enabled; + size_t patch_index; // Index in the TOML file + QCheckBox* checkbox; + }; + + void SetupUI(); + void LoadPatchFile(); + std::filesystem::path GetStorageRootPatchPath(); + void SavePatchToggle(size_t patch_index, bool new_value); + bool UpdateSinglePatchEnabledLine(std::vector& lines, + size_t patch_index, bool new_value); + + EmulatorWindow* emulator_window_; + uint32_t title_id_; + std::filesystem::path + patch_file_; // Original file location (could be exe_dir or storage_root) + std::vector patches_; + + // UI widgets + QScrollArea* scroll_area_; + QWidget* patches_container_; + QVBoxLayout* patches_layout_; + QLabel* info_label_; +}; + +} // namespace app +} // namespace xe + +#endif // XENIA_UI_PATCHES_DIALOG_QT_H_