diff --git a/src/citron/configuration/configure_dialog.cpp b/src/citron/configuration/configure_dialog.cpp index 29be50965..2f6e038ba 100644 --- a/src/citron/configuration/configure_dialog.cpp +++ b/src/citron/configuration/configure_dialog.cpp @@ -174,14 +174,18 @@ ConfigureDialog::~ConfigureDialog() { void ConfigureDialog::UpdateTheme() { QString accent_color_str; 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) { 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()) { - // THE FIX: Use a sane timer interval to prevent UI lag. - rainbow_timer->start(100); + // Optimized timer interval for better performance + rainbow_timer->start(150); // Increased from 100ms to 150ms } } else { if (rainbow_timer->isActive()) { @@ -190,11 +194,18 @@ void ConfigureDialog::UpdateTheme() { accent_color_str = Theme::GetAccentColor(); } + // Cache color operations to avoid repeated calculations QColor accent_color(accent_color_str); - QString accent_color_hover = accent_color.lighter(115).name(); - QString accent_color_pressed = accent_color.darker(120).name(); + const QString accent_color_hover = accent_color.lighter(115).name(QColor::HexRgb); + 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_HOVER%%"), accent_color_hover); style_sheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed); diff --git a/src/citron/configuration/configure_per_game.cpp b/src/citron/configuration/configure_per_game.cpp index 3f52bdaf6..4d0585d2c 100644 --- a/src/citron/configuration/configure_per_game.cpp +++ b/src/citron/configuration/configure_per_game.cpp @@ -16,6 +16,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -52,12 +57,15 @@ #include "citron/uisettings.h" #include "citron/util/util.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, std::vector& vk_device_records, Core::System& system_) : QDialog(parent), -ui(std::make_unique()), title_id{title_id_}, system{system_}, +ui(std::make_unique()), title_id{title_id_}, file_name{file_name}, system{system_}, builder{std::make_unique(this, !system_.IsPoweredOn())}, tab_group{std::make_shared>()} , rainbow_timer{new QTimer(this)} { @@ -133,6 +141,9 @@ rainbow_timer{new QTimer(this)} { &ConfigurePerGame::HandleApplyButtonClicked); } + // Connect trim XCI button + connect(ui->trim_xci_button, &QPushButton::clicked, this, &ConfigurePerGame::OnTrimXCI); + LoadConfiguration(); } @@ -216,7 +227,7 @@ void ConfigurePerGame::UpdateTheme() { return; } - rainbow_hue += 0.01f; + rainbow_hue += 0.003f; // Even slower color transition for better performance if (rainbow_hue > 1.0f) { rainbow_hue = 0.0f; } @@ -225,19 +236,24 @@ void ConfigurePerGame::UpdateTheme() { QColor accent_color_hover = accent_color.lighter(115); 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 QString tab_style = QStringLiteral( "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); 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:hover { background-color: %2; }" "QPushButton:pressed { background-color: %3; }") - .arg(accent_color.name(QColor::HexRgb)) - .arg(accent_color_hover.name(QColor::HexRgb)) - .arg(accent_color_pressed.name(QColor::HexRgb)); + .arg(accent_color_name) + .arg(accent_color_hover_name) + .arg(accent_color_pressed_name); ui->buttonBox->button(QDialogButtonBox::Ok)->setStyleSheet(button_style); ui->buttonBox->button(QDialogButtonBox::Cancel)->setStyleSheet(button_style); @@ -245,11 +261,14 @@ void ConfigurePerGame::UpdateTheme() { 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 QString child_stylesheet = property("templateStyleSheet").toString(); - child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color.name(QColor::HexRgb)); - child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover.name(QColor::HexRgb)); - child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed.name(QColor::HexRgb)); + child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent_color_name); + child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_HOVER%%"), accent_color_hover_name); + child_stylesheet.replace(QStringLiteral("%%ACCENT_COLOR_PRESSED%%"), accent_color_pressed_name); // Pass the updated stylesheet to the child tabs graphics_tab->SetTemplateStyleSheet(child_stylesheet); @@ -259,7 +278,7 @@ void ConfigurePerGame::UpdateTheme() { graphics_advanced_tab->SetTemplateStyleSheet(child_stylesheet); 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")); } } + +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((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)); + } +} diff --git a/src/citron/configuration/configure_per_game.h b/src/citron/configuration/configure_per_game.h index 3591347fb..c2dee1eb5 100644 --- a/src/citron/configuration/configure_per_game.h +++ b/src/citron/configuration/configure_per_game.h @@ -62,6 +62,7 @@ public: public slots: void accept() override; + void OnTrimXCI(); private: void changeEvent(QEvent* event) override; @@ -76,6 +77,7 @@ private: std::unique_ptr ui; FileSys::VirtualFile file; u64 title_id; + std::string file_name; QGraphicsScene* scene; std::unique_ptr game_config; diff --git a/src/citron/configuration/configure_per_game.ui b/src/citron/configuration/configure_per_game.ui index c269478b5..d6018be9e 100644 --- a/src/citron/configuration/configure_per_game.ui +++ b/src/citron/configuration/configure_per_game.ui @@ -448,7 +448,7 @@ - Qt::ScrollBarAlwaysOff + Qt::ScrollBarAsNeeded true @@ -667,6 +667,16 @@ + + + + Trim XCI File + + + Remove unused space from XCI file to reduce file size + + + @@ -695,28 +705,59 @@ - - - true - + 0 0 - - -1 + + Qt::ScrollBarAlwaysOff - + + Qt::ScrollBarAsNeeded + + true - - false - - - false - + + + + 0 + 0 + 560 + 538 + + + + + + + true + + + + 0 + 0 + + + + -1 + + + false + + + false + + + false + + + + + diff --git a/src/citron/main.cpp b/src/citron/main.cpp index 0e0d5d427..66fb8eafa 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -118,7 +118,9 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "common/x64/cpu_detect.h" #endif #include "common/settings.h" +#include "common/string_util.h" #include "common/telemetry.h" +#include "common/xci_trimmer.h" #include "core/core.h" #include "core/core_timing.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_Folder, &GMainWindow::OnMenuLoadFolder); 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_Load_Amiibo, &GMainWindow::OnLoadAmiibo); @@ -3385,6 +3388,216 @@ void GMainWindow::OnMenuInstallToNAND() { 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(trimmer.GetFileSize()) / (1024.0 * 1024.0); + const double data_size_mb = static_cast(trimmer.GetDataSize()) / (1024.0 * 1024.0); + const double savings_mb = static_cast(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((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(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) { const QStringList tt_options{tr("System Application"), tr("System Archive"), diff --git a/src/citron/main.h b/src/citron/main.h index 714ff867e..76b7b4e0c 100644 --- a/src/citron/main.h +++ b/src/citron/main.h @@ -377,6 +377,7 @@ private slots: void OnMenuLoadFolder(); void IncrementInstallProgress(); void OnMenuInstallToNAND(); + void OnMenuTrimXCI(); void OnMenuRecentFile(); void OnConfigure(); void OnConfigureTas(); diff --git a/src/citron/main.ui b/src/citron/main.ui index 0798676f2..79aa342b5 100644 --- a/src/citron/main.ui +++ b/src/citron/main.ui @@ -58,6 +58,7 @@ + @@ -206,6 +207,14 @@ &Install Files to NAND... + + + true + + + &Trim XCI File... + + L&oad File... diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 4498658e9..bfff73daa 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -157,6 +157,8 @@ add_library(common STATIC virtual_buffer.h wall_clock.cpp wall_clock.h + xci_trimmer.cpp + xci_trimmer.h zstd_compression.cpp zstd_compression.h ) diff --git a/src/common/xci_trimmer.cpp b/src/common/xci_trimmer.cpp new file mode 100644 index 000000000..c94d72913 --- /dev/null +++ b/src/common/xci_trimmer.cpp @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#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, 7> CART_SIZES_GB = {{ + {static_cast(0xFA), 1}, + {static_cast(0xF8), 2}, + {static_cast(0xF0), 4}, + {static_cast(0xE0), 8}, + {static_cast(0xE1), 16}, + {static_cast(0xE2), 32}, + {static_cast(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 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(BUFFER_SIZE, bytes_left); + const size_t bytes_read = file.ReadSpan(std::span(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 test_buffer(1024); + const size_t bytes_read = test_file.ReadSpan(std::span(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 diff --git a/src/common/xci_trimmer.h b/src/common/xci_trimmer.h new file mode 100644 index 000000000..380ac2a98 --- /dev/null +++ b/src/common/xci_trimmer.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#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; + using CancelCallback = std::function; + + 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