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:
Zephyron
2025-10-21 22:26:10 +10:00
parent a184f951e9
commit e9b63b4b34
10 changed files with 1079 additions and 31 deletions

View File

@@ -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<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) {
const QStringList tt_options{tr("System Application"),
tr("System Archive"),