mirror of
https://git.citron-emu.org/citron/emulator
synced 2025-12-20 02:53:57 +00:00
feat: Add XCI Trimmer and UI improvements
- Implement XCI file trimming with validation and Unicode support - Add trimming options to File menu and game properties dialog - Optimize rainbow mode performance globally (150ms timer, cached colors) - Add horizontal scrolling to game properties dialog - Fix compilation issues and improve code quality Thanks to Citron Tester Tetsuya Takahashi (高橋 哲屋) for extensive testing and contributions to the XCI Trimmer implementation. Co-authored-by: Tetsuya Takahashi <tetsuya@citron-emu.org> Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
@@ -174,14 +174,18 @@ ConfigureDialog::~ConfigureDialog() {
|
|||||||
void ConfigureDialog::UpdateTheme() {
|
void ConfigureDialog::UpdateTheme() {
|
||||||
QString accent_color_str;
|
QString accent_color_str;
|
||||||
if (UISettings::values.enable_rainbow_mode.GetValue()) {
|
if (UISettings::values.enable_rainbow_mode.GetValue()) {
|
||||||
rainbow_hue += 0.005f;
|
rainbow_hue += 0.003f; // Even slower transition for better performance
|
||||||
if (rainbow_hue > 1.0f) {
|
if (rainbow_hue > 1.0f) {
|
||||||
rainbow_hue = 0.0f;
|
rainbow_hue = 0.0f;
|
||||||
}
|
}
|
||||||
accent_color_str = QColor::fromHsvF(rainbow_hue, 0.8f, 1.0f).name();
|
|
||||||
|
// Cache the color to avoid repeated operations
|
||||||
|
QColor accent_color = QColor::fromHsvF(rainbow_hue, 0.8f, 1.0f);
|
||||||
|
accent_color_str = accent_color.name(QColor::HexRgb);
|
||||||
|
|
||||||
if (!rainbow_timer->isActive()) {
|
if (!rainbow_timer->isActive()) {
|
||||||
// THE FIX: Use a sane timer interval to prevent UI lag.
|
// Optimized timer interval for better performance
|
||||||
rainbow_timer->start(100);
|
rainbow_timer->start(150); // Increased from 100ms to 150ms
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (rainbow_timer->isActive()) {
|
if (rainbow_timer->isActive()) {
|
||||||
@@ -190,11 +194,18 @@ void ConfigureDialog::UpdateTheme() {
|
|||||||
accent_color_str = Theme::GetAccentColor();
|
accent_color_str = Theme::GetAccentColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache color operations to avoid repeated calculations
|
||||||
QColor accent_color(accent_color_str);
|
QColor accent_color(accent_color_str);
|
||||||
QString accent_color_hover = accent_color.lighter(115).name();
|
const QString accent_color_hover = accent_color.lighter(115).name(QColor::HexRgb);
|
||||||
QString accent_color_pressed = accent_color.darker(120).name();
|
const QString accent_color_pressed = accent_color.darker(120).name(QColor::HexRgb);
|
||||||
|
|
||||||
QString style_sheet = property("templateStyleSheet").toString();
|
// Get template stylesheet once and cache it
|
||||||
|
static QString cached_template_style_sheet;
|
||||||
|
if (cached_template_style_sheet.isEmpty()) {
|
||||||
|
cached_template_style_sheet = property("templateStyleSheet").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString style_sheet = cached_template_style_sheet;
|
||||||
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color_str);
|
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color_str);
|
||||||
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover);
|
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover);
|
||||||
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed);
|
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
#include <QAbstractButton>
|
#include <QAbstractButton>
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QProgressDialog>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
@@ -52,12 +57,15 @@
|
|||||||
#include "citron/uisettings.h"
|
#include "citron/uisettings.h"
|
||||||
#include "citron/util/util.h"
|
#include "citron/util/util.h"
|
||||||
#include "citron/vk_device_info.h"
|
#include "citron/vk_device_info.h"
|
||||||
|
#include "citron/main.h"
|
||||||
|
#include "common/string_util.h"
|
||||||
|
#include "common/xci_trimmer.h"
|
||||||
|
|
||||||
ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::string& file_name,
|
ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::string& file_name,
|
||||||
std::vector<VkDeviceInfo::Record>& vk_device_records,
|
std::vector<VkDeviceInfo::Record>& vk_device_records,
|
||||||
Core::System& system_)
|
Core::System& system_)
|
||||||
: QDialog(parent),
|
: QDialog(parent),
|
||||||
ui(std::make_unique<Ui::ConfigurePerGame>()), title_id{title_id_}, system{system_},
|
ui(std::make_unique<Ui::ConfigurePerGame>()), title_id{title_id_}, file_name{file_name}, system{system_},
|
||||||
builder{std::make_unique<ConfigurationShared::Builder>(this, !system_.IsPoweredOn())},
|
builder{std::make_unique<ConfigurationShared::Builder>(this, !system_.IsPoweredOn())},
|
||||||
tab_group{std::make_shared<std::vector<ConfigurationShared::Tab*>>()} ,
|
tab_group{std::make_shared<std::vector<ConfigurationShared::Tab*>>()} ,
|
||||||
rainbow_timer{new QTimer(this)} {
|
rainbow_timer{new QTimer(this)} {
|
||||||
@@ -133,6 +141,9 @@ rainbow_timer{new QTimer(this)} {
|
|||||||
&ConfigurePerGame::HandleApplyButtonClicked);
|
&ConfigurePerGame::HandleApplyButtonClicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect trim XCI button
|
||||||
|
connect(ui->trim_xci_button, &QPushButton::clicked, this, &ConfigurePerGame::OnTrimXCI);
|
||||||
|
|
||||||
LoadConfiguration();
|
LoadConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +227,7 @@ void ConfigurePerGame::UpdateTheme() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rainbow_hue += 0.01f;
|
rainbow_hue += 0.003f; // Even slower color transition for better performance
|
||||||
if (rainbow_hue > 1.0f) {
|
if (rainbow_hue > 1.0f) {
|
||||||
rainbow_hue = 0.0f;
|
rainbow_hue = 0.0f;
|
||||||
}
|
}
|
||||||
@@ -225,19 +236,24 @@ void ConfigurePerGame::UpdateTheme() {
|
|||||||
QColor accent_color_hover = accent_color.lighter(115);
|
QColor accent_color_hover = accent_color.lighter(115);
|
||||||
QColor accent_color_pressed = accent_color.darker(120);
|
QColor accent_color_pressed = accent_color.darker(120);
|
||||||
|
|
||||||
|
// Cache color names to avoid repeated string operations
|
||||||
|
const QString accent_color_name = accent_color.name(QColor::HexRgb);
|
||||||
|
const QString accent_color_hover_name = accent_color_hover.name(QColor::HexRgb);
|
||||||
|
const QString accent_color_pressed_name = accent_color_pressed.name(QColor::HexRgb);
|
||||||
|
|
||||||
// Efficiently update only the necessary widgets
|
// Efficiently update only the necessary widgets
|
||||||
QString tab_style = QStringLiteral(
|
QString tab_style = QStringLiteral(
|
||||||
"QTabBar::tab:selected { background-color: %1; border-color: %1; }")
|
"QTabBar::tab:selected { background-color: %1; border-color: %1; }")
|
||||||
.arg(accent_color.name(QColor::HexRgb));
|
.arg(accent_color_name);
|
||||||
ui->tabWidget->tabBar()->setStyleSheet(tab_style);
|
ui->tabWidget->tabBar()->setStyleSheet(tab_style);
|
||||||
|
|
||||||
QString button_style = QStringLiteral(
|
QString button_style = QStringLiteral(
|
||||||
"QPushButton { background-color: %1; color: #ffffff; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; min-height: 20px; }"
|
"QPushButton { background-color: %1; color: #ffffff; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; min-height: 20px; }"
|
||||||
"QPushButton:hover { background-color: %2; }"
|
"QPushButton:hover { background-color: %2; }"
|
||||||
"QPushButton:pressed { background-color: %3; }")
|
"QPushButton:pressed { background-color: %3; }")
|
||||||
.arg(accent_color.name(QColor::HexRgb))
|
.arg(accent_color_name)
|
||||||
.arg(accent_color_hover.name(QColor::HexRgb))
|
.arg(accent_color_hover_name)
|
||||||
.arg(accent_color_pressed.name(QColor::HexRgb));
|
.arg(accent_color_pressed_name);
|
||||||
|
|
||||||
ui->buttonBox->button(QDialogButtonBox::Ok)->setStyleSheet(button_style);
|
ui->buttonBox->button(QDialogButtonBox::Ok)->setStyleSheet(button_style);
|
||||||
ui->buttonBox->button(QDialogButtonBox::Cancel)->setStyleSheet(button_style);
|
ui->buttonBox->button(QDialogButtonBox::Cancel)->setStyleSheet(button_style);
|
||||||
@@ -245,11 +261,14 @@ void ConfigurePerGame::UpdateTheme() {
|
|||||||
apply_button->setStyleSheet(button_style);
|
apply_button->setStyleSheet(button_style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply rainbow mode to the Trim XCI button
|
||||||
|
ui->trim_xci_button->setStyleSheet(button_style);
|
||||||
|
|
||||||
// Create a temporary full stylesheet for the child tabs to update their internal widgets
|
// Create a temporary full stylesheet for the child tabs to update their internal widgets
|
||||||
QString child_stylesheet = property("templateStyleSheet").toString();
|
QString child_stylesheet = property("templateStyleSheet").toString();
|
||||||
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color.name(QColor::HexRgb));
|
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color_name);
|
||||||
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover.name(QColor::HexRgb));
|
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover_name);
|
||||||
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed.name(QColor::HexRgb));
|
child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed_name);
|
||||||
|
|
||||||
// Pass the updated stylesheet to the child tabs
|
// Pass the updated stylesheet to the child tabs
|
||||||
graphics_tab->SetTemplateStyleSheet(child_stylesheet);
|
graphics_tab->SetTemplateStyleSheet(child_stylesheet);
|
||||||
@@ -259,7 +278,7 @@ void ConfigurePerGame::UpdateTheme() {
|
|||||||
graphics_advanced_tab->SetTemplateStyleSheet(child_stylesheet);
|
graphics_advanced_tab->SetTemplateStyleSheet(child_stylesheet);
|
||||||
|
|
||||||
if (!rainbow_timer->isActive()) {
|
if (!rainbow_timer->isActive()) {
|
||||||
rainbow_timer->start(50); // Use a reasonable 50ms interval to prevent lag
|
rainbow_timer->start(150); // Further optimized 150ms interval for better performance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,3 +552,170 @@ void ConfigurePerGame::LoadConfiguration() {
|
|||||||
ui->display_update_build_id->setText(tr("Not Available"));
|
ui->display_update_build_id->setText(tr("Not Available"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConfigurePerGame::OnTrimXCI() {
|
||||||
|
// Use the stored file name from the constructor
|
||||||
|
if (file_name.empty()) {
|
||||||
|
QMessageBox::warning(this, tr("Trim XCI File"), tr("No file path available."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to filesystem path with proper Unicode support
|
||||||
|
const std::filesystem::path filepath = file_name;
|
||||||
|
|
||||||
|
// Check if the file is an XCI file
|
||||||
|
const std::string extension = filepath.extension().string();
|
||||||
|
if (extension != ".xci" && extension != ".XCI") {
|
||||||
|
QMessageBox::warning(this, tr("Trim XCI File"),
|
||||||
|
tr("This feature only works with XCI files."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!std::filesystem::exists(filepath)) {
|
||||||
|
QMessageBox::warning(this, tr("Trim XCI File"),
|
||||||
|
tr("The game file no longer exists."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the trimmer
|
||||||
|
Common::XCITrimmer trimmer(filepath);
|
||||||
|
if (!trimmer.IsValid()) {
|
||||||
|
QMessageBox::warning(this, tr("Trim XCI File"),
|
||||||
|
tr("Invalid XCI file or file cannot be read."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmer.CanBeTrimmed()) {
|
||||||
|
QMessageBox::information(this, tr("Trim XCI File"),
|
||||||
|
tr("This XCI file does not need to be trimmed."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show file information
|
||||||
|
const u64 current_size_mb = trimmer.GetFileSize() / (1024 * 1024);
|
||||||
|
const u64 data_size_mb = trimmer.GetDataSize() / (1024 * 1024);
|
||||||
|
const u64 savings_mb = trimmer.GetDiskSpaceSavings() / (1024 * 1024);
|
||||||
|
|
||||||
|
const QString info_message = tr(
|
||||||
|
"XCI File Information:\n\n"
|
||||||
|
"Current Size: %1 MB\n"
|
||||||
|
"Data Size: %2 MB\n"
|
||||||
|
"Potential Savings: %3 MB\n\n"
|
||||||
|
"This will remove unused space from the XCI file."
|
||||||
|
).arg(current_size_mb).arg(data_size_mb).arg(savings_mb);
|
||||||
|
|
||||||
|
// Create custom message box with three options
|
||||||
|
QMessageBox msgBox(this);
|
||||||
|
msgBox.setWindowTitle(tr("Trim XCI File"));
|
||||||
|
msgBox.setText(info_message);
|
||||||
|
msgBox.setIcon(QMessageBox::Question);
|
||||||
|
|
||||||
|
msgBox.addButton(tr("Trim In-Place"), QMessageBox::YesRole);
|
||||||
|
QPushButton* saveAsBtn = msgBox.addButton(tr("Save As Trimmed Copy"), QMessageBox::YesRole);
|
||||||
|
QPushButton* cancelBtn = msgBox.addButton(QMessageBox::Cancel);
|
||||||
|
|
||||||
|
msgBox.setDefaultButton(saveAsBtn);
|
||||||
|
msgBox.exec();
|
||||||
|
|
||||||
|
std::filesystem::path output_path;
|
||||||
|
bool is_save_as = false;
|
||||||
|
|
||||||
|
if (msgBox.clickedButton() == cancelBtn) {
|
||||||
|
return;
|
||||||
|
} else if (msgBox.clickedButton() == saveAsBtn) {
|
||||||
|
is_save_as = true;
|
||||||
|
QFileInfo file_info(QString::fromStdString(file_name));
|
||||||
|
const QString new_basename = file_info.completeBaseName() + QStringLiteral("_trimmed");
|
||||||
|
const QString new_filename = new_basename + QStringLiteral(".") + file_info.suffix();
|
||||||
|
const QString suggested_name = QDir(file_info.path()).filePath(new_filename);
|
||||||
|
|
||||||
|
const QString output_filename = QFileDialog::getSaveFileName(
|
||||||
|
this, tr("Save Trimmed XCI File As"), suggested_name,
|
||||||
|
tr("NX Cartridge Image (*.xci)"));
|
||||||
|
|
||||||
|
if (output_filename.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_path = std::filesystem::path{
|
||||||
|
Common::U16StringFromBuffer(output_filename.utf16(), output_filename.size())};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-translate strings for use in lambda
|
||||||
|
const QString checking_text = tr("Checking free space...");
|
||||||
|
const QString copying_text = tr("Copying file...");
|
||||||
|
|
||||||
|
// Track last operation to detect changes
|
||||||
|
size_t last_total = 0;
|
||||||
|
QString current_operation;
|
||||||
|
|
||||||
|
// Show progress dialog
|
||||||
|
QProgressDialog progress_dialog(tr("Preparing to trim XCI file..."), tr("Cancel"), 0, 100, this);
|
||||||
|
progress_dialog.setWindowTitle(tr("Trim XCI File"));
|
||||||
|
progress_dialog.setWindowModality(Qt::WindowModal);
|
||||||
|
progress_dialog.setMinimumDuration(0);
|
||||||
|
progress_dialog.show();
|
||||||
|
|
||||||
|
// Progress callback
|
||||||
|
auto progress_callback = [&](size_t current, size_t total) {
|
||||||
|
if (total > 0) {
|
||||||
|
// Detect operation change (when total changes significantly)
|
||||||
|
if (total != last_total) {
|
||||||
|
last_total = total;
|
||||||
|
if (current == 0 || current == total) {
|
||||||
|
// Likely switched operations
|
||||||
|
if (total < current_size_mb * 1024 * 1024) {
|
||||||
|
// Smaller total = checking padding
|
||||||
|
current_operation = checking_text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int percent = static_cast<int>((current * 100) / total);
|
||||||
|
progress_dialog.setValue(percent);
|
||||||
|
|
||||||
|
// Update label text based on operation
|
||||||
|
if (!current_operation.isEmpty()) {
|
||||||
|
const QString current_mb = QString::number(current / (1024.0 * 1024.0), 'f', 1);
|
||||||
|
const QString total_mb = QString::number(total / (1024.0 * 1024.0), 'f', 1);
|
||||||
|
const QString percent_str = QString::number(percent);
|
||||||
|
|
||||||
|
QString label_text = current_operation;
|
||||||
|
label_text += QStringLiteral("\n");
|
||||||
|
label_text += current_mb;
|
||||||
|
label_text += QStringLiteral(" / ");
|
||||||
|
label_text += total_mb;
|
||||||
|
label_text += QStringLiteral(" MB (");
|
||||||
|
label_text += percent_str;
|
||||||
|
label_text += QStringLiteral("%)");
|
||||||
|
|
||||||
|
progress_dialog.setLabelText(label_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel callback
|
||||||
|
auto cancel_callback = [&]() -> bool {
|
||||||
|
return progress_dialog.wasCanceled();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform the trim operation
|
||||||
|
const auto result = trimmer.Trim(progress_callback, cancel_callback, output_path);
|
||||||
|
progress_dialog.close();
|
||||||
|
|
||||||
|
// Show result
|
||||||
|
if (result == Common::XCITrimmer::OperationOutcome::Successful) {
|
||||||
|
const QString success_message = is_save_as ?
|
||||||
|
tr("XCI file successfully trimmed and saved as:\n%1")
|
||||||
|
.arg(QString::fromStdString(output_path.string())) :
|
||||||
|
tr("XCI file successfully trimmed in-place!");
|
||||||
|
|
||||||
|
QMessageBox::information(this, tr("Trim XCI File"), success_message);
|
||||||
|
} else {
|
||||||
|
const QString error_message = QString::fromStdString(
|
||||||
|
Common::XCITrimmer::GetOperationOutcomeString(result));
|
||||||
|
QMessageBox::warning(this, tr("Trim XCI File"),
|
||||||
|
tr("Failed to trim XCI file:\n%1").arg(error_message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public:
|
|||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void accept() override;
|
void accept() override;
|
||||||
|
void OnTrimXCI();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void changeEvent(QEvent* event) override;
|
void changeEvent(QEvent* event) override;
|
||||||
@@ -76,6 +77,7 @@ private:
|
|||||||
std::unique_ptr<Ui::ConfigurePerGame> ui;
|
std::unique_ptr<Ui::ConfigurePerGame> ui;
|
||||||
FileSys::VirtualFile file;
|
FileSys::VirtualFile file;
|
||||||
u64 title_id;
|
u64 title_id;
|
||||||
|
std::string file_name;
|
||||||
|
|
||||||
QGraphicsScene* scene;
|
QGraphicsScene* scene;
|
||||||
std::unique_ptr<QtConfig> game_config;
|
std::unique_ptr<QtConfig> game_config;
|
||||||
|
|||||||
@@ -448,7 +448,7 @@
|
|||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
<property name="horizontalScrollBarPolicy">
|
<property name="horizontalScrollBarPolicy">
|
||||||
<enum>Qt::ScrollBarAlwaysOff</enum>
|
<enum>Qt::ScrollBarAsNeeded</enum>
|
||||||
</property>
|
</property>
|
||||||
<property name="widgetResizable">
|
<property name="widgetResizable">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
@@ -667,6 +667,16 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="10" column="0" colspan="2">
|
||||||
|
<widget class="QPushButton" name="trim_xci_button">
|
||||||
|
<property name="text">
|
||||||
|
<string>Trim XCI File</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Remove unused space from XCI file to reduce file size</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
@@ -695,28 +705,59 @@
|
|||||||
<layout class="QVBoxLayout" name="verticalLayout_2"/>
|
<layout class="QVBoxLayout" name="verticalLayout_2"/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QTabWidget" name="tabWidget">
|
<widget class="QScrollArea" name="tabScrollArea">
|
||||||
<property name="enabled">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
<horstretch>0</horstretch>
|
<horstretch>0</horstretch>
|
||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="currentIndex">
|
<property name="verticalScrollBarPolicy">
|
||||||
<number>-1</number>
|
<enum>Qt::ScrollBarAlwaysOff</enum>
|
||||||
</property>
|
</property>
|
||||||
<property name="usesScrollButtons">
|
<property name="horizontalScrollBarPolicy">
|
||||||
|
<enum>Qt::ScrollBarAsNeeded</enum>
|
||||||
|
</property>
|
||||||
|
<property name="widgetResizable">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
<property name="documentMode">
|
<widget class="QWidget" name="tabScrollAreaWidgetContents">
|
||||||
<bool>false</bool>
|
<property name="geometry">
|
||||||
</property>
|
<rect>
|
||||||
<property name="tabsClosable">
|
<x>0</x>
|
||||||
<bool>false</bool>
|
<y>0</y>
|
||||||
</property>
|
<width>560</width>
|
||||||
|
<height>538</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="tabScrollAreaLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QTabWidget" name="tabWidget">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="currentIndex">
|
||||||
|
<number>-1</number>
|
||||||
|
</property>
|
||||||
|
<property name="usesScrollButtons">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="documentMode">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="tabsClosable">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual
|
|||||||
#include "common/x64/cpu_detect.h"
|
#include "common/x64/cpu_detect.h"
|
||||||
#endif
|
#endif
|
||||||
#include "common/settings.h"
|
#include "common/settings.h"
|
||||||
|
#include "common/string_util.h"
|
||||||
#include "common/telemetry.h"
|
#include "common/telemetry.h"
|
||||||
|
#include "common/xci_trimmer.h"
|
||||||
#include "core/core.h"
|
#include "core/core.h"
|
||||||
#include "core/core_timing.h"
|
#include "core/core_timing.h"
|
||||||
#include "core/crypto/key_manager.h"
|
#include "core/crypto/key_manager.h"
|
||||||
@@ -1580,6 +1582,7 @@ void GMainWindow::ConnectMenuEvents() {
|
|||||||
connect_menu(ui->action_Load_File, &GMainWindow::OnMenuLoadFile);
|
connect_menu(ui->action_Load_File, &GMainWindow::OnMenuLoadFile);
|
||||||
connect_menu(ui->action_Load_Folder, &GMainWindow::OnMenuLoadFolder);
|
connect_menu(ui->action_Load_Folder, &GMainWindow::OnMenuLoadFolder);
|
||||||
connect_menu(ui->action_Install_File_NAND, &GMainWindow::OnMenuInstallToNAND);
|
connect_menu(ui->action_Install_File_NAND, &GMainWindow::OnMenuInstallToNAND);
|
||||||
|
connect_menu(ui->action_Trim_XCI_File, &GMainWindow::OnMenuTrimXCI);
|
||||||
connect_menu(ui->action_Exit, &QMainWindow::close);
|
connect_menu(ui->action_Exit, &QMainWindow::close);
|
||||||
connect_menu(ui->action_Load_Amiibo, &GMainWindow::OnLoadAmiibo);
|
connect_menu(ui->action_Load_Amiibo, &GMainWindow::OnLoadAmiibo);
|
||||||
|
|
||||||
@@ -3385,6 +3388,216 @@ void GMainWindow::OnMenuInstallToNAND() {
|
|||||||
ui->action_Install_File_NAND->setEnabled(true);
|
ui->action_Install_File_NAND->setEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GMainWindow::OnMenuTrimXCI() {
|
||||||
|
const QString file_filter = tr("NX Cartridge Image (*.xci)");
|
||||||
|
|
||||||
|
const QString filename = QFileDialog::getOpenFileName(
|
||||||
|
this, tr("Select XCI File to Trim"), QString::fromStdString(UISettings::values.roms_path),
|
||||||
|
file_filter);
|
||||||
|
|
||||||
|
if (filename.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save folder location
|
||||||
|
UISettings::values.roms_path = QFileInfo(filename).path().toStdString();
|
||||||
|
|
||||||
|
// Convert QString to filesystem::path with proper Unicode support
|
||||||
|
const std::filesystem::path filepath =
|
||||||
|
std::filesystem::path{Common::U16StringFromBuffer(filename.utf16(), filename.size())};
|
||||||
|
|
||||||
|
// Create trimmer and check if file is valid
|
||||||
|
Common::XCITrimmer trimmer(filepath);
|
||||||
|
|
||||||
|
if (!trimmer.IsValid()) {
|
||||||
|
QMessageBox::critical(this, tr("Trim XCI File"),
|
||||||
|
tr("The selected file is not a valid XCI file."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmer.CanBeTrimmed()) {
|
||||||
|
QMessageBox::information(this, tr("Trim XCI File"),
|
||||||
|
tr("The XCI file does not need to be trimmed (already trimmed or no padding)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show confirmation dialog with savings information
|
||||||
|
const double current_size_mb = static_cast<double>(trimmer.GetFileSize()) / (1024.0 * 1024.0);
|
||||||
|
const double data_size_mb = static_cast<double>(trimmer.GetDataSize()) / (1024.0 * 1024.0);
|
||||||
|
const double savings_mb = static_cast<double>(trimmer.GetDiskSpaceSavings()) / (1024.0 * 1024.0);
|
||||||
|
|
||||||
|
const QString info_message = tr(
|
||||||
|
"This function will check the empty space and then trim the XCI file to save disk space.\n\n"
|
||||||
|
"Current file size: %1 MB\n"
|
||||||
|
"Data size: %2 MB\n"
|
||||||
|
"Potential savings: %3 MB\n\n"
|
||||||
|
"How would you like to proceed?")
|
||||||
|
.arg(QString::number(current_size_mb, 'f', 2))
|
||||||
|
.arg(QString::number(data_size_mb, 'f', 2))
|
||||||
|
.arg(QString::number(savings_mb, 'f', 2));
|
||||||
|
|
||||||
|
// Create custom message box with three options
|
||||||
|
QMessageBox msgBox(this);
|
||||||
|
msgBox.setWindowTitle(tr("Trim XCI File"));
|
||||||
|
msgBox.setText(info_message);
|
||||||
|
msgBox.setIcon(QMessageBox::Question);
|
||||||
|
|
||||||
|
msgBox.addButton(tr("Trim In-Place"), QMessageBox::YesRole);
|
||||||
|
QPushButton* saveAsBtn = msgBox.addButton(tr("Save As Trimmed Copy"), QMessageBox::YesRole);
|
||||||
|
QPushButton* cancelBtn = msgBox.addButton(QMessageBox::Cancel);
|
||||||
|
|
||||||
|
msgBox.setDefaultButton(saveAsBtn);
|
||||||
|
msgBox.exec();
|
||||||
|
|
||||||
|
std::filesystem::path output_path;
|
||||||
|
bool is_save_as = false;
|
||||||
|
|
||||||
|
if (msgBox.clickedButton() == cancelBtn) {
|
||||||
|
return;
|
||||||
|
} else if (msgBox.clickedButton() == saveAsBtn) {
|
||||||
|
// User wants to save to a new file
|
||||||
|
is_save_as = true;
|
||||||
|
|
||||||
|
// Suggest default filename with _trimmed suffix
|
||||||
|
QFileInfo file_info(filename);
|
||||||
|
const QString new_basename = file_info.completeBaseName() + QStringLiteral("_trimmed");
|
||||||
|
const QString new_filename = new_basename + QStringLiteral(".") + file_info.suffix();
|
||||||
|
const QString suggested_name = QDir(file_info.path()).filePath(new_filename);
|
||||||
|
|
||||||
|
const QString output_filename = QFileDialog::getSaveFileName(
|
||||||
|
this, tr("Save Trimmed XCI File As"), suggested_name,
|
||||||
|
tr("NX Cartridge Image (*.xci)"));
|
||||||
|
|
||||||
|
if (output_filename.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert QString to filesystem::path with proper Unicode support
|
||||||
|
output_path = std::filesystem::path{
|
||||||
|
Common::U16StringFromBuffer(output_filename.utf16(), output_filename.size())};
|
||||||
|
}
|
||||||
|
// else: trim in-place (output_path remains empty)
|
||||||
|
|
||||||
|
// Create progress dialog with proper range
|
||||||
|
QProgressDialog progress_dialog(tr("Preparing..."), tr("Cancel"), 0, 100, this);
|
||||||
|
progress_dialog.setWindowFlags(windowFlags() & ~Qt::WindowMaximizeButtonHint);
|
||||||
|
progress_dialog.setWindowModality(Qt::WindowModal);
|
||||||
|
progress_dialog.setMinimumDuration(0);
|
||||||
|
progress_dialog.setAutoClose(false);
|
||||||
|
progress_dialog.setAutoReset(false);
|
||||||
|
progress_dialog.setValue(0);
|
||||||
|
progress_dialog.show();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
bool cancelled = false;
|
||||||
|
QString current_operation;
|
||||||
|
|
||||||
|
// Pre-translate strings for use in lambda
|
||||||
|
const QString checking_text = tr("Checking free space...");
|
||||||
|
const QString copying_text = tr("Copying file...");
|
||||||
|
|
||||||
|
// Track last operation to detect changes
|
||||||
|
size_t last_total = 0;
|
||||||
|
|
||||||
|
// Progress callback
|
||||||
|
auto progress_callback = [&](size_t current, size_t total) {
|
||||||
|
if (total > 0) {
|
||||||
|
// Detect operation change (when total changes significantly)
|
||||||
|
if (total != last_total) {
|
||||||
|
last_total = total;
|
||||||
|
if (current == 0 || current == total) {
|
||||||
|
// Likely switched operations
|
||||||
|
if (total < current_size_mb * 1024 * 1024) {
|
||||||
|
// Smaller total = checking padding
|
||||||
|
current_operation = checking_text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int percent = static_cast<int>((current * 100) / total);
|
||||||
|
progress_dialog.setValue(percent);
|
||||||
|
|
||||||
|
// Update label text based on operation
|
||||||
|
if (!current_operation.isEmpty()) {
|
||||||
|
const QString current_mb = QString::number(current / (1024.0 * 1024.0), 'f', 1);
|
||||||
|
const QString total_mb = QString::number(total / (1024.0 * 1024.0), 'f', 1);
|
||||||
|
const QString percent_str = QString::number(percent);
|
||||||
|
|
||||||
|
QString label_text = current_operation;
|
||||||
|
label_text += QStringLiteral("\n");
|
||||||
|
label_text += current_mb;
|
||||||
|
label_text += QStringLiteral(" / ");
|
||||||
|
label_text += total_mb;
|
||||||
|
label_text += QStringLiteral(" MB (");
|
||||||
|
label_text += percent_str;
|
||||||
|
label_text += QStringLiteral("%)");
|
||||||
|
|
||||||
|
progress_dialog.setLabelText(label_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel callback
|
||||||
|
auto cancel_callback = [&]() -> bool {
|
||||||
|
cancelled = progress_dialog.wasCanceled();
|
||||||
|
return cancelled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform trim operation
|
||||||
|
ui->action_Trim_XCI_File->setEnabled(false);
|
||||||
|
|
||||||
|
// Set initial operation text
|
||||||
|
if (is_save_as) {
|
||||||
|
current_operation = copying_text;
|
||||||
|
progress_dialog.setLabelText(current_operation);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto outcome = trimmer.Trim(progress_callback, cancel_callback, output_path);
|
||||||
|
ui->action_Trim_XCI_File->setEnabled(true);
|
||||||
|
|
||||||
|
progress_dialog.close();
|
||||||
|
|
||||||
|
// Show result
|
||||||
|
if (outcome == Common::XCITrimmer::OperationOutcome::Successful) {
|
||||||
|
// Calculate final size based on whether it was save-as or in-place
|
||||||
|
const double final_size_mb = is_save_as ? data_size_mb :
|
||||||
|
static_cast<double>(trimmer.GetFileSize()) / (1024.0 * 1024.0);
|
||||||
|
const double actual_savings_mb = current_size_mb - final_size_mb;
|
||||||
|
|
||||||
|
QString success_message;
|
||||||
|
if (is_save_as) {
|
||||||
|
success_message = tr("Successfully created trimmed XCI file!\n\n"
|
||||||
|
"Original file: %1\n"
|
||||||
|
"Original size: %2 MB\n"
|
||||||
|
"New file: %3\n"
|
||||||
|
"New size: %4 MB\n"
|
||||||
|
"Space saved: %5 MB")
|
||||||
|
.arg(QFileInfo(filename).fileName())
|
||||||
|
.arg(QString::number(current_size_mb, 'f', 2))
|
||||||
|
.arg(QFileInfo(QString::fromStdString(output_path.string())).fileName())
|
||||||
|
.arg(QString::number(final_size_mb, 'f', 2))
|
||||||
|
.arg(QString::number(actual_savings_mb, 'f', 2));
|
||||||
|
} else {
|
||||||
|
success_message = tr("Successfully trimmed XCI file!\n\n"
|
||||||
|
"Original size: %1 MB\n"
|
||||||
|
"New size: %2 MB\n"
|
||||||
|
"Space saved: %3 MB")
|
||||||
|
.arg(QString::number(current_size_mb, 'f', 2))
|
||||||
|
.arg(QString::number(final_size_mb, 'f', 2))
|
||||||
|
.arg(QString::number(actual_savings_mb, 'f', 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
QMessageBox::information(this, tr("Trim XCI File"), success_message);
|
||||||
|
} else {
|
||||||
|
const QString error_message = QString::fromStdString(
|
||||||
|
Common::XCITrimmer::GetOperationOutcomeString(outcome));
|
||||||
|
QMessageBox::critical(this, tr("Trim XCI File"),
|
||||||
|
tr("Failed to trim XCI file: %1").arg(error_message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ContentManager::InstallResult GMainWindow::InstallNCA(const QString& filename) {
|
ContentManager::InstallResult GMainWindow::InstallNCA(const QString& filename) {
|
||||||
const QStringList tt_options{tr("System Application"),
|
const QStringList tt_options{tr("System Application"),
|
||||||
tr("System Archive"),
|
tr("System Archive"),
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ private slots:
|
|||||||
void OnMenuLoadFolder();
|
void OnMenuLoadFolder();
|
||||||
void IncrementInstallProgress();
|
void IncrementInstallProgress();
|
||||||
void OnMenuInstallToNAND();
|
void OnMenuInstallToNAND();
|
||||||
|
void OnMenuTrimXCI();
|
||||||
void OnMenuRecentFile();
|
void OnMenuRecentFile();
|
||||||
void OnConfigure();
|
void OnConfigure();
|
||||||
void OnConfigureTas();
|
void OnConfigureTas();
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
<addaction name="action_Install_File_NAND"/>
|
<addaction name="action_Install_File_NAND"/>
|
||||||
|
<addaction name="action_Trim_XCI_File"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="action_Load_File"/>
|
<addaction name="action_Load_File"/>
|
||||||
<addaction name="action_Load_Folder"/>
|
<addaction name="action_Load_Folder"/>
|
||||||
@@ -206,6 +207,14 @@
|
|||||||
<string>&Install Files to NAND...</string>
|
<string>&Install Files to NAND...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="action_Trim_XCI_File">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>&Trim XCI File...</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
<action name="action_Load_File">
|
<action name="action_Load_File">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>L&oad File...</string>
|
<string>L&oad File...</string>
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ add_library(common STATIC
|
|||||||
virtual_buffer.h
|
virtual_buffer.h
|
||||||
wall_clock.cpp
|
wall_clock.cpp
|
||||||
wall_clock.h
|
wall_clock.h
|
||||||
|
xci_trimmer.cpp
|
||||||
|
xci_trimmer.h
|
||||||
zstd_compression.cpp
|
zstd_compression.cpp
|
||||||
zstd_compression.h
|
zstd_compression.h
|
||||||
)
|
)
|
||||||
|
|||||||
509
src/common/xci_trimmer.cpp
Normal file
509
src/common/xci_trimmer.cpp
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "common/fs/file.h"
|
||||||
|
#include "common/fs/fs.h"
|
||||||
|
#include "common/logging/log.h"
|
||||||
|
#include "common/xci_trimmer.h"
|
||||||
|
|
||||||
|
namespace Common {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::array<std::pair<u8, u64>, 7> CART_SIZES_GB = {{
|
||||||
|
{static_cast<u8>(0xFA), 1},
|
||||||
|
{static_cast<u8>(0xF8), 2},
|
||||||
|
{static_cast<u8>(0xF0), 4},
|
||||||
|
{static_cast<u8>(0xE0), 8},
|
||||||
|
{static_cast<u8>(0xE1), 16},
|
||||||
|
{static_cast<u8>(0xE2), 32},
|
||||||
|
{static_cast<u8>(0xE3), 64}, // Switch 2 cartridge support
|
||||||
|
}};
|
||||||
|
|
||||||
|
u64 RecordsToByte(u64 records) {
|
||||||
|
return 512 + (records * 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 GetCartSizeGB(u8 cart_size_id) {
|
||||||
|
for (const auto& [id, size] : CART_SIZES_GB) {
|
||||||
|
if (id == cart_size_id) {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // Anonymous namespace
|
||||||
|
|
||||||
|
XCITrimmer::XCITrimmer(const std::filesystem::path& path) : filename(path) {
|
||||||
|
ReadHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
XCITrimmer::~XCITrimmer() = default;
|
||||||
|
|
||||||
|
bool XCITrimmer::IsValid() const {
|
||||||
|
return file_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::CanBeTrimmed() const {
|
||||||
|
return file_ok && file_size_bytes > (offset_bytes + data_size_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 XCITrimmer::GetDataSize() const {
|
||||||
|
return data_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 XCITrimmer::GetCartSize() const {
|
||||||
|
return cart_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 XCITrimmer::GetFileSize() const {
|
||||||
|
return file_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 XCITrimmer::GetDiskSpaceSavings() const {
|
||||||
|
return cart_size_bytes - data_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::ReadHeader() {
|
||||||
|
try {
|
||||||
|
// Use Common::FS::IOFile for proper Unicode support on all platforms
|
||||||
|
FS::IOFile file(filename, FS::FileAccessMode::Read, FS::FileType::BinaryFile);
|
||||||
|
|
||||||
|
if (!file.IsOpen()) {
|
||||||
|
LOG_ERROR(Common, "Failed to open XCI file: {}", filename.string());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
file_size_bytes = file.GetSize();
|
||||||
|
|
||||||
|
if (file_size_bytes < 32 * 1024) {
|
||||||
|
LOG_ERROR(Common, "File too small to be an XCI file");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try without key area first
|
||||||
|
auto check_header = [&](bool assume_key_area) -> bool {
|
||||||
|
offset_bytes = assume_key_area ? CART_KEY_AREA_SIZE : 0;
|
||||||
|
|
||||||
|
// Check header magic
|
||||||
|
if (!file.Seek(offset_bytes + HEADER_FILE_POS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
u32 magic;
|
||||||
|
if (!file.ReadObject(magic)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (magic != MAGIC_VALUE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read cart size
|
||||||
|
if (!file.Seek(offset_bytes + CART_SIZE_FILE_POS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
u8 cart_size_id;
|
||||||
|
if (!file.ReadObject(cart_size_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u64 cart_size_gb = GetCartSizeGB(cart_size_id);
|
||||||
|
if (cart_size_gb == 0) {
|
||||||
|
LOG_ERROR(Common, "Invalid cartridge size: 0x{:02X}", cart_size_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cart_size_bytes = cart_size_gb * CART_SIZE_MB_IN_FORMATTED_GB * BYTES_IN_A_MEGABYTE;
|
||||||
|
|
||||||
|
// Read data size
|
||||||
|
if (!file.Seek(offset_bytes + DATA_SIZE_FILE_POS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
u32 records;
|
||||||
|
if (!file.ReadObject(records)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
data_size_bytes = RecordsToByte(records);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try without key area first
|
||||||
|
bool success = check_header(false);
|
||||||
|
if (!success) {
|
||||||
|
// Try with key area
|
||||||
|
success = check_header(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_ok = success;
|
||||||
|
return success;
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR(Common, "Exception while reading XCI header: {}", e.what());
|
||||||
|
file_ok = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::CheckPadding(size_t read_size, CancelCallback cancel_callback,
|
||||||
|
ProgressCallback progress_callback) {
|
||||||
|
// Use Common::FS::IOFile for proper Unicode support on all platforms
|
||||||
|
FS::IOFile file(filename, FS::FileAccessMode::Read, FS::FileType::BinaryFile);
|
||||||
|
|
||||||
|
if (!file.IsOpen()) {
|
||||||
|
LOG_ERROR(Common, "Failed to open file for padding check");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.Seek(offset_bytes + data_size_bytes)) {
|
||||||
|
LOG_ERROR(Common, "Failed to seek to padding area");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// More conservative approach: only trim if we find a large block of consecutive padding
|
||||||
|
// at the END of the file, not just any padding
|
||||||
|
const size_t MIN_PADDING_BLOCK_SIZE = 1024 * 1024; // 1MB minimum padding block
|
||||||
|
const size_t SAFETY_MARGIN = 64 * 1024; // 64KB safety margin
|
||||||
|
|
||||||
|
std::vector<u8> buffer(BUFFER_SIZE);
|
||||||
|
size_t bytes_left = read_size;
|
||||||
|
size_t bytes_processed = 0;
|
||||||
|
size_t consecutive_padding = 0;
|
||||||
|
size_t last_non_padding_pos = 0;
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Checking for safe padding with {} MB minimum block size and {} KB safety margin",
|
||||||
|
MIN_PADDING_BLOCK_SIZE / (1024 * 1024), SAFETY_MARGIN / 1024);
|
||||||
|
|
||||||
|
while (bytes_left > 0) {
|
||||||
|
if (cancel_callback && cancel_callback()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t to_read = std::min<size_t>(BUFFER_SIZE, bytes_left);
|
||||||
|
const size_t bytes_read = file.ReadSpan(std::span<u8>(buffer.data(), to_read));
|
||||||
|
|
||||||
|
if (bytes_read == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for padding in this block
|
||||||
|
for (size_t i = 0; i < bytes_read; i++) {
|
||||||
|
if (buffer[i] == PADDING_BYTE) {
|
||||||
|
consecutive_padding++;
|
||||||
|
} else {
|
||||||
|
// Found non-padding data - reset counter and record position
|
||||||
|
if (consecutive_padding > 0) {
|
||||||
|
LOG_DEBUG(Common, "Found {} consecutive padding bytes, but non-padding data at offset {}",
|
||||||
|
consecutive_padding, bytes_processed + i);
|
||||||
|
}
|
||||||
|
consecutive_padding = 0;
|
||||||
|
last_non_padding_pos = bytes_processed + i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes_left -= bytes_read;
|
||||||
|
bytes_processed += bytes_read;
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
if (progress_callback) {
|
||||||
|
progress_callback(bytes_processed, read_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow trimming if we have a large enough padding block at the very end
|
||||||
|
// and we're not too close to the actual data
|
||||||
|
const size_t actual_data_end = offset_bytes + data_size_bytes + last_non_padding_pos + 1;
|
||||||
|
const size_t proposed_trim_point = offset_bytes + data_size_bytes + consecutive_padding;
|
||||||
|
|
||||||
|
// Ensure we have enough padding and maintain safety margin
|
||||||
|
if (consecutive_padding < MIN_PADDING_BLOCK_SIZE) {
|
||||||
|
LOG_WARNING(Common, "Insufficient padding block size: {} bytes (minimum: {} bytes)",
|
||||||
|
consecutive_padding, MIN_PADDING_BLOCK_SIZE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we're not trimming too close to actual data
|
||||||
|
if (proposed_trim_point < actual_data_end + SAFETY_MARGIN) {
|
||||||
|
LOG_WARNING(Common, "Proposed trim point too close to data: {} bytes from data end (minimum: {} bytes)",
|
||||||
|
proposed_trim_point - actual_data_end, SAFETY_MARGIN);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Safe padding found: {} bytes of consecutive padding at end, {} bytes from last data",
|
||||||
|
consecutive_padding, proposed_trim_point - actual_data_end);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::CheckFreeSpace(CancelCallback cancel_callback,
|
||||||
|
ProgressCallback progress_callback) {
|
||||||
|
if (free_space_checked) {
|
||||||
|
return free_space_valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!CanBeTrimmed()) {
|
||||||
|
LOG_WARNING(Common, "File cannot be trimmed, no free space to check");
|
||||||
|
free_space_valid = false;
|
||||||
|
free_space_checked = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u64 trimmed_size = offset_bytes + data_size_bytes;
|
||||||
|
const size_t read_size = file_size_bytes - trimmed_size;
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Checking {} MB of free space", read_size / BYTES_IN_A_MEGABYTE);
|
||||||
|
|
||||||
|
// Report that we're starting the padding check
|
||||||
|
if (progress_callback) {
|
||||||
|
progress_callback(0, read_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
free_space_valid = CheckPadding(read_size, cancel_callback, progress_callback);
|
||||||
|
free_space_checked = true;
|
||||||
|
|
||||||
|
if (free_space_valid) {
|
||||||
|
LOG_INFO(Common, "Free space is valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return free_space_valid;
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR(Common, "Exception during free space check: {}", e.what());
|
||||||
|
free_space_valid = false;
|
||||||
|
free_space_checked = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
XCITrimmer::OperationOutcome XCITrimmer::Trim(ProgressCallback progress_callback,
|
||||||
|
CancelCallback cancel_callback,
|
||||||
|
const std::filesystem::path& output_path) {
|
||||||
|
if (!file_ok) {
|
||||||
|
return OperationOutcome::InvalidXCIFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanBeTrimmed()) {
|
||||||
|
return OperationOutcome::NoTrimNecessary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!free_space_checked) {
|
||||||
|
CheckFreeSpace(cancel_callback, progress_callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!free_space_valid) {
|
||||||
|
if (cancel_callback && cancel_callback()) {
|
||||||
|
return OperationOutcome::Cancelled;
|
||||||
|
}
|
||||||
|
return OperationOutcome::FreeSpaceCheckFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target file path (output_path or original filename)
|
||||||
|
const auto target_path = output_path.empty() ? filename : output_path;
|
||||||
|
const bool is_save_as = !output_path.empty() && (output_path != filename);
|
||||||
|
|
||||||
|
if (is_save_as) {
|
||||||
|
LOG_INFO(Common, "Trimming XCI file to new location: {}", target_path.string());
|
||||||
|
} else {
|
||||||
|
LOG_INFO(Common, "Trimming XCI file in-place...");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If saving to a new file, copy first
|
||||||
|
if (is_save_as) {
|
||||||
|
LOG_INFO(Common, "Copying file...");
|
||||||
|
|
||||||
|
// Report copy progress
|
||||||
|
if (progress_callback) {
|
||||||
|
progress_callback(0, file_size_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::copy_file(filename, target_path,
|
||||||
|
std::filesystem::copy_options::overwrite_existing);
|
||||||
|
|
||||||
|
// Report copy complete
|
||||||
|
if (progress_callback) {
|
||||||
|
progress_callback(file_size_bytes, file_size_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancel_callback && cancel_callback()) {
|
||||||
|
std::filesystem::remove(target_path);
|
||||||
|
return OperationOutcome::Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target file is read-only
|
||||||
|
const auto perms = std::filesystem::status(target_path).permissions();
|
||||||
|
const bool is_readonly = (perms & std::filesystem::perms::owner_write) ==
|
||||||
|
std::filesystem::perms::none;
|
||||||
|
|
||||||
|
if (is_readonly) {
|
||||||
|
LOG_INFO(Common, "Attempting to remove read-only attribute");
|
||||||
|
try {
|
||||||
|
std::filesystem::permissions(target_path,
|
||||||
|
std::filesystem::perms::owner_write,
|
||||||
|
std::filesystem::perm_options::add);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR(Common, "Failed to remove read-only attribute: {}", e.what());
|
||||||
|
if (is_save_as) {
|
||||||
|
std::filesystem::remove(target_path);
|
||||||
|
}
|
||||||
|
return OperationOutcome::ReadOnlyFileCannotFix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size hasn't changed (check original if in-place, or target if save-as)
|
||||||
|
const auto current_size = std::filesystem::file_size(target_path);
|
||||||
|
if (current_size != file_size_bytes) {
|
||||||
|
LOG_ERROR(Common, "File size has changed, cannot safely trim");
|
||||||
|
if (is_save_as) {
|
||||||
|
std::filesystem::remove(target_path);
|
||||||
|
}
|
||||||
|
return OperationOutcome::FileSizeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim the file
|
||||||
|
const u64 trimmed_size = offset_bytes + data_size_bytes;
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Trimming XCI: offset={} bytes, data_size={} bytes, trimmed_size={} bytes, original_size={} bytes",
|
||||||
|
offset_bytes, data_size_bytes, trimmed_size, file_size_bytes);
|
||||||
|
|
||||||
|
std::filesystem::resize_file(target_path, trimmed_size);
|
||||||
|
|
||||||
|
// Verify the file was trimmed successfully
|
||||||
|
const auto final_size = std::filesystem::file_size(target_path);
|
||||||
|
if (final_size != trimmed_size) {
|
||||||
|
LOG_ERROR(Common, "File resize verification failed! Expected {} bytes, got {} bytes",
|
||||||
|
trimmed_size, final_size);
|
||||||
|
return OperationOutcome::FileIOWriteError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the trimmed file can still be read properly
|
||||||
|
LOG_INFO(Common, "Validating trimmed file integrity...");
|
||||||
|
if (!ValidateTrimmedFile(target_path)) {
|
||||||
|
LOG_ERROR(Common, "Trimmed file validation failed - file may be corrupted");
|
||||||
|
if (is_save_as) {
|
||||||
|
std::filesystem::remove(target_path);
|
||||||
|
}
|
||||||
|
return OperationOutcome::FileIOWriteError;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Successfully trimmed XCI file from {} MB to {} MB (validated)",
|
||||||
|
file_size_bytes / BYTES_IN_A_MEGABYTE, trimmed_size / BYTES_IN_A_MEGABYTE);
|
||||||
|
|
||||||
|
// Update internal state only if trimming in-place
|
||||||
|
if (!is_save_as) {
|
||||||
|
file_size_bytes = trimmed_size;
|
||||||
|
free_space_checked = false;
|
||||||
|
free_space_valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationOutcome::Successful;
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR(Common, "Exception during trim operation: {}", e.what());
|
||||||
|
return OperationOutcome::FileIOWriteError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::CanTrim(const std::filesystem::path& path) {
|
||||||
|
const auto extension = path.extension().string();
|
||||||
|
if (extension != ".xci" && extension != ".XCI") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
XCITrimmer trimmer(path);
|
||||||
|
return trimmer.CanBeTrimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string XCITrimmer::GetOperationOutcomeString(OperationOutcome outcome) {
|
||||||
|
switch (outcome) {
|
||||||
|
case OperationOutcome::Successful:
|
||||||
|
return "Successfully trimmed XCI file";
|
||||||
|
case OperationOutcome::InvalidXCIFile:
|
||||||
|
return "Invalid XCI file";
|
||||||
|
case OperationOutcome::NoTrimNecessary:
|
||||||
|
return "XCI file does not need to be trimmed";
|
||||||
|
case OperationOutcome::FreeSpaceCheckFailed:
|
||||||
|
return "Free space check failed - file contains data in padding area";
|
||||||
|
case OperationOutcome::FileIOWriteError:
|
||||||
|
return "File I/O write error";
|
||||||
|
case OperationOutcome::ReadOnlyFileCannotFix:
|
||||||
|
return "Cannot remove read-only attribute";
|
||||||
|
case OperationOutcome::FileSizeChanged:
|
||||||
|
return "File size changed during operation";
|
||||||
|
case OperationOutcome::Cancelled:
|
||||||
|
return "Operation cancelled";
|
||||||
|
default:
|
||||||
|
return "Unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XCITrimmer::ValidateTrimmedFile(const std::filesystem::path& trimmed_path) {
|
||||||
|
try {
|
||||||
|
// Create a new XCITrimmer instance to validate the trimmed file
|
||||||
|
XCITrimmer validator(trimmed_path);
|
||||||
|
|
||||||
|
if (!validator.IsValid()) {
|
||||||
|
LOG_ERROR(Common, "Trimmed file is not a valid XCI file");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the trimmed file has the expected size
|
||||||
|
const u64 expected_size = offset_bytes + data_size_bytes;
|
||||||
|
const u64 actual_size = validator.GetFileSize();
|
||||||
|
|
||||||
|
if (actual_size != expected_size) {
|
||||||
|
LOG_ERROR(Common, "Trimmed file size mismatch: expected {} bytes, got {} bytes",
|
||||||
|
expected_size, actual_size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the header can still be read correctly
|
||||||
|
const u64 validator_data_size = validator.GetDataSize();
|
||||||
|
const u64 validator_cart_size = validator.GetCartSize();
|
||||||
|
|
||||||
|
if (validator_data_size != data_size_bytes) {
|
||||||
|
LOG_ERROR(Common, "Data size mismatch in trimmed file: expected {} bytes, got {} bytes",
|
||||||
|
data_size_bytes, validator_data_size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validator_cart_size != cart_size_bytes) {
|
||||||
|
LOG_ERROR(Common, "Cart size mismatch in trimmed file: expected {} bytes, got {} bytes",
|
||||||
|
cart_size_bytes, validator_cart_size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read a small portion of the file to ensure it's not corrupted
|
||||||
|
FS::IOFile test_file(trimmed_path, FS::FileAccessMode::Read, FS::FileType::BinaryFile);
|
||||||
|
if (!test_file.IsOpen()) {
|
||||||
|
LOG_ERROR(Common, "Cannot open trimmed file for validation");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the first 1KB to ensure the file is readable
|
||||||
|
std::vector<u8> test_buffer(1024);
|
||||||
|
const size_t bytes_read = test_file.ReadSpan(std::span<u8>(test_buffer.data(), 1024));
|
||||||
|
|
||||||
|
if (bytes_read != 1024) {
|
||||||
|
LOG_ERROR(Common, "Cannot read from trimmed file - file may be corrupted");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO(Common, "Trimmed file validation successful - file is intact and readable");
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR(Common, "Exception during trimmed file validation: {}", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Common
|
||||||
74
src/common/xci_trimmer.h
Normal file
74
src/common/xci_trimmer.h
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include "common/common_types.h"
|
||||||
|
|
||||||
|
namespace Common {
|
||||||
|
|
||||||
|
class XCITrimmer {
|
||||||
|
public:
|
||||||
|
enum class OperationOutcome {
|
||||||
|
Successful,
|
||||||
|
InvalidXCIFile,
|
||||||
|
NoTrimNecessary,
|
||||||
|
FreeSpaceCheckFailed,
|
||||||
|
FileIOWriteError,
|
||||||
|
ReadOnlyFileCannotFix,
|
||||||
|
FileSizeChanged,
|
||||||
|
Cancelled
|
||||||
|
};
|
||||||
|
|
||||||
|
using ProgressCallback = std::function<void(size_t current, size_t total)>;
|
||||||
|
using CancelCallback = std::function<bool()>;
|
||||||
|
|
||||||
|
explicit XCITrimmer(const std::filesystem::path& path);
|
||||||
|
~XCITrimmer();
|
||||||
|
|
||||||
|
bool IsValid() const;
|
||||||
|
bool CanBeTrimmed() const;
|
||||||
|
|
||||||
|
u64 GetDataSize() const;
|
||||||
|
u64 GetCartSize() const;
|
||||||
|
u64 GetFileSize() const;
|
||||||
|
u64 GetDiskSpaceSavings() const;
|
||||||
|
|
||||||
|
OperationOutcome Trim(ProgressCallback progress_callback = nullptr,
|
||||||
|
CancelCallback cancel_callback = nullptr,
|
||||||
|
const std::filesystem::path& output_path = {});
|
||||||
|
|
||||||
|
static bool CanTrim(const std::filesystem::path& path);
|
||||||
|
static std::string GetOperationOutcomeString(OperationOutcome outcome);
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool ReadHeader();
|
||||||
|
bool CheckFreeSpace(CancelCallback cancel_callback, ProgressCallback progress_callback = nullptr);
|
||||||
|
bool CheckPadding(size_t read_size, CancelCallback cancel_callback,
|
||||||
|
ProgressCallback progress_callback = nullptr);
|
||||||
|
bool ValidateTrimmedFile(const std::filesystem::path& trimmed_path);
|
||||||
|
|
||||||
|
static constexpr u64 BYTES_IN_A_MEGABYTE = 1024 * 1024;
|
||||||
|
static constexpr u32 BUFFER_SIZE = 8 * 1024 * 1024; // 8 MB
|
||||||
|
static constexpr u64 CART_SIZE_MB_IN_FORMATTED_GB = 952;
|
||||||
|
static constexpr u32 CART_KEY_AREA_SIZE = 0x1000;
|
||||||
|
static constexpr u8 PADDING_BYTE = 0xFF;
|
||||||
|
static constexpr u32 HEADER_FILE_POS = 0x100;
|
||||||
|
static constexpr u32 CART_SIZE_FILE_POS = 0x10D;
|
||||||
|
static constexpr u32 DATA_SIZE_FILE_POS = 0x118;
|
||||||
|
static constexpr u32 MAGIC_VALUE = 0x44414548; // "HEAD"
|
||||||
|
|
||||||
|
std::filesystem::path filename;
|
||||||
|
u64 offset_bytes{0};
|
||||||
|
u64 data_size_bytes{0};
|
||||||
|
u64 cart_size_bytes{0};
|
||||||
|
u64 file_size_bytes{0};
|
||||||
|
bool file_ok{false};
|
||||||
|
bool free_space_checked{false};
|
||||||
|
bool free_space_valid{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Common
|
||||||
Reference in New Issue
Block a user