aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorkraibse <33869366+kraibse@users.noreply.github.com>2026-11-02 17:36:33 +0100
committerGitHub <noreply@github.com>2026-11-02 17:36:33 +0100
commitead60c761e4d58cb4adc8b3b08f67dc10cf1e36b (patch)
tree14ebe42f9af0768afcd853a5cb3155a3c33bd89f /src
parent75bdb925dfb77db2370cb027eda81352c9622bbb (diff)
parent15255641e2cad1ce099819afc7308809442e47e7 (diff)
Merge pull request #318 from Djihads80/feat/nup-packer-ui
[emu-ui] Add a metadata mask to the NUP-packer
Diffstat (limited to 'src')
-rw-r--r--src/core/file_sys/nup_writer.cpp99
-rw-r--r--src/core/file_sys/nup_writer.h17
-rw-r--r--src/core/loader/deconstructed_rom_directory.cpp47
-rw-r--r--src/tests/core/nup_writer.cpp53
-rw-r--r--src/yuzu/CMakeLists.txt1
-rw-r--r--src/yuzu/main_window.cpp65
-rw-r--r--src/yuzu/main_window.h3
-rw-r--r--src/yuzu/nup_dialogs.cpp485
-rw-r--r--src/yuzu/nup_dialogs.h114
9 files changed, 820 insertions, 64 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/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;
+};