From 926e7ac6af102956b08cc053d81527ec3be855a2 Mon Sep 17 00:00:00 2001 From: Jonas <104419015+jojo-chaechae@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:39:49 +0600 Subject: file_sys: add a deterministic NUP packer, Convert to NUP in Qt and eden-cli --pack-nup (#271) * file_sys: add a NUP packer for loose dumps Packs a loose dump (exefs/romfs, base/, updates// and dlc///) into a NUP v1 container with the aligned record layout. Every package is kept, files are deduplicated by SHA-256 and compressed with zstd unless that does not shrink them, and files over 256 MiB are stored raw. Output is byte-identical on every platform: compression is single-threaded with every frame parameter set explicitly, and zstd is always bundled so all builds use the same version. A low-storage mode deletes each source file once its extent is synced and journaled, and an interrupted run resumes from the journal. Assisted-by: Claude Opus 5.5 (wrote the packer, its EdenPack-free dump detection, the aligned record layout and the deterministic zstd setup; ran the Raiders and Donkey Kong Bananza packs and boot checks) * tests: cover the NUP packer Round-trips a dump through NupContainer, checks standalone updates, a bare exefs/romfs dump, low-storage resume and overwrite rejection, and pins the hash of a packed dump so changes to the output are deliberate. Assisted-by: Claude Opus 5.5 (wrote the tests, including the pinned output hash) * qt: add Convert to NUP to the game list Shown for loose dumps. Asks for confirmation with the estimated space, offers low-storage mode, runs in the background with progress and cancel, and resumes an interrupted low-storage conversion. Assisted-by: Claude Opus 5.5 (wrote the Convert to NUP action and its progress and resume handling) * cli: add --pack-nup [out.nup] Assisted-by: Claude Opus 5.5 (wrote the --pack-nup option) --- src/yuzu/main_window.cpp | 158 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) (limited to 'src/yuzu/main_window.cpp') diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index b0c0446add..2b43bcd2d0 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -149,6 +149,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "core/file_sys/card_image.h" #include "core/file_sys/common_funcs.h" +#include "core/file_sys/nup_writer.h" #include "core/file_sys/romfs.h" #include "core/file_sys/savedata_factory.h" @@ -554,6 +555,10 @@ MainWindow::MainWindow(bool has_broken_vulkan) } MainWindow::~MainWindow() { + if (nup_cancel) { + nup_cancel->store(true); + } + nup_watcher.waitForFinished(); #ifdef __APPLE__ AllowOSSleep(); #endif @@ -1458,6 +1463,8 @@ void MainWindow::ConnectWidgetEvents() { connect(game_list, &GameList::DumpRomFSRequested, this, &MainWindow::OnGameListDumpRomFS); connect(game_list, &GameList::VerifyIntegrityRequested, this, &MainWindow::OnGameListVerifyIntegrity); + connect(game_list, &GameList::ConvertToNupRequested, this, + &MainWindow::OnGameListConvertToNup); connect(game_list, &GameList::CopyTIDRequested, this, &MainWindow::OnGameListCopyTID); connect(game_list, &GameList::CreateShortcut, this, &MainWindow::OnGameListCreateShortcut); connect(game_list, &GameList::AddDirectory, this, &MainWindow::OnGameListAddDirectory); @@ -1907,6 +1914,12 @@ bool MainWindow::SelectAndSetCurrentUser( void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletParameters params, StartGameType type) { + if (nup_running && filename.toStdString() == nup_source_path) { + QMessageBox::information(this, tr("Conversion in Progress"), + tr("This game cannot be started while its dump is being " + "converted.")); + return; + } LOG_INFO(Frontend, "Eglantine starting..."); if (params.program_id == 0 || @@ -3967,6 +3980,151 @@ void MainWindow::OnGameListRefresh() { SetFirmwareVersion(); } +void MainWindow::OnGameListConvertToNup(const std::string& game_path) { + if (nup_running) + return; + + const auto root = FileSys::Nup::DumpRootFromMain(game_path); + if (!root) + return; + const QString convert_title = tr("Convert to NUP"); + const auto archive = FileSys::Nup::DefaultArchivePath(*root); + if (std::filesystem::exists(archive)) { + QMessageBox::information(this, convert_title, + tr("The target archive already exists:\n%1") + .arg(QString::fromStdString(Common::FS::PathToUTF8String(archive)))); + return; + } + + const bool resume = FileSys::Nup::HasResumableJournal(archive); + bool delete_sources = resume; + if (resume) { + if (QMessageBox::question(this, tr("Resume Conversion"), + tr("Resume the interrupted conversion?"), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) + return; + } else { + QMessageBox prompt(QMessageBox::Question, convert_title, + tr("Source folder:\n%1\n\nTarget archive:\n%2") + .arg(QString::fromStdString(Common::FS::PathToUTF8String(*root)), + QString::fromStdString(Common::FS::PathToUTF8String(archive))), + QMessageBox::Yes | QMessageBox::No, this); + // QMessageBox takes ownership of its check box. + auto* low_storage = new QCheckBox(tr("Low storage: delete the original files as they " + "are packed (cannot be undone)")); + prompt.setCheckBox(low_storage); + u64 needed{}; + { + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(*root, ec), end; + !ec && it != end; it.increment(ec)) { + if (it->is_regular_file(ec)) + needed += it->file_size(ec); + } + } + std::error_code space_error; + const auto space = std::filesystem::space(archive.parent_path(), space_error); + const auto to_mb = [](u64 bytes) { return bytes / (1024 * 1024); }; + const QString space_info = tr("\n\nApproximate space needed: %1 MB\nFree space on target " + "volume: %2 MB") + .arg(to_mb(needed)) + .arg(space_error ? 0 : to_mb(space.available)); + prompt.setInformativeText(space_info); + if (!space_error && space.available < needed) { + prompt.setText(prompt.text() + tr("\n\nWarning: free space is less than the " + "estimated space needed.")); + } + if (prompt.exec() != QMessageBox::Yes) + return; + delete_sources = low_storage->isChecked(); + } + + nup_running = true; + nup_source_path = game_path; + game_list->SetNupConversionRunning(true); + nup_cancel = std::make_shared(false); + nup_progress = + new QProgressDialog(tr("Preparing conversion..."), tr("Cancel"), 0, 1000, this); + nup_progress->setWindowTitle(tr("Converting to NUP")); + nup_progress->setMinimumDuration(0); + nup_progress->setAutoClose(false); + nup_progress->setAutoReset(false); + connect(nup_progress, &QProgressDialog::canceled, this, + [cancel = nup_cancel] { cancel->store(true); }); + nup_progress->show(); + + const auto cancel = nup_cancel; + auto last_update = std::make_shared(); + nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, + cancel, last_update] { + std::string error; + const auto on_progress = [this, last_update]( + const FileSys::Nup::PackProgress& progress) { + const auto now = std::chrono::steady_clock::now(); + if (now - *last_update < std::chrono::milliseconds(200)) + return; + *last_update = now; + QMetaObject::invokeMethod( + this, + [this, progress] { + if (!nup_progress) + return; + const auto percent = progress.bytes_total == 0 + ? 0 + : static_cast(progress.bytes_done * 1000 / + progress.bytes_total); + nup_progress->setValue(percent); + nup_progress->setLabelText( + tr("%1 / %2 MB\n%3 / %4 files") + .arg(progress.bytes_done / (1024 * 1024)) + .arg(progress.bytes_total / (1024 * 1024)) + .arg(progress.files_done) + .arg(progress.files_total)); + }, + Qt::QueuedConnection); + }; + const auto result = FileSys::Nup::PackDump( + root, archive, {.delete_sources = delete_sources}, on_progress, cancel.get(), &error); + QMetaObject::invokeMethod( + this, + [this, result, delete_sources, error] { + nup_running = false; + nup_source_path.clear(); + game_list->SetNupConversionRunning(false); + if (nup_progress) { + nup_progress->close(); + nup_progress->deleteLater(); + nup_progress = nullptr; + } + nup_cancel.reset(); + switch (result) { + case FileSys::Nup::PackResult::Success: + QMessageBox::information( + this, tr("Conversion Complete"), + delete_sources ? tr("The NUP was created and the loose folder was " + "removed.") + : tr("The NUP was created. You can delete the loose " + "folder if you no longer need it.")); + OnGameListRefresh(); + break; + case FileSys::Nup::PackResult::Cancelled: + if (delete_sources) + QMessageBox::information(this, tr("Conversion Cancelled"), + tr("The conversion can be resumed by choosing " + "Convert to NUP again.")); + break; + case FileSys::Nup::PackResult::InvalidSource: + case FileSys::Nup::PackResult::IoError: + QMessageBox::critical(this, tr("Conversion Failed"), + QString::fromStdString(error)); + break; + } + }, + Qt::QueuedConnection); + return result; + })); +} + void MainWindow::LaunchFirmwareApplet(u64 raw_program_id, std::optional cabinet_mode) { auto const program_id = Service::AM::AppletProgramId(raw_program_id); -- cgit v1.2.3