diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-11-02 17:52:11 +0100 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-11-02 17:52:11 +0100 |
| commit | 7fe395e5ab7a42a73262be3ca69b640d2ccd6ec2 (patch) | |
| tree | f0b9eea55b3e1864492d19842950562d3cb17427 | |
| parent | bf2a94eb29f13073848a16754c983d53a3fc6d44 (diff) | |
| parent | ead60c761e4d58cb4adc8b3b08f67dc10cf1e36b (diff) | |
Merge upstream Djihads80/Eglantine nx2 into nx2
| -rw-r--r-- | src/core/file_sys/nup_writer.cpp | 99 | ||||
| -rw-r--r-- | src/core/file_sys/nup_writer.h | 17 | ||||
| -rw-r--r-- | src/core/loader/deconstructed_rom_directory.cpp | 47 | ||||
| -rw-r--r-- | src/qt_common/game_list/worker.cpp | 2 | ||||
| -rw-r--r-- | src/tests/core/nup_writer.cpp | 53 | ||||
| -rw-r--r-- | src/yuzu/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/yuzu/main_window.cpp | 65 | ||||
| -rw-r--r-- | src/yuzu/main_window.h | 3 | ||||
| -rw-r--r-- | src/yuzu/nup_dialogs.cpp | 485 | ||||
| -rw-r--r-- | src/yuzu/nup_dialogs.h | 114 |
10 files changed, 821 insertions, 65 deletions
diff --git a/src/core/file_sys/nup_writer.cpp b/src/core/file_sys/nup_writer.cpp index 8c7728ed01..4161969f98 100644 --- a/src/core/file_sys/nup_writer.cpp +++ b/src/core/file_sys/nup_writer.cpp @@ -8,6 +8,7 @@ #include <cctype> #include <charconv> #include <cstring> +#include <functional> #include <limits> #include <map> #include <memory> @@ -516,8 +517,9 @@ std::unique_ptr<Loader::AppLoader_DeconstructedRomDirectory> OpenLoader( class Discovery { public: - Discovery(const fs::path& dump_root_, std::vector<fs::path> excluded_) - : dump_root{dump_root_}, excluded{std::move(excluded_)} {} + Discovery(const fs::path& dump_root_, std::vector<fs::path> excluded_, + const Metadata& overrides_) + : dump_root{dump_root_}, excluded{std::move(excluded_)}, overrides{overrides_} {} Plan Run() { const auto base = FindDirectory(dump_root, "base"); @@ -678,6 +680,34 @@ private: tlvs.emplace_back(TlvIcon, std::move(icon)); } } + + if (!overrides.title_name.empty()) { + SetTlv(tlvs, TlvTitleName, StringBytes(overrides.title_name)); + } + if (!overrides.icon.empty()) { + SetTlv(tlvs, TlvIcon, overrides.icon); + } + if (!overrides.display_version.empty()) { + // The loader reports the newest update's version when there is one. + SetTlv(tlvs, TlvDisplayVersion, StringBytes(overrides.display_version)); + std::optional<u32> newest; + for (u32 i = 0; i < plan.packages.size(); ++i) { + if (plan.packages[i].kind == KindUpdate && + (!newest || plan.packages[i].version >= plan.packages[*newest].version)) { + newest = i; + } + } + if (newest && *newest != target) { + SetTlv(package_tlvs[*newest], TlvDisplayVersion, + StringBytes(overrides.display_version)); + } + } + } + + static void SetTlv(std::vector<std::pair<u16, std::vector<u8>>>& tlvs, u16 type, + std::vector<u8> payload) { + std::erase_if(tlvs, [type](const auto& tlv) { return tlv.first == type; }); + tlvs.emplace_back(type, std::move(payload)); } bool IsExcluded(const fs::path& path) const { @@ -722,6 +752,7 @@ private: fs::path dump_root; std::vector<fs::path> excluded; + Metadata overrides; RealVfsFilesystem vfs; Plan plan; std::vector<std::vector<std::pair<u16, std::vector<u8>>>> package_tlvs; @@ -838,7 +869,7 @@ public: for (const auto& path : {archive, part, journal}) { excluded.push_back(fs::absolute(path).lexically_normal()); } - plan = Discovery{dump_root, std::move(excluded)}.Run(); + plan = Discovery{dump_root, std::move(excluded), options.metadata}.Run(); layout = BuildLayout(plan); OpenNew(); } @@ -1421,6 +1452,68 @@ std::optional<fs::path> DumpRootFromMain(const fs::path& main) { return root; } +Metadata ProbeMetadata(const fs::path& dump_root) { + RealVfsFilesystem vfs; + const auto base = FindDirectory(dump_root, "base"); + const auto updates = FindDirectory(dump_root, "updates"); + + // Newest update first, then the base (or a bare dump's root). + std::vector<std::pair<u64, fs::path>> update_dirs; + std::error_code ec; + if (!updates.empty()) { + for (const auto& entry : fs::directory_iterator(updates, ec)) { + const auto version = ParseDecimal(ToUtf8(entry.path().filename())); + if (version && entry.is_directory(ec)) { + update_dirs.emplace_back(*version, entry.path()); + } + } + } + std::ranges::sort(update_dirs, std::greater<>{}); + const fs::path base_dir = !base.empty() ? base : (updates.empty() ? dump_root : fs::path{}); + + Metadata result; + const auto probe = [&](const fs::path& dir, const std::string& label, bool names) { + const auto loader = OpenLoader(vfs, dir); + if (!loader) { + return; + } + if (result.display_version.empty()) { + std::string version; + if (loader->ReadDisplayVersion(version) == Loader::ResultStatus::Success && + !version.empty()) { + result.display_version = version; + result.version_source = label; + } + } + if (names) { + NACP control; + if (result.title_name.empty() && + loader->ReadControlData(control) == Loader::ResultStatus::Success) { + result.title_name = control.GetApplicationName(); + } + std::vector<u8> icon; + if (result.icon.empty() && + loader->ReadIcon(icon) == Loader::ResultStatus::Success) { + result.icon = std::move(icon); + } + } + }; + for (const auto& [version, dir] : update_dirs) { + probe(dir, "update " + std::to_string(version), false); + } + if (!base_dir.empty()) { + probe(base_dir, "base", true); + } + // Names and icons fall back to the updates when the base has none. + for (const auto& [version, dir] : update_dirs) { + if (!result.title_name.empty() && !result.icon.empty()) { + break; + } + probe(dir, "update " + std::to_string(version), true); + } + return result; +} + fs::path DefaultArchivePath(const fs::path& dump_root) { auto root = dump_root; if (!root.has_filename()) { diff --git a/src/core/file_sys/nup_writer.h b/src/core/file_sys/nup_writer.h index 231387b156..d2423dd1f4 100644 --- a/src/core/file_sys/nup_writer.h +++ b/src/core/file_sys/nup_writer.h @@ -8,6 +8,7 @@ #include <functional> #include <optional> #include <string> +#include <vector> #include "common/common_types.h" @@ -30,6 +31,17 @@ enum class PackResult { Success, Cancelled, InvalidSource, IoError }; // that one file does not evict everything else from the cache. constexpr u64 DefaultRawThreshold = 256ULL * 1024 * 1024; +// Application metadata written to the base package (or the first package when there is no base). +// In PackOptions an empty field keeps what the dump supplies; ProbeMetadata fills all of them. +struct Metadata { + std::string title_name; + // JPEG or PNG bytes; 256x256 JPEG as distributed on-console. + std::vector<u8> icon; + std::string display_version; + // Where ProbeMetadata found display_version, e.g. "update 458752" or "base". + std::string version_source; +}; + struct PackOptions { // Low-storage mode: delete each source file once its extent is synced and journaled. bool delete_sources = false; @@ -37,6 +49,8 @@ struct PackOptions { int zstd_level = 3; // Files larger than this are always stored raw. u64 raw_threshold = DefaultRawThreshold; + // Overrides for the name, icon and display version found in the dump. + Metadata metadata; }; // A loose dump holds exefs/main and main.npdm, directly or under base/ or updates/<version>/, @@ -45,6 +59,9 @@ bool IsLooseDump(const std::filesystem::path& dir); // The dump folder a game list entry's exefs/main (or exefs folder) belongs to, if it is a loose // dump. std::optional<std::filesystem::path> DumpRootFromMain(const std::filesystem::path& main); +// Reads the name, icon and display version a dump would be packed with. The version comes from +// the newest update that reports one, else the base. +Metadata ProbeMetadata(const std::filesystem::path& dump_root); std::filesystem::path DefaultArchivePath(const std::filesystem::path& dump_root); // Writes <archive>.part and renames it when done; an existing archive is never overwritten. A diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index 60cf87db14..4b1168a59b 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -117,27 +117,19 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { return nullptr; } -// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name -// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario -// Kart World 1.8.0). A hotfix can keep its minor version's tables (Product.110 in a 1.1.1). -std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { - if (romfs_dir == nullptr) { - return {}; - } - const auto system_dir = romfs_dir->GetSubdirectory("System"); - const auto resource_dir = system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr; - if (resource_dir == nullptr) { - return {}; - } - constexpr std::string_view Prefix = "ResourceSizeTable.Product."; +// The newest three-digit tag in a directory's file names of the form <prefix>NNN.<anything>. +std::string NewestProductTag(const FileSys::VirtualDir& dir, std::string_view prefix) { std::string best; - for (const auto& file : resource_dir->GetFiles()) { + if (dir == nullptr) { + return best; + } + for (const auto& file : dir->GetFiles()) { const std::string file_name = file->GetName(); - if (!file_name.starts_with(Prefix) || file_name.size() < Prefix.size() + 4) { + if (!file_name.starts_with(prefix) || file_name.size() < prefix.size() + 4) { continue; } - const std::string_view digits = std::string_view{file_name}.substr(Prefix.size(), 3); - if (file_name[Prefix.size() + 3] != '.' || + const std::string_view digits = std::string_view{file_name}.substr(prefix.size(), 3); + if (file_name[prefix.size() + 3] != '.' || !std::all_of(digits.begin(), digits.end(), [](char c) { return c >= '0' && c <= '9'; })) { continue; } @@ -145,6 +137,27 @@ std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { best = version; } } + return best; +} + +// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name +// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario +// Kart World 1.8.0), and some name their game data list instead (GameData/GameDataList.Product.320.* +// in Donkey Kong Bananza 3.2.0). A hotfix can keep its minor version's tables (Product.110 in a +// 1.1.1). +std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { + if (romfs_dir == nullptr) { + return {}; + } + const auto system_dir = romfs_dir->GetSubdirectory("System"); + std::string best = NewestProductTag( + system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr, + "ResourceSizeTable.Product."); + if (const auto game_data = NewestProductTag(romfs_dir->GetSubdirectory("GameData"), + "GameDataList.Product."); + game_data > best) { + best = game_data; + } if (best.empty()) { return {}; } diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp index 46460c1f66..bd3d81dac4 100644 --- a/src/qt_common/game_list/worker.cpp +++ b/src/qt_common/game_list/worker.cpp @@ -444,7 +444,7 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::vector<std::fi std::vector<u64> program_ids; loader->ReadProgramIds(program_ids); - const auto addEntry = [this, &path, physical_name, + const auto addEntry = [this, physical_name, parent_dir](std::unique_ptr<Loader::AppLoader>& app_loader, const u64 id) { std::vector<u8> icon; diff --git a/src/tests/core/nup_writer.cpp b/src/tests/core/nup_writer.cpp index ce1a0717ed..c0230d8694 100644 --- a/src/tests/core/nup_writer.cpp +++ b/src/tests/core/nup_writer.cpp @@ -390,3 +390,56 @@ TEST_CASE("NUP output is reproducible", "[nup]") { } REQUIRE(hex == "df8446903ab2c39a51af0304935eb4aa5d8ead105440976ceae7ab97133f6143"); } + +TEST_CASE("NUP writes metadata overrides to the base and the newest update", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.title_name.empty()); + REQUIRE(probed.icon.empty()); + // The version comes from the RomFS resource size table of the newest update that has one. + REQUIRE(probed.display_version == "1.1.0"); + REQUIRE(probed.version_source == "update 65536"); + + FileSys::Nup::PackOptions options; + options.metadata.title_name = "Custom Name"; + options.metadata.icon = {0xFF, 0xD8, 0xFF, 0xD9}; + options.metadata.display_version = "9.8.7"; + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive, options) == FileSys::Nup::PackResult::Success); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + const auto& packages = nup.GetPackages(); + REQUIRE(packages.size() == 4); + REQUIRE(packages[0].title_name == "Custom Name"); + REQUIRE(packages[0].icon == options.metadata.icon); + REQUIRE(packages[0].display_version == "9.8.7"); + REQUIRE(packages[2].version == 131072); + REQUIRE(packages[2].display_version == "9.8.7"); + REQUIRE(packages[1].display_version == "1.1.0"); +} + +TEST_CASE("NUP probes the version from the game data list tag", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "base/exefs/main", "main"); + Put(root / "base/exefs/main.npdm", Npdm(TitleId)); + Put(root / "base/romfs/GameData/GameDataList.Product.100.byml.zs", "x"); + Put(root / "base/romfs/System/Resource/ResourceSizeTable.Product.rsizetable.zs", "x"); + Put(root / "updates/458752/exefs/main", "main"); + Put(root / "updates/458752/exefs/main.npdm", Npdm(TitleId)); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.byml.zs", "x"); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.revision.txt", "1"); + + auto probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "3.2.0"); + REQUIRE(probed.version_source == "update 458752"); + + fs::remove_all(root / "updates"); + probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "1.0.0"); + REQUIRE(probed.version_source == "base"); +} diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 40ff1d45f9..f5895edd10 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -219,6 +219,7 @@ add_executable(yuzu yuzu.qrc yuzu.rc migration_dialog.h migration_dialog.cpp + nup_dialogs.h nup_dialogs.cpp migration_worker.h migration_worker.cpp libqt_common.h libqt_common.cpp diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 70000a22c9..e5a8f81424 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -42,6 +42,7 @@ #include "set_play_time_dialog.h" #include "util/util.h" #include "yuzu/game/game_list.h" +#include "yuzu/nup_dialogs.h" #include "applets/qt_amiibo_settings.h" #include "applets/qt_controller.h" @@ -4061,21 +4062,15 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { const bool resume = FileSys::Nup::HasResumableJournal(archive); bool delete_sources = resume; + FileSys::Nup::Metadata metadata; + QString progress_title; + QPixmap progress_icon; 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; @@ -4087,38 +4082,31 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { } 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) + NupMetadataDialog dialog(*root, archive, needed, space_error ? 0 : space.available, this); + if (dialog.exec() != QDialog::Accepted) return; - delete_sources = low_storage->isChecked(); + delete_sources = dialog.DeleteSources(); + metadata = dialog.Metadata(); + progress_title = dialog.DisplayName(); + progress_icon = dialog.CurrentIcon(); } nup_running = true; nup_source_path = game_path; game_list->SetNupConversionRunning(true); nup_cancel = std::make_shared<std::atomic_bool>(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 = new NupProgressDialog(progress_title, progress_icon, this); + connect(nup_progress, &NupProgressDialog::CancelRequested, this, + [this, cancel = nup_cancel] { + cancel->store(true); + if (nup_progress) + nup_progress->SetCancelling(); + }); nup_progress->show(); const auto cancel = nup_cancel; auto last_update = std::make_shared<std::chrono::steady_clock::time_point>(); - nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, + nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, metadata, cancel, last_update] { std::string error; const auto on_progress = [this, last_update]( @@ -4132,22 +4120,13 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { [this, progress] { if (!nup_progress) return; - const auto percent = progress.bytes_total == 0 - ? 0 - : static_cast<int>(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)); + nup_progress->SetProgress(progress); }, Qt::QueuedConnection); }; const auto result = FileSys::Nup::PackDump( - root, archive, {.delete_sources = delete_sources}, on_progress, cancel.get(), &error); + root, archive, {.delete_sources = delete_sources, .metadata = metadata}, on_progress, + cancel.get(), &error); QMetaObject::invokeMethod( this, [this, result, delete_sources, error] { @@ -4155,7 +4134,7 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { nup_source_path.clear(); game_list->SetNupConversionRunning(false); if (nup_progress) { - nup_progress->close(); + nup_progress->Finish(); nup_progress->deleteLater(); nup_progress = nullptr; } diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 290ecac6fb..e2c5a0f6db 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -55,6 +55,7 @@ class QLabel; class MultiplayerState; class QPushButton; class QProgressDialog; +class NupProgressDialog; class QSlider; class QHBoxLayout; class WaitTreeWidget; @@ -577,7 +578,7 @@ private: // Install progress dialog QProgressDialog* install_progress = nullptr; - QProgressDialog* nup_progress = nullptr; + NupProgressDialog* nup_progress = nullptr; QFutureWatcher<FileSys::Nup::PackResult> nup_watcher; std::shared_ptr<std::atomic_bool> nup_cancel; bool nup_running{}; diff --git a/src/yuzu/nup_dialogs.cpp b/src/yuzu/nup_dialogs.cpp new file mode 100644 index 0000000000..cd7fcba496 --- /dev/null +++ b/src/yuzu/nup_dialogs.cpp @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "yuzu/nup_dialogs.h" + +#include <QBuffer> +#include <QCheckBox> +#include <QCloseEvent> +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QMimeData> +#include <QMouseEvent> +#include <QPaintEvent> +#include <QDialogButtonBox> +#include <QFile> +#include <QFileDialog> +#include <QFontMetrics> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QImage> +#include <QImageReader> +#include <QLabel> +#include <QLineEdit> +#include <QPainter> +#include <QPainterPath> +#include <QProgressBar> +#include <QPushButton> +#include <QRadioButton> +#include <QUrl> +#include <QVBoxLayout> + +#include "common/fs/path_util.h" + +namespace { + +constexpr int IconSize = 256; +constexpr int PreviewSize = 128; + +QString ToQString(const std::filesystem::path& path) { + return QString::fromStdString(Common::FS::PathToUTF8String(path)); +} + +QString Megabytes(u64 bytes) { + return QString::number(bytes / (1024 * 1024)); +} + +QPixmap PixmapFromBytes(const std::vector<u8>& bytes) { + QPixmap pixmap; + pixmap.loadFromData(bytes.data(), static_cast<uint>(bytes.size())); + return pixmap; +} + +QPixmap RoundedPreview(const QPixmap& source, int size) { + const qreal ratio = 2.0; + QPixmap out(QSize(size, size) * ratio); + out.setDevicePixelRatio(ratio); + out.fill(Qt::transparent); + QPainter painter(&out); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath clip; + clip.addRoundedRect(QRectF(0, 0, size, size), size / 8.0, size / 8.0); + painter.setClipPath(clip); + painter.drawPixmap(QRectF(0, 0, size, size), source, source.rect()); + return out; +} + +// The console distributes 256x256 JPEG icons. A picture that already is one is kept as is; +// anything else is cropped to a square, scaled and re-encoded. +QByteArray EncodeIcon(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray raw = file.readAll(); + QImage image; + if (!image.loadFromData(raw) || image.isNull()) { + return {}; + } + if (image.size() == QSize(IconSize, IconSize) && raw.startsWith("\xFF\xD8")) { + return raw; + } + const int side = std::min(image.width(), image.height()); + image = image.copy((image.width() - side) / 2, (image.height() - side) / 2, side, side) + .scaled(IconSize, IconSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flat(IconSize, IconSize, QImage::Format_RGB32); + flat.fill(Qt::black); + QPainter painter(&flat); + painter.drawImage(0, 0, image); + painter.end(); + QByteArray out; + QBuffer buffer(&out); + buffer.open(QIODevice::WriteOnly); + flat.save(&buffer, "JPEG", 92); + return out; +} + +// The first dropped local file that Qt can read as an image. +QString DroppedImage(const QMimeData* mime) { + if (mime == nullptr) { + return {}; + } + for (const auto& url : mime->urls()) { + if (url.isLocalFile() && !QImageReader(url.toLocalFile()).format().isEmpty()) { + return url.toLocalFile(); + } + } + return {}; +} + +} // namespace + +IconDropZone::IconDropZone(int size, QWidget* parent) : QWidget(parent) { + setFixedSize(size, size); + setAcceptDrops(true); + setCursor(Qt::PointingHandCursor); + setToolTip(tr("Drop an image here or click to browse")); +} + +void IconDropZone::SetIcon(const QPixmap& new_icon) { + icon = new_icon; + update(); +} + +void IconDropZone::paintEvent(QPaintEvent*) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF area = QRectF(rect()).adjusted(1, 1, -1, -1); + const bool active = dragging || hovered; + if (!icon.isNull()) { + painter.drawPixmap(0, 0, RoundedPreview(icon, width())); + if (active) { + painter.setBrush(QColor(0, 0, 0, 120)); + painter.setPen(Qt::NoPen); + painter.drawRoundedRect(area, width() / 8.0, width() / 8.0); + painter.setPen(Qt::white); + painter.drawText(area, Qt::AlignCenter | Qt::TextWordWrap, tr("Drop to replace")); + } + return; + } + const auto accent = palette().color(QPalette::Highlight); + QPen pen(active ? accent : palette().color(QPalette::Mid), 2, Qt::DashLine); + painter.setPen(pen); + QColor fill = active ? accent : palette().color(QPalette::Mid); + fill.setAlpha(active ? 40 : 20); + painter.setBrush(fill); + painter.drawRoundedRect(area, width() / 8.0, width() / 8.0); + painter.setPen(palette().color(QPalette::PlaceholderText)); + painter.drawText(area.adjusted(8, 8, -8, -8), Qt::AlignCenter | Qt::TextWordWrap, + dragging ? tr("Release to use this image") + : tr("Drop an icon here\nor click to browse")); +} + +void IconDropZone::dragEnterEvent(QDragEnterEvent* event) { + if (!DroppedImage(event->mimeData()).isEmpty()) { + dragging = true; + event->acceptProposedAction(); + update(); + } +} + +void IconDropZone::dragLeaveEvent(QDragLeaveEvent*) { + dragging = false; + update(); +} + +void IconDropZone::dropEvent(QDropEvent* event) { + dragging = false; + update(); + if (const auto path = DroppedImage(event->mimeData()); !path.isEmpty()) { + event->acceptProposedAction(); + emit FileDropped(path); + } +} + +void IconDropZone::mouseReleaseEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton && rect().contains(event->position().toPoint())) { + emit Clicked(); + } +} + +void IconDropZone::enterEvent(QEnterEvent*) { + hovered = true; + update(); +} + +void IconDropZone::leaveEvent(QEvent*) { + hovered = false; + update(); +} + +NupMetadataDialog::NupMetadataDialog(const std::filesystem::path& source, + const std::filesystem::path& archive, u64 needed_bytes, + u64 available_bytes, QWidget* parent) + : QDialog(parent), detected(FileSys::Nup::ProbeMetadata(source)) { + setWindowTitle(tr("Convert to NUP")); + setMinimumWidth(520); + + icon_preview = new IconDropZone(PreviewSize); + connect(icon_preview, &IconDropZone::Clicked, this, &NupMetadataDialog::ChooseIcon); + connect(icon_preview, &IconDropZone::FileDropped, this, &NupMetadataDialog::SetIconFromFile); + icon_hint = new QLabel; + icon_hint->setWordWrap(true); + icon_hint->setEnabled(false); + auto* choose_icon = new QPushButton(tr("Choose Icon...")); + reset_icon = new QPushButton(tr("Use Detected")); + connect(choose_icon, &QPushButton::clicked, this, &NupMetadataDialog::ChooseIcon); + connect(reset_icon, &QPushButton::clicked, this, &NupMetadataDialog::ResetIcon); + + auto* icon_buttons = new QVBoxLayout; + icon_buttons->addWidget(choose_icon); + if (!detected.icon.empty()) { + icon_buttons->addWidget(reset_icon); + } else { + reset_icon->hide(); + } + icon_buttons->addWidget(icon_hint); + icon_buttons->addStretch(); + auto* icon_row = new QHBoxLayout; + icon_row->setSpacing(16); + icon_row->addWidget(icon_preview, 0, Qt::AlignTop); + icon_row->addLayout(icon_buttons, 1); + + name_edit = new QLineEdit(QString::fromStdString(detected.title_name)); + name_edit->setPlaceholderText(tr("Name shown in the game list")); + name_edit->setClearButtonEnabled(true); + + version_auto = new QRadioButton; + if (detected.display_version.empty()) { + version_auto->setText(tr("Auto-detect (nothing found in the dump)")); + } else { + version_auto->setText(tr("Auto-detect: %1 (from %2)") + .arg(QString::fromStdString(detected.display_version), + QString::fromStdString(detected.version_source))); + } + version_manual = new QRadioButton(tr("Set manually:")); + version_edit = new QLineEdit; + version_edit->setPlaceholderText(tr("e.g. 1.2.0")); + version_auto->setChecked(true); + connect(version_auto, &QRadioButton::toggled, this, &NupMetadataDialog::UpdateVersionEnabled); + auto* manual_row = new QHBoxLayout; + manual_row->addWidget(version_manual); + manual_row->addWidget(version_edit, 1); + auto* version_box = new QVBoxLayout; + version_box->setSpacing(6); + version_box->addWidget(version_auto); + version_box->addLayout(manual_row); + + auto* form = new QFormLayout; + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(14); + form->addRow(tr("Icon"), icon_row); + form->addRow(tr("Name"), name_edit); + form->addRow(tr("Version"), version_box); + + auto* paths = new QLabel(tr("<b>Source</b><br>%1<br><b>Target</b><br>%2") + .arg(ToQString(source).toHtmlEscaped(), + ToQString(archive).toHtmlEscaped())); + paths->setTextFormat(Qt::RichText); + paths->setWordWrap(true); + paths->setTextInteractionFlags(Qt::TextSelectableByMouse); + + auto* space = new QLabel(tr("Approximate space needed: %1 MB. Free on the target volume: " + "%2 MB.") + .arg(Megabytes(needed_bytes), Megabytes(available_bytes))); + space->setWordWrap(true); + if (available_bytes < needed_bytes) { + space->setText(space->text() + QLatin1Char(' ') + + tr("Free space is less than the estimate.")); + } + + low_storage = new QCheckBox(tr("Low storage: delete the original files as they are packed " + "(cannot be undone)")); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Cancel); + auto* convert = buttons->addButton(tr("Convert"), QDialogButtonBox::AcceptRole); + convert->setDefault(true); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto* separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 16); + layout->setSpacing(14); + layout->addLayout(form); + layout->addWidget(separator); + layout->addWidget(paths); + layout->addWidget(space); + layout->addWidget(low_storage); + layout->addSpacing(4); + layout->addWidget(buttons); + + UpdateIconPreview(); + UpdateVersionEnabled(); +} + +NupMetadataDialog::~NupMetadataDialog() = default; + +FileSys::Nup::Metadata NupMetadataDialog::Metadata() const { + FileSys::Nup::Metadata result; + // Only what differs from the dump is an override. + if (const auto name = name_edit->text().trimmed().toStdString(); name != detected.title_name) { + result.title_name = name; + } + if (!custom_icon.isEmpty()) { + result.icon.assign(custom_icon.begin(), custom_icon.end()); + } + if (version_manual->isChecked()) { + result.display_version = version_edit->text().trimmed().toStdString(); + } + return result; +} + +bool NupMetadataDialog::DeleteSources() const { + return low_storage->isChecked(); +} + +QString NupMetadataDialog::DisplayName() const { + return name_edit->text().trimmed(); +} + +QPixmap NupMetadataDialog::CurrentIcon() const { + return current_icon; +} + +void NupMetadataDialog::ChooseIcon() { + const auto path = QFileDialog::getOpenFileName( + this, tr("Choose Icon"), {}, tr("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)")); + if (!path.isEmpty()) { + SetIconFromFile(path); + } +} + +void NupMetadataDialog::SetIconFromFile(const QString& path) { + const auto encoded = EncodeIcon(path); + if (encoded.isEmpty()) { + icon_hint->setText(tr("That file could not be read as an image.")); + return; + } + custom_icon = encoded; + UpdateIconPreview(); +} + +void NupMetadataDialog::ResetIcon() { + custom_icon.clear(); + UpdateIconPreview(); +} + +void NupMetadataDialog::UpdateIconPreview() { + QPixmap pixmap; + if (!custom_icon.isEmpty()) { + pixmap.loadFromData(custom_icon); + icon_hint->setText(tr("Custom icon, stored as %1x%1 JPEG.").arg(IconSize)); + } else if (!detected.icon.empty()) { + pixmap = PixmapFromBytes(detected.icon); + icon_hint->setText(tr("Icon found in the dump.")); + } else { + icon_hint->setText(tr("No icon found in the dump. Add one, or the NUP will have none.")); + } + current_icon = pixmap; + reset_icon->setEnabled(!custom_icon.isEmpty()); + icon_preview->SetIcon(pixmap); +} + +void NupMetadataDialog::UpdateVersionEnabled() { + version_edit->setEnabled(version_manual->isChecked()); + if (version_manual->isChecked()) { + version_edit->setFocus(); + } +} + +NupProgressDialog::NupProgressDialog(const QString& title, const QPixmap& icon, QWidget* parent) + : QDialog(parent, Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint) { + setWindowTitle(tr("Converting to NUP")); + setModal(true); + setMinimumWidth(480); + + auto* header = new QHBoxLayout; + header->setSpacing(14); + if (!icon.isNull()) { + auto* icon_label = new QLabel; + icon_label->setFixedSize(56, 56); + icon_label->setPixmap(RoundedPreview(icon, 56)); + header->addWidget(icon_label); + } + auto* title_label = new QLabel(title.isEmpty() ? tr("Converting to NUP") : title); + QFont font = title_label->font(); + font.setBold(true); + font.setPointSizeF(font.pointSizeF() * 1.2); + title_label->setFont(font); + title_label->setWordWrap(true); + status = new QLabel(tr("Preparing...")); + status->setEnabled(false); + auto* titles = new QVBoxLayout; + titles->setSpacing(2); + titles->addStretch(); + titles->addWidget(title_label); + titles->addWidget(status); + titles->addStretch(); + header->addLayout(titles, 1); + + bar = new QProgressBar; + bar->setRange(0, 1000); + bar->setTextVisible(false); + bar->setFixedHeight(14); + bar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + details = new QLabel; + details->setAlignment(Qt::AlignCenter); + details->setMinimumHeight(QFontMetrics(details->font()).height() * 2 + 4); + + cancel = new QPushButton(tr("Cancel")); + connect(cancel, &QPushButton::clicked, this, &NupProgressDialog::CancelRequested); + auto* button_row = new QHBoxLayout; + button_row->addStretch(); + button_row->addWidget(cancel); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 20); + layout->setSpacing(14); + layout->addLayout(header); + layout->addWidget(bar); + layout->addWidget(details); + layout->addLayout(button_row); + timer.start(); +} + +void NupProgressDialog::SetProgress(const FileSys::Nup::PackProgress& progress) { + if (cancelling) { + return; + } + const double fraction = + progress.bytes_total == 0 ? 0.0 : double(progress.bytes_done) / progress.bytes_total; + bar->setValue(static_cast<int>(fraction * 1000)); + status->setText(tr("%1%").arg(fraction * 100, 0, 'f', 1)); + + QString text = tr("%1 / %2 MB - %3 / %4 files") + .arg(Megabytes(progress.bytes_done), Megabytes(progress.bytes_total)) + .arg(progress.files_done) + .arg(progress.files_total); + const double seconds = timer.elapsed() / 1000.0; + if (seconds >= 3.0 && progress.bytes_done > 0 && fraction < 1.0) { + const double rate = progress.bytes_done / seconds; + const int remaining = static_cast<int>((progress.bytes_total - progress.bytes_done) / rate); + text += tr("\n%1 MB/s - about %2:%3 left") + .arg(rate / (1024 * 1024), 0, 'f', 0) + .arg(remaining / 60) + .arg(remaining % 60, 2, 10, QLatin1Char('0')); + } + details->setText(text); +} + +void NupProgressDialog::SetCancelling() { + cancelling = true; + cancel->setEnabled(false); + status->setText(tr("Cancelling...")); + bar->setRange(0, 0); +} + +void NupProgressDialog::Finish() { + finished = true; + close(); +} + +void NupProgressDialog::closeEvent(QCloseEvent* event) { + // Only the window code closes this dialog; a close request cancels the conversion. + if (!finished) { + emit CancelRequested(); + event->ignore(); + return; + } + QDialog::closeEvent(event); +} + +void NupProgressDialog::reject() { + if (!finished) { + emit CancelRequested(); + return; + } + QDialog::reject(); +} diff --git a/src/yuzu/nup_dialogs.h b/src/yuzu/nup_dialogs.h new file mode 100644 index 0000000000..b9258c216b --- /dev/null +++ b/src/yuzu/nup_dialogs.h @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <filesystem> + +#include <QByteArray> +#include <QDialog> +#include <QElapsedTimer> +#include <QPixmap> +#include <QWidget> + +#include "common/common_types.h" +#include "core/file_sys/nup_writer.h" + +class QCheckBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QRadioButton; + +// Square preview that accepts an image dropped on it or chosen with a click. +class IconDropZone : public QWidget { + Q_OBJECT +public: + explicit IconDropZone(int size, QWidget* parent = nullptr); + + void SetIcon(const QPixmap& icon); + +signals: + void FileDropped(const QString& path); + void Clicked(); + +protected: + void paintEvent(QPaintEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dragLeaveEvent(QDragLeaveEvent* event) override; + void dropEvent(QDropEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void enterEvent(QEnterEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + QPixmap icon; + bool dragging = false; + bool hovered = false; +}; + +// Asks for the metadata a NUP is written with and for the conversion options. +class NupMetadataDialog : public QDialog { + Q_OBJECT +public: + NupMetadataDialog(const std::filesystem::path& source, const std::filesystem::path& archive, + u64 needed_bytes, u64 available_bytes, QWidget* parent = nullptr); + ~NupMetadataDialog() override; + + // Empty fields keep what the dump supplies. + FileSys::Nup::Metadata Metadata() const; + bool DeleteSources() const; + // What the summary shows: the name entered and the icon that will be stored. + QString DisplayName() const; + QPixmap CurrentIcon() const; + +private: + void ChooseIcon(); + void SetIconFromFile(const QString& path); + void ResetIcon(); + void UpdateIconPreview(); + void UpdateVersionEnabled(); + + FileSys::Nup::Metadata detected; + QByteArray custom_icon; + QPixmap current_icon; + + IconDropZone* icon_preview; + QLabel* icon_hint; + QPushButton* reset_icon; + QLineEdit* name_edit; + QRadioButton* version_auto; + QRadioButton* version_manual; + QLineEdit* version_edit; + QCheckBox* low_storage; +}; + +// Shown while a conversion runs. The bar spans the dialog; the labels show what is being +// converted, how much is done and a rough time remaining. +class NupProgressDialog : public QDialog { + Q_OBJECT +public: + NupProgressDialog(const QString& title, const QPixmap& icon, QWidget* parent = nullptr); + + void SetProgress(const FileSys::Nup::PackProgress& progress); + void SetCancelling(); + // Closes the dialog without treating it as a cancel request. + void Finish(); + +signals: + void CancelRequested(); + +protected: + void closeEvent(QCloseEvent* event) override; + void reject() override; + +private: + QLabel* status; + QLabel* details; + QProgressBar* bar; + QPushButton* cancel; + QElapsedTimer timer; + bool cancelling = false; + bool finished = false; +}; |
