[UI] Implement patches dialog

This commit is contained in:
Herman S.
2025-11-06 13:50:39 +09:00
parent a3fef01015
commit 3de6d4d891
6 changed files with 666 additions and 0 deletions
+1
View File
@@ -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
+118
View File
@@ -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<std::filesystem::path> GameListDialogQt::FindPatchesForTitle(
uint32_t title_id) {
std::vector<std::filesystem::path> patches;
std::map<std::string, std::filesystem::path> 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()) {
+1
View File
@@ -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<std::filesystem::path> FindPatchesForTitle(uint32_t title_id);
EmulatorWindow* emulator_window_;
std::vector<GameListEntry> game_entries_;
+87
View File
@@ -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 <QtCore/qmetatype.h>
#include "patches_dialog_qt.h"
#include <QtCore/qtmochelpers.h>
#include <memory>
#include <QtCore/qxptype_traits.h>
#if !defined(Q_MOC_OUTPUT_REVISION)
#error "The header file 'patches_dialog_qt.h' doesn't include <QObject>."
#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<QDialog::staticMetaObject>(),
qt_staticMetaObjectStaticContent<qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>.stringdata,
qt_staticMetaObjectStaticContent<qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>.data,
qt_static_metacall,
nullptr,
qt_staticMetaObjectRelocatingContent<qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>.metaTypes,
nullptr
} };
void xe::app::PatchesDialogQt::qt_static_metacall(QObject* _o,
QMetaObject::Call _c, int _id,
void** _a) {
auto* _t = static_cast<PatchesDialogQt*>(_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<qt_meta_tag_ZN2xe3app15PatchesDialogQtE_t>.strings))
return static_cast<void*>(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
+390
View File
@@ -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 <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QScrollBar>
#include <QSpacerItem>
#include <fstream>
#include <regex>
#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<std::string>()) {
info.name = *name;
} else {
info.name = fmt::format("Patch #{}", info.patch_index + 1);
}
// Get patch description
if (auto desc = (*patch_table)["desc"].value<std::string>()) {
info.description = *desc;
}
// Get patch author
if (auto author = (*patch_table)["author"].value<std::string>()) {
info.author = *author;
}
// Get enabled state (default to false if not specified)
if (auto enabled = (*patch_table)["is_enabled"].value<bool>()) {
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<std::string>& 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<size_t> 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<std::string> 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
+69
View File
@@ -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 <QCheckBox>
#include <QDialog>
#include <QLabel>
#include <QScrollArea>
#include <QVBoxLayout>
#include <filesystem>
#include <map>
#include <string>
#include <vector>
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<std::string>& 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<PatchInfo> 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_