aboutsummaryrefslogtreecommitdiff
path: root/src/core
diff options
context:
space:
mode:
authora55uka <59313695+a55uka@users.noreply.github.com>2026-11-01 18:53:47 +0000
committera55uka <59313695+a55uka@users.noreply.github.com>2026-11-01 18:53:47 +0000
commit31b901b93b992580d608fc2f4db9ddac1d755e43 (patch)
tree4eb28dee71635147e35bff067af78582b7edf1ee /src/core
parent1d945527856d2eb9390d5030d1ae6b3ab1ab97dd (diff)
loader: add NUP (Unencrypted (Archival) Package) container support
Diffstat (limited to 'src/core')
-rw-r--r--src/core/CMakeLists.txt4
-rw-r--r--src/core/core.cpp21
-rw-r--r--src/core/file_sys/nup_container.cpp656
-rw-r--r--src/core/file_sys/nup_container.h177
-rw-r--r--src/core/loader/loader.cpp35
-rw-r--r--src/core/loader/loader.h3
-rw-r--r--src/core/loader/nup.cpp280
-rw-r--r--src/core/loader/nup.h65
8 files changed, 1238 insertions, 3 deletions
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 0075838d49..846ea7a25d 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -132,6 +132,8 @@ add_library(core STATIC
file_sys/program_metadata.h
file_sys/registered_cache.cpp
file_sys/registered_cache.h
+ file_sys/nup_container.cpp
+ file_sys/nup_container.h
file_sys/romfs.cpp
file_sys/romfs.h
file_sys/romfs_factory.cpp
@@ -1155,6 +1157,8 @@ add_library(core STATIC
loader/nso.h
loader/nsp.cpp
loader/nsp.h
+ loader/nup.cpp
+ loader/nup.h
loader/xci.cpp
loader/xci.h
memory.cpp
diff --git a/src/core/core.cpp b/src/core/core.cpp
index 41871df870..3842ee25a8 100644
--- a/src/core/core.cpp
+++ b/src/core/core.cpp
@@ -25,6 +25,7 @@
#include "core/debugger/debugger.h"
#include "core/device_memory.h"
#include "core/file_sys/fs_filesystem.h"
+#include "core/file_sys/nup_container.h"
#include "core/file_sys/patch_manager.h"
#include "core/file_sys/program_metadata.h"
#include "core/file_sys/registered_cache.h"
@@ -116,9 +117,27 @@ namespace {
// Switch 2 titles boot as a loose ExeFS, with main.npdm next to main.
bool IsSwitch2Program(const FileSys::VirtualFile& file) {
+ FileSys::ProgramMetadata metadata;
+ if (FileSys::NupContainer::IsNupFile(file)) {
+ FileSys::NupContainer nup{file};
+ if (nup.GetStatus() != Loader::ResultStatus::Success) {
+ return false;
+ }
+ std::optional<std::size_t> program;
+ if (const auto base = nup.FindBasePackage();
+ base && nup.GetExeFSDir(*base) != nullptr) {
+ program = base;
+ } else if (const auto update = nup.FindBestUpdatePackage();
+ update && nup.GetExeFSDir(*update) != nullptr) {
+ program = update;
+ }
+ const auto exefs = program ? nup.GetExeFSDir(*program) : nullptr;
+ const auto npdm = exefs != nullptr ? exefs->GetFile("main.npdm") : nullptr;
+ return npdm != nullptr && metadata.Load(npdm) == Loader::ResultStatus::Success &&
+ metadata.IsSwitch2();
+ }
const auto dir = file != nullptr ? file->GetContainingDirectory() : nullptr;
const auto npdm = dir != nullptr ? dir->GetFile("main.npdm") : nullptr;
- FileSys::ProgramMetadata metadata;
return npdm != nullptr && metadata.Load(npdm) == Loader::ResultStatus::Success &&
metadata.IsSwitch2();
}
diff --git a/src/core/file_sys/nup_container.cpp b/src/core/file_sys/nup_container.cpp
new file mode 100644
index 0000000000..efd08390a0
--- /dev/null
+++ b/src/core/file_sys/nup_container.cpp
@@ -0,0 +1,656 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/file_sys/nup_container.h"
+
+#include <algorithm>
+#include <cstring>
+#include <list>
+#include <map>
+#include <unordered_map>
+
+#include <openssl/evp.h>
+#include <zstd.h>
+
+#include "common/logging.h"
+#include "common/swap.h"
+#include "core/file_sys/vfs/vfs_offset.h"
+
+namespace FileSys {
+namespace {
+
+constexpr u32 NoDir = 0xFFFFFFFF;
+constexpr std::size_t SuperblockSize = 0x80;
+constexpr std::size_t PackageRecordSize = 0x40;
+constexpr std::size_t DirRecordSize = 0x18;
+constexpr std::size_t FileRecordSize = 0x50;
+constexpr std::size_t TocHeaderSize = 0x30;
+constexpr std::size_t TlvHeaderSize = 0x8;
+constexpr u32 TocMagic = 0x01434F54; // "TOC\x01" little-endian
+constexpr u8 CodecRaw = 0;
+constexpr u8 CodecZstd = 1;
+constexpr u8 PackageFlagStandaloneUpdate = 0x01;
+
+#pragma pack(push, 1)
+struct Superblock {
+ std::array<u8, 4> magic;
+ u16_le header_size;
+ u16_le header_version;
+ u64_le toc_offset;
+ u64_le toc_size;
+ std::array<u8, 0x20> toc_sha256;
+ u64_le blob_offset;
+ u64_le file_size;
+ u32_le package_count;
+ u32_le flags;
+ std::array<u8, 0x10> uuid;
+ std::array<u8, 0x20> reserved;
+};
+static_assert(sizeof(Superblock) == SuperblockSize);
+
+struct TocHeader {
+ u32_le magic;
+ u32_le package_count;
+ u64_le dir_count;
+ u64_le file_count;
+ u64_le pool_offset;
+ u64_le pool_size;
+ u64_le reserved;
+};
+static_assert(sizeof(TocHeader) == TocHeaderSize);
+
+struct PackageRecord {
+ u64_le title_id;
+ u32_le root_dir;
+ u32_le exefs_dir;
+ u32_le romfs_dir;
+ u32_le version;
+ u8 kind;
+ u8 flags;
+ u16_le reserved;
+ u64_le meta_offset;
+ u32_le meta_size;
+ u32_le reserved2;
+ std::array<u8, 20> reserved3;
+};
+static_assert(sizeof(PackageRecord) == PackageRecordSize);
+
+struct DirRecord {
+ u32_le name_offset;
+ u32_le name_length;
+ u32_le first_child;
+ u32_le child_count;
+ u64_le reserved;
+};
+static_assert(sizeof(DirRecord) == DirRecordSize);
+
+struct FileRecord {
+ u32_le name_offset;
+ u32_le name_length;
+ u32_le reserved;
+ u64_le size;
+ std::array<u8, 0x20> sha256;
+ u64_le extent_offset;
+ u64_le extent_size;
+ u8 codec;
+ u8 codec_flags;
+ u16_le reserved2;
+ std::array<u8, 8> reserved3;
+};
+static_assert(sizeof(FileRecord) == FileRecordSize);
+
+struct TlvHeader {
+ u16_le type;
+ u16_le reserved;
+ u32_le size;
+};
+static_assert(sizeof(TlvHeader) == TlvHeaderSize);
+#pragma pack(pop)
+
+enum MetaType : u16 {
+ MetaNpdm = 0x0001,
+ MetaIcon = 0x0003,
+ MetaDisplayVersion = 0x0004,
+ MetaTitleName = 0x0005,
+ MetaLocalizedTitles = 0x0009,
+ MetaLocalizedIcons = 0x000A,
+};
+
+bool Sha256(const u8* data, std::size_t size, std::array<u8, 0x20>& out) {
+ unsigned int length = 0;
+ EVP_Digest(data, size, out.data(), &length, EVP_sha256(), nullptr);
+ return length == out.size();
+}
+
+/// A member file whose extent is zstd-compressed. Decompresses on first read
+/// and caches the result (LRU-evicted) unless the file exceeds the cache cap.
+class NupVfsFile final : public VfsFile {
+public:
+ NupVfsFile(VirtualFile backing_, u64 extent_offset_, u64 extent_size_, u64 uncompressed_size_,
+ std::string name_, std::shared_ptr<NupDecompressCache> cache_,
+ std::size_t cache_index_)
+ : backing(std::move(backing_)), extent_offset(extent_offset_), extent_size(extent_size_),
+ uncompressed_size(uncompressed_size_), name(std::move(name_)), cache(std::move(cache_)),
+ cache_index(cache_index_), cache_enabled(uncompressed_size <= NupDecompressCache::DecompressCacheCap) {}
+
+ std::string GetName() const override {
+ return name;
+ }
+
+ std::size_t GetSize() const override {
+ return static_cast<std::size_t>(uncompressed_size);
+ }
+
+ bool Resize(std::size_t) override {
+ return false;
+ }
+
+ VirtualDir GetContainingDirectory() const override {
+ return nullptr;
+ }
+
+ bool IsWritable() const override {
+ return false;
+ }
+
+ bool IsReadable() const override {
+ return true;
+ }
+
+ std::size_t Read(u8* data, std::size_t length, std::size_t offset) const override {
+ if (offset >= uncompressed_size) {
+ return 0;
+ }
+ length = std::min(length, static_cast<std::size_t>(uncompressed_size) - offset);
+
+ {
+ std::scoped_lock lock{cache->mutex};
+ if (const auto* cached = cache->Find(cache_index)) {
+ std::memcpy(data, cached->data() + offset, length);
+ return length;
+ }
+ }
+
+ // Miss: decompress without holding the lock, then publish and copy.
+ std::vector<u8> scratch = Decompress();
+ if (scratch.empty()) {
+ return 0;
+ }
+ {
+ std::scoped_lock lock{cache->mutex};
+ if (cache_enabled) {
+ cache->Insert(cache_index, scratch);
+ }
+ if (const auto* cached = cache->Find(cache_index)) {
+ std::memcpy(data, cached->data() + offset, length);
+ return length;
+ }
+ }
+ std::memcpy(data, scratch.data() + offset, length);
+ return length;
+ }
+
+ std::size_t Write(const u8*, std::size_t, std::size_t) override {
+ return 0;
+ }
+
+ bool Rename(std::string_view new_name) override {
+ name = std::string(new_name);
+ return true;
+ }
+
+private:
+ std::vector<u8> Decompress() const {
+ if (extent_size > backing->GetSize() || extent_offset > backing->GetSize() - extent_size) {
+ return {};
+ }
+ const auto compressed = backing->ReadBytes(static_cast<std::size_t>(extent_size),
+ static_cast<std::size_t>(extent_offset));
+ if (compressed.size() != extent_size) {
+ return {};
+ }
+ std::vector<u8> out(static_cast<std::size_t>(uncompressed_size));
+ const std::size_t result =
+ ZSTD_decompress(out.data(), out.size(), compressed.data(), compressed.size());
+ if (ZSTD_isError(result) || result != out.size()) {
+ LOG_ERROR(Loader, "NUP zstd decompression failed for {}", name);
+ return {};
+ }
+ return out;
+ }
+
+ VirtualFile backing;
+ u64 extent_offset;
+ u64 extent_size;
+ u64 uncompressed_size;
+ std::string name;
+ std::shared_ptr<NupDecompressCache> cache;
+ std::size_t cache_index;
+ bool cache_enabled;
+};
+
+} // namespace
+
+
+bool NupContainer::IsNupFile(const VirtualFile& file) {
+ if (file == nullptr || file->GetSize() < SuperblockSize) {
+ return false;
+ }
+ std::array<u8, 4> magic{};
+ file->ReadObject(&magic, 0);
+ return magic == HeaderMagic;
+}
+
+NupContainer::NupContainer(VirtualFile file_)
+ : file(std::move(file_)), cache(std::make_shared<NupDecompressCache>()) {
+ if (file == nullptr) {
+ return;
+ }
+ if (Parse()) {
+ status = Loader::ResultStatus::Success;
+ } else if (status == Loader::ResultStatus::ErrorNullFile) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ }
+}
+
+NupContainer::~NupContainer() = default;
+
+bool NupContainer::Parse() {
+ if (file->GetSize() < SuperblockSize) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ Superblock super{};
+ if (file->ReadObject(&super, 0) != sizeof(Superblock)) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ if (super.magic != HeaderMagic || super.header_size < SuperblockSize || super.flags != 0) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ toc_offset = super.toc_offset;
+ toc_size = super.toc_size;
+ blob_offset = super.blob_offset;
+ file_size = super.file_size;
+ if (file_size != file->GetSize() || toc_offset < SuperblockSize ||
+ blob_offset < toc_offset + toc_size || blob_offset % 0x10 != 0 ||
+ blob_offset > file_size) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+
+ const auto toc = file->ReadBytes(static_cast<std::size_t>(toc_size),
+ static_cast<std::size_t>(toc_offset));
+ if (toc.size() != toc_size) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ std::array<u8, 0x20> toc_hash{};
+ if (!Sha256(toc.data(), toc.size(), toc_hash) || toc_hash != super.toc_sha256) {
+ LOG_ERROR(Loader, "NUP TOC SHA-256 mismatch, the file is corrupt or was modified");
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+
+ TocHeader header{};
+ if (toc.size() < sizeof(header)) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ std::memcpy(&header, toc.data(), sizeof(header));
+ if (header.magic != TocMagic || header.package_count == 0 ||
+ static_cast<u32>(header.package_count) != super.package_count) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+
+ const u64 needed = TocHeaderSize + static_cast<u64>(header.package_count) * PackageRecordSize +
+ header.dir_count * DirRecordSize + header.file_count * FileRecordSize;
+ const u64 children_offset = needed;
+ const u64 children_bytes = header.pool_offset - needed;
+ if (toc.size() < needed || header.pool_offset < needed ||
+ header.pool_offset + header.pool_size > toc.size() || children_bytes % sizeof(u32) != 0) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ const u8* base = toc.data();
+
+ std::size_t pos = TocHeaderSize;
+ packages.reserve(header.package_count);
+ for (u32 i = 0; i < header.package_count; ++i) {
+ PackageRecord record{};
+ std::memcpy(&record, base + pos, sizeof(record));
+ pos += PackageRecordSize;
+ if (record.kind > static_cast<u8>(PackageKind::Addon) ||
+ (record.flags & ~PackageFlagStandaloneUpdate) != 0 ||
+ record.root_dir >= header.dir_count ||
+ (record.exefs_dir != NoDir && record.exefs_dir >= header.dir_count) ||
+ (record.romfs_dir != NoDir && record.romfs_dir >= header.dir_count)) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ PackageInfo package;
+ package.title_id = record.title_id;
+ package.version = record.version;
+ package.kind = static_cast<PackageKind>(record.kind);
+ package.standalone_update = (record.flags & PackageFlagStandaloneUpdate) != 0;
+ package.root_dir = record.root_dir;
+ package.exefs_dir = record.exefs_dir;
+ package.romfs_dir = record.romfs_dir;
+ if (record.meta_size > 0 && record.meta_offset != 0 &&
+ record.meta_offset + record.meta_size <= file_size) {
+ package.meta_offset = record.meta_offset;
+ package.meta_size = record.meta_size;
+ }
+ packages.push_back(std::move(package));
+ }
+
+ dirs.resize(header.dir_count);
+ for (u64 i = 0; i < header.dir_count; ++i) {
+ DirRecord record{};
+ std::memcpy(&record, base + pos, sizeof(record));
+ pos += DirRecordSize;
+ if (record.first_child + record.child_count > children_bytes / sizeof(u32) ||
+ record.name_offset + record.name_length > header.pool_size) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ dirs[i].name = std::string(reinterpret_cast<const char*>(base) + header.pool_offset +
+ record.name_offset,
+ record.name_length);
+ dirs[i].first_child = record.first_child;
+ dirs[i].child_count = record.child_count;
+ }
+
+ files.resize(header.file_count);
+ for (u64 i = 0; i < header.file_count; ++i) {
+ FileRecord record{};
+ std::memcpy(&record, base + pos, sizeof(record));
+ pos += FileRecordSize;
+ if (record.codec > CodecZstd || record.codec_flags != 0 ||
+ (record.size == 0 && (record.extent_offset != 0 || record.extent_size != 0)) ||
+ (record.size != 0 &&
+ (record.extent_size == 0 || record.extent_offset < blob_offset ||
+ record.extent_offset % 0x10 != 0 ||
+ record.extent_offset + record.extent_size > file_size)) ||
+ record.name_offset + record.name_length > header.pool_size) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ files[i].name = std::string(reinterpret_cast<const char*>(base) + header.pool_offset +
+ record.name_offset,
+ record.name_length);
+ files[i].size = record.size;
+ files[i].sha256 = record.sha256;
+ files[i].extent_offset = record.extent_offset;
+ files[i].extent_size = record.extent_size;
+ files[i].codec = record.codec;
+ }
+
+ children.resize(children_bytes / sizeof(u32));
+ std::memcpy(children.data(), base + children_offset, children_bytes);
+ for (const u32 child : children) {
+ if (child >= header.dir_count + header.file_count) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ }
+
+ for (auto& package : packages) {
+ if (!ParseMeta(package)) {
+ status = Loader::ResultStatus::ErrorNUPBadFormat;
+ return false;
+ }
+ }
+ return true;
+}
+
+bool NupContainer::ParseMeta(PackageInfo& package) {
+ if (package.meta_offset == 0) {
+ return true;
+ }
+ const auto block = file->ReadBytes(static_cast<std::size_t>(package.meta_size),
+ static_cast<std::size_t>(package.meta_offset));
+ if (block.size() != package.meta_size) {
+ return false;
+ }
+ std::size_t pos = 0;
+ while (pos + TlvHeaderSize <= block.size()) {
+ TlvHeader header{};
+ std::memcpy(&header, block.data() + pos, sizeof(header));
+ pos += TlvHeaderSize;
+ if (pos + header.size > block.size()) {
+ return false;
+ }
+ const u8* payload = block.data() + pos;
+ pos += header.size;
+ switch (header.type) {
+ case MetaNpdm:
+ package.npdm.assign(payload, payload + header.size);
+ break;
+ case MetaIcon:
+ package.icon.assign(payload, payload + header.size);
+ break;
+ case MetaTitleName:
+ package.title_name = std::string(reinterpret_cast<const char*>(payload), header.size);
+ break;
+ case MetaDisplayVersion:
+ package.display_version =
+ std::string(reinterpret_cast<const char*>(payload), header.size);
+ break;
+ case MetaLocalizedTitles:
+ case MetaLocalizedIcons: {
+ const bool is_title = header.type == MetaLocalizedTitles;
+ std::size_t record_pos = 0;
+ while (record_pos < header.size) {
+ const u8 lang_length = payload[record_pos++];
+ if (record_pos + lang_length + sizeof(u32) > header.size) {
+ return false;
+ }
+ const std::string lang(reinterpret_cast<const char*>(payload) + record_pos,
+ lang_length);
+ record_pos += lang_length;
+ u32 data_length = 0;
+ std::memcpy(&data_length, payload + record_pos, sizeof(data_length));
+ record_pos += sizeof(data_length);
+ if (record_pos + data_length > header.size) {
+ return false;
+ }
+ if (is_title) {
+ package.localized_titles.emplace_back(
+ lang, std::string(reinterpret_cast<const char*>(payload) + record_pos,
+ data_length));
+ } else {
+ package.localized_icons.emplace_back(
+ lang, std::vector<u8>(payload + record_pos,
+ payload + record_pos + data_length));
+ }
+ record_pos += data_length;
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ return true;
+}
+
+std::optional<std::size_t> NupContainer::FindPackage(PackageKind kind,
+ std::optional<u64> title_id) const {
+ for (std::size_t i = 0; i < packages.size(); ++i) {
+ if (packages[i].kind != kind) {
+ continue;
+ }
+ if (title_id && packages[i].title_id != *title_id) {
+ continue;
+ }
+ return i;
+ }
+ return std::nullopt;
+}
+
+std::optional<std::size_t> NupContainer::FindBasePackage() const {
+ return FindPackage(PackageKind::Base);
+}
+
+std::optional<std::size_t> NupContainer::FindBestUpdatePackage() const {
+ std::optional<std::size_t> best;
+ for (std::size_t i = 0; i < packages.size(); ++i) {
+ if (packages[i].kind != PackageKind::Update) {
+ continue;
+ }
+ if (!best || packages[i].version > packages[*best].version) {
+ best = i;
+ }
+ }
+ return best;
+}
+
+VirtualFile NupContainer::OpenFile(std::size_t file_index) {
+ if (file_index >= files.size()) {
+ return nullptr;
+ }
+ const FileNode& node = files[file_index];
+ if (node.size == 0) {
+ return std::make_shared<VectorVfsFile>(std::vector<u8>{}, node.name);
+ }
+ if (node.codec == CodecRaw) {
+ return std::make_shared<OffsetVfsFile>(
+ file, static_cast<std::size_t>(node.extent_size),
+ static_cast<std::size_t>(node.extent_offset), node.name);
+ }
+ return std::make_shared<NupVfsFile>(file, node.extent_offset, node.extent_size, node.size,
+ node.name, cache, file_index);
+}
+
+void NupContainer::BuildDirTree(u32 dir_index, VectorVfsDirectory& out) {
+ const DirNode& node = dirs[dir_index];
+ for (u32 slot = node.first_child; slot < node.first_child + node.child_count; ++slot) {
+ const u32 child = children[slot];
+ if (child >= dirs.size()) {
+ out.AddFile(OpenFile(child - static_cast<u32>(dirs.size())));
+ } else {
+ auto sub = std::make_shared<VectorVfsDirectory>(std::vector<VirtualFile>{},
+ std::vector<VirtualDir>{},
+ dirs[child].name);
+ BuildDirTree(child, *sub);
+ out.AddDirectory(sub);
+ }
+ }
+}
+
+VirtualDir NupContainer::BuildPackageDir(u32 dir_index) {
+ auto out = std::make_shared<VectorVfsDirectory>(std::vector<VirtualFile>{},
+ std::vector<VirtualDir>{}, dirs[dir_index].name);
+ BuildDirTree(dir_index, *out);
+ return out;
+}
+
+VirtualDir NupContainer::GetExeFSDir(std::size_t package_index) {
+ if (package_index >= packages.size() || packages[package_index].exefs_dir == NoDir) {
+ return nullptr;
+ }
+ if (exefs_dir_cache.size() <= package_index) {
+ exefs_dir_cache.resize(packages.size());
+ }
+ if (exefs_dir_cache[package_index] == nullptr) {
+ exefs_dir_cache[package_index] = BuildPackageDir(packages[package_index].exefs_dir);
+ }
+ return exefs_dir_cache[package_index];
+}
+
+VirtualDir NupContainer::GetRomFSDir(std::size_t package_index) {
+ if (package_index >= packages.size() || packages[package_index].romfs_dir == NoDir) {
+ return nullptr;
+ }
+ if (romfs_dir_cache.size() <= package_index) {
+ romfs_dir_cache.resize(packages.size());
+ }
+ if (romfs_dir_cache[package_index] == nullptr) {
+ romfs_dir_cache[package_index] = BuildPackageDir(packages[package_index].romfs_dir);
+ }
+ return romfs_dir_cache[package_index];
+}
+
+VirtualDir NupContainer::MergeShadowDirs(const VirtualDir& base, const VirtualDir& overlay) {
+ if (base == nullptr) {
+ return overlay;
+ }
+ if (overlay == nullptr) {
+ return base;
+ }
+
+ std::map<std::string, VirtualFile, std::less<>> file_map;
+ for (const auto& entry : base->GetFiles()) {
+ file_map.insert_or_assign(entry->GetName(), entry);
+ }
+ for (const auto& entry : overlay->GetFiles()) {
+ file_map.insert_or_assign(entry->GetName(), entry);
+ }
+
+ std::map<std::string, VirtualDir, std::less<>> dir_map;
+ for (const auto& entry : base->GetSubdirectories()) {
+ dir_map.insert_or_assign(entry->GetName(), entry);
+ }
+ for (const auto& entry : overlay->GetSubdirectories()) {
+ const auto it = dir_map.find(entry->GetName());
+ if (it != dir_map.end()) {
+ dir_map.insert_or_assign(entry->GetName(), MergeShadowDirs(it->second, entry));
+ } else {
+ dir_map.insert_or_assign(entry->GetName(), entry);
+ }
+ }
+
+ std::vector<VirtualFile> merged_files;
+ merged_files.reserve(file_map.size());
+ for (const auto& [_, entry] : file_map) {
+ merged_files.push_back(entry);
+ }
+ std::vector<VirtualDir> merged_dirs;
+ merged_dirs.reserve(dir_map.size());
+ for (const auto& [_, entry] : dir_map) {
+ merged_dirs.push_back(entry);
+ }
+ return std::make_shared<VectorVfsDirectory>(std::move(merged_files), std::move(merged_dirs),
+ base->GetName());
+}
+
+std::vector<u8> NupContainer::ReadExtentRaw(const FileNode& node) const {
+ return file->ReadBytes(static_cast<std::size_t>(node.extent_size),
+ static_cast<std::size_t>(node.extent_offset));
+}
+
+Loader::ResultStatus NupContainer::VerifyIntegrity(
+ std::function<bool(std::size_t, std::size_t)> progress_callback) const {
+ const std::size_t total = files.size();
+ for (std::size_t i = 0; i < total; ++i) {
+ const FileNode& node = files[i];
+ std::vector<u8> content;
+ if (node.size == 0) {
+ content = ReadExtentRaw(node);
+ } else if (node.codec == CodecRaw) {
+ content = ReadExtentRaw(node);
+ } else {
+ NupVfsFile view(file, node.extent_offset, node.extent_size, node.size, node.name,
+ cache, i);
+ content = view.ReadAllBytes();
+ }
+ if (content.size() != node.size) {
+ LOG_ERROR(Loader, "NUP integrity check failed for {}: short read", node.name);
+ return Loader::ResultStatus::ErrorIntegrityVerificationFailed;
+ }
+ std::array<u8, 0x20> hash{};
+ if (!Sha256(content.data(), content.size(), hash) || hash != node.sha256) {
+ LOG_ERROR(Loader, "NUP integrity check failed for {}: SHA-256 mismatch", node.name);
+ return Loader::ResultStatus::ErrorIntegrityVerificationFailed;
+ }
+ if (progress_callback && !progress_callback(i + 1, total)) {
+ return Loader::ResultStatus::ErrorIntegrityVerificationFailed;
+ }
+ }
+ return Loader::ResultStatus::Success;
+}
+
+} // namespace FileSys
diff --git a/src/core/file_sys/nup_container.h b/src/core/file_sys/nup_container.h
new file mode 100644
index 0000000000..118b137a7b
--- /dev/null
+++ b/src/core/file_sys/nup_container.h
@@ -0,0 +1,177 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+#include <functional>
+#include <list>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "common/common_types.h"
+#include "core/file_sys/vfs/vfs_types.h"
+#include "core/file_sys/vfs/vfs_vector.h"
+#include "core/loader/loader.h"
+
+namespace FileSys {
+
+/// LRU over decompressed zstd extents, shared by the files of one container.
+struct NupDecompressCache {
+ std::mutex mutex;
+ std::list<std::pair<std::size_t, std::vector<u8>>> lru;
+ std::unordered_map<std::size_t, std::list<std::pair<std::size_t, std::vector<u8>>>::iterator>
+ index;
+ std::size_t bytes{};
+
+ std::vector<u8>* Find(std::size_t file_index) {
+ const auto it = index.find(file_index);
+ if (it == index.end()) {
+ return nullptr;
+ }
+ lru.splice(lru.begin(), lru, it->second);
+ return &it->second->second;
+ }
+
+ void Insert(std::size_t file_index, std::vector<u8>& data) {
+ while (bytes + data.size() > DecompressCacheCap && !lru.empty()) {
+ bytes -= lru.back().second.size();
+ index.erase(lru.back().first);
+ lru.pop_back();
+ }
+ if (bytes + data.size() > DecompressCacheCap) {
+ return;
+ }
+ lru.emplace_front(file_index, data);
+ index.emplace(file_index, lru.begin());
+ bytes += data.size();
+ }
+
+ static constexpr std::size_t DecompressCacheCap = 512 * 1024 * 1024;
+};
+
+/**
+ * NUP (NX Package) container: stores loose ExeFS/RomFS trees for a base program,
+ * its updates and add-on content in one file. Layout:
+ *
+ * Superblock (0x80 bytes, magic "NUP\x01")
+ * Table of Contents (packages, dir/file nodes, children array, string pool)
+ * Blob region (per-file extents, raw or zstd codec, 16-byte aligned)
+ *
+ * Per-package meta TLVs carry NPDM bytes, title names (default + localized),
+ * icons and a display version. All integers little-endian.
+ */
+class NupContainer {
+public:
+ static constexpr std::array<u8, 4> HeaderMagic{'N', 'U', 'P', 0x01};
+
+ enum class PackageKind : u8 {
+ Base = 0,
+ Update = 1,
+ Addon = 2,
+ };
+
+
+ struct PackageInfo {
+ u64 title_id{};
+ u32 version{};
+ PackageKind kind{};
+ bool standalone_update{};
+ u32 root_dir{};
+ u32 exefs_dir{};
+ u32 romfs_dir{};
+ u64 meta_offset{};
+ u32 meta_size{};
+
+ std::vector<u8> npdm;
+ std::string title_name;
+ std::vector<std::pair<std::string, std::string>> localized_titles;
+ std::vector<u8> icon;
+ std::vector<std::pair<std::string, std::vector<u8>>> localized_icons;
+ std::string display_version;
+ };
+
+ /// Quick magic check without a full parse.
+ static bool IsNupFile(const VirtualFile& file);
+
+ /// Recursively merges overlay into a copy of base; overlay paths win.
+ static VirtualDir MergeShadowDirs(const VirtualDir& base, const VirtualDir& overlay);
+
+ explicit NupContainer(VirtualFile file_);
+ ~NupContainer();
+
+ Loader::ResultStatus GetStatus() const {
+ return status;
+ }
+
+ const std::vector<PackageInfo>& GetPackages() const {
+ return packages;
+ }
+
+ std::optional<std::size_t> FindPackage(PackageKind kind,
+ std::optional<u64> title_id = std::nullopt) const;
+ std::optional<std::size_t> FindBasePackage() const;
+ /// Highest version among Update packages, if any.
+ std::optional<std::size_t> FindBestUpdatePackage() const;
+
+ /// Opens a member file by node index; raw extents are zero-copy views into
+ /// the container, zstd extents are lazily decompressed and cached.
+ VirtualFile OpenFile(std::size_t file_index);
+
+ VirtualDir GetExeFSDir(std::size_t package_index);
+ VirtualDir GetRomFSDir(std::size_t package_index);
+
+ /// Stream-hashes every member file against its stored SHA-256.
+ Loader::ResultStatus VerifyIntegrity(
+ std::function<bool(std::size_t, std::size_t)> progress_callback) const;
+
+ std::size_t GetFileCount() const {
+ return files.size();
+ }
+
+private:
+ struct DirNode {
+ std::string name;
+ u32 first_child{};
+ u32 child_count{};
+ };
+
+ struct FileNode {
+ std::string name;
+ u64 size{};
+ std::array<u8, 0x20> sha256{};
+ u64 extent_offset{};
+ u64 extent_size{};
+ u8 codec{};
+ };
+
+ bool Parse();
+ bool ParseMeta(PackageInfo& package);
+ void BuildDirTree(u32 dir_index, VectorVfsDirectory& out);
+ VirtualDir BuildPackageDir(u32 dir_index);
+ std::vector<u8> ReadExtentRaw(const FileNode& node) const;
+
+ VirtualFile file;
+ Loader::ResultStatus status{Loader::ResultStatus::ErrorNullFile};
+
+ u64 toc_offset{};
+ u64 toc_size{};
+ u64 blob_offset{};
+ u64 file_size{};
+
+ std::vector<DirNode> dirs;
+ std::vector<FileNode> files;
+ std::vector<u32> children;
+ std::vector<PackageInfo> packages;
+
+ std::vector<VirtualDir> exefs_dir_cache;
+ std::vector<VirtualDir> romfs_dir_cache;
+
+ std::shared_ptr<NupDecompressCache> cache;
+};
+
+} // namespace FileSys
diff --git a/src/core/loader/loader.cpp b/src/core/loader/loader.cpp
index 33a93573cf..839bb4d80b 100644
--- a/src/core/loader/loader.cpp
+++ b/src/core/loader/loader.cpp
@@ -29,6 +29,7 @@
#include "core/loader/nro.h"
#include "core/loader/nso.h"
#include "core/loader/nsp.h"
+#include "core/loader/nup.h"
#include "core/loader/xci.h"
namespace Loader {
@@ -130,6 +131,8 @@ FileType IdentifyFile(FileSys::VirtualFile file) {
return *nsp_type;
} else if (const auto xci_type = IdentifyFileLoader<AppLoader_XCI>(file)) {
return *xci_type;
+ } else if (const auto nup_type = IdentifyFileLoader<AppLoader_NUP>(file)) {
+ return *nup_type;
} else if (const auto nro_type = IdentifyFileLoader<AppLoader_NRO>(file)) {
return *nro_type;
} else if (const auto nca_type = IdentifyFileLoader<AppLoader_NCA>(file)) {
@@ -150,7 +153,7 @@ FileType IdentifyFile(FileSys::VirtualFile file) {
bool IsContainerType(FileType type) {
return type == FileType::NSP || type == FileType::NSZ || type == FileType::XCI ||
- type == FileType::XCZ;
+ type == FileType::XCZ || type == FileType::NUP;
}
bool IsBootableGameContainer(FileSys::VirtualFile file, FileType type, u64 program_id,
@@ -163,6 +166,24 @@ bool IsBootableGameContainer(FileSys::VirtualFile file, FileType type, u64 progr
type = IdentifyFile(file);
}
+ if (type == FileType::NUP) {
+ FileSys::NupContainer nup{file};
+ if (nup.GetStatus() != ResultStatus::Success) {
+ return false;
+ }
+ // A base package boots normally; a standalone update (no base) carries
+ // a complete patched tree and boots on its own (SPEC section 4.1.2).
+ if (const auto base = nup.FindBasePackage();
+ base.has_value() && nup.GetExeFSDir(*base) != nullptr) {
+ return true;
+ }
+ if (const auto update = nup.FindBestUpdatePackage();
+ update.has_value() && nup.GetExeFSDir(*update) != nullptr) {
+ return true;
+ }
+ return false;
+ }
+
if (!IsContainerType(type)) {
return false;
}
@@ -190,6 +211,8 @@ FileType GuessFromFilename(const std::string& name) {
return FileType::XCZ;
else if (extension == "nsp")
return FileType::NSP;
+ else if (extension == "nup")
+ return FileType::NUP;
else if (extension == "nsz")
return FileType::NSZ;
else if (extension == "kip")
@@ -215,6 +238,8 @@ std::string GetFileTypeString(FileType type) {
return "NSP";
case FileType::NSZ:
return "NSZ";
+ case FileType::NUP:
+ return "NUP";
case FileType::KIP:
return "KIP";
case FileType::DeconstructedRomDirectory:
@@ -227,7 +252,7 @@ std::string GetFileTypeString(FileType type) {
return "unknown";
}
-constexpr std::array<const char*, 68> RESULT_MESSAGES{
+constexpr std::array<const char*, 70> RESULT_MESSAGES{
"The operation completed successfully.",
"The loader requested to load is already loaded.",
"The operation is not implemented.",
@@ -296,6 +321,8 @@ constexpr std::array<const char*, 68> RESULT_MESSAGES{
"The INI file contains more than the maximum allowable number of KIP files.",
"Integrity verification could not be performed for this file.",
"Integrity verification failed.",
+ "The NUP container is malformed or its TOC hash does not match.",
+ "The NUP container does not contain a loadable base package.",
};
std::string GetResultStatusString(ResultStatus status) {
@@ -380,6 +407,10 @@ static std::unique_ptr<AppLoader> GetFileLoader(Core::System& system, FileSys::V
case FileType::KIP:
return std::make_unique<AppLoader_KIP>(std::move(file));
+ // NUP (NX Package) container format.
+ case FileType::NUP:
+ return std::make_unique<AppLoader_NUP>(std::move(file));
+
// NX deconstructed ROM directory.
case FileType::DeconstructedRomDirectory:
return std::make_unique<AppLoader_DeconstructedRomDirectory>(std::move(file));
diff --git a/src/core/loader/loader.h b/src/core/loader/loader.h
index 431aeace73..23be3597f5 100644
--- a/src/core/loader/loader.h
+++ b/src/core/loader/loader.h
@@ -47,6 +47,7 @@ enum class FileType {
XCZ,
NAX,
KIP,
+ NUP,
DeconstructedRomDirectory,
};
@@ -157,6 +158,8 @@ enum class ResultStatus : u16 {
ErrorINITooManyKIPs,
ErrorIntegrityVerificationNotImplemented,
ErrorIntegrityVerificationFailed,
+ ErrorNUPBadFormat,
+ ErrorNUPNoBasePackage,
};
std::string GetResultStatusString(ResultStatus status);
diff --git a/src/core/loader/nup.cpp b/src/core/loader/nup.cpp
new file mode 100644
index 0000000000..7ddfb8947e
--- /dev/null
+++ b/src/core/loader/nup.cpp
@@ -0,0 +1,280 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/loader/nup.h"
+
+#include <algorithm>
+#include <cstring>
+
+#include "common/logging.h"
+#include "common/string_util.h"
+#include "core/core.h"
+#include "core/file_sys/nup_container.h"
+#include "core/file_sys/control_metadata.h"
+#include "core/file_sys/patch_manager.h"
+#include "core/file_sys/romfs.h"
+#include "core/file_sys/romfs_factory.h"
+#include "core/file_sys/vfs/vfs_vector.h"
+#include "core/hle/kernel/k_process.h"
+#include "core/hle/service/filesystem/filesystem.h"
+#include "core/loader/deconstructed_rom_directory.h"
+
+namespace Loader {
+
+namespace {
+
+using FileSys::NupContainer;
+
+// NACP stores 16 uncompressed language entries; map BCP-47 tags onto them.
+struct LanguageSlot {
+ std::string_view tag;
+ std::size_t index;
+};
+
+constexpr std::array<LanguageSlot, 18> LanguageSlots{{
+ {"en-us", 0}, {"en-gb", 1}, {"ja", 2}, {"fr", 3}, {"de", 4},
+ {"es-419", 5}, {"es", 6}, {"it", 7}, {"nl", 8}, {"fr-ca", 9},
+ {"pt", 10}, {"ru", 11}, {"ko", 12}, {"zh-hant", 13}, {"zh-hans", 14},
+ {"pt-br", 15}, {"pl", 16}, {"th", 17},
+}};
+
+void CopyNameToEntry(FileSys::LanguageEntry& entry, std::string_view name) {
+ const std::size_t length = std::min(name.size(), entry.application_name.size() - 1);
+ std::memcpy(entry.application_name.data(), name.data(), length);
+ entry.application_name[length] = '\0';
+}
+
+std::unique_ptr<FileSys::NACP> BuildNacp(const NupContainer::PackageInfo& base,
+ const std::string& version) {
+ if (base.title_name.empty() && base.localized_titles.empty() && version.empty()) {
+ return nullptr;
+ }
+ auto nacp = std::make_unique<FileSys::NACP>();
+ nacp->raw.supported_languages = static_cast<FileSys::SupportedLanguage>(0x3FFFF);
+
+ for (const auto& [lang, name] : base.localized_titles) {
+ for (const auto& slot : LanguageSlots) {
+ // The uncompressed RawNACP only stores 16 entries; Polish/Thai exist
+ // only in the compressed format, so they fall back to the default.
+ if (slot.index < 16 && slot.tag == lang) {
+ CopyNameToEntry(nacp->raw.language_entries.language_entries[slot.index], name);
+ }
+ }
+ }
+ if (!base.title_name.empty()) {
+ CopyNameToEntry(nacp->raw.language_entries.language_entries[0], base.title_name);
+ }
+ if (!version.empty()) {
+ const std::size_t length = std::min(version.size(), nacp->raw.version_string.size() - 1);
+ std::memcpy(nacp->raw.version_string.data(), version.data(), length);
+ nacp->raw.version_string[length] = '\0';
+ }
+ nacp->raw.save_data_owner_id = base.title_id;
+
+ nacp->language_entries.resize(16);
+ std::memcpy(nacp->language_entries.data(), nacp->raw.language_entries.language_entries.data(),
+ sizeof(nacp->raw.language_entries.language_entries));
+ return nacp;
+}
+
+std::string DecodeVersion(u32 version) {
+ if (version == 0) {
+ return {};
+ }
+ return fmt::format("{}.{}.{}", version >> 16, (version >> 8) & 0xFF, version & 0xFF);
+}
+
+} // namespace
+
+AppLoader_NUP::AppLoader_NUP(FileSys::VirtualFile file_)
+ : AppLoader(std::move(file_)), nup(std::make_unique<NupContainer>(file)) {
+ if (nup->GetStatus() != ResultStatus::Success) {
+ return;
+ }
+
+ const auto base_index = nup->FindBasePackage();
+ const auto update_index = nup->FindBestUpdatePackage();
+
+ // The program comes from the base package; a standalone update container
+ // (SPEC §4.1.2) carries a complete patched tree and boots on its own.
+ std::optional<std::size_t> program_index;
+ std::optional<std::size_t> exefs_index;
+ if (base_index && nup->GetExeFSDir(*base_index) != nullptr) {
+ program_index = base_index;
+ exefs_index = update_index && nup->GetExeFSDir(*update_index) != nullptr
+ ? update_index
+ : base_index;
+ } else if (update_index && nup->GetExeFSDir(*update_index) != nullptr) {
+ LOG_INFO(Loader,
+ "NUP container is a standalone update; booting the update program directly");
+ program_index = update_index;
+ exefs_index = update_index;
+ }
+ if (!program_index || !exefs_index) {
+ LOG_ERROR(Loader, "NUP container has no package with a usable ExeFS");
+ return;
+ }
+
+ const auto& program = nup->GetPackages()[*program_index];
+ base_title_id = program.title_id;
+ has_program = true;
+
+ const auto exefs = nup->GetExeFSDir(*exefs_index);
+ if (exefs == nullptr || exefs->GetFile("main") == nullptr ||
+ exefs->GetFile("main.npdm") == nullptr) {
+ LOG_ERROR(Loader, "NUP package has no usable ExeFS");
+ return;
+ }
+ secondary_loader = std::make_unique<AppLoader_DeconstructedRomDirectory>(exefs, true);
+
+ if (update_index) {
+ const auto& update = nup->GetPackages()[*update_index];
+ display_version = update.display_version.empty() ? DecodeVersion(update.version)
+ : update.display_version;
+ } else {
+ display_version = program.display_version.empty() ? DecodeVersion(program.version)
+ : program.display_version;
+ }
+ nacp_file = BuildNacp(program, display_version);
+ if (nacp_file != nullptr && !program.icon.empty()) {
+ icon_file = std::make_shared<FileSys::VectorVfsFile>(program.icon, "icon.dat");
+ } else if (nacp_file != nullptr) {
+ for (const auto& [lang, icon] : program.localized_icons) {
+ if (lang == "en-us") {
+ icon_file = std::make_shared<FileSys::VectorVfsFile>(icon, "icon_" + lang + ".dat");
+ break;
+ }
+ }
+ if (icon_file == nullptr && !program.localized_icons.empty()) {
+ icon_file = std::make_shared<FileSys::VectorVfsFile>(
+ program.localized_icons.front().second, "icon.dat");
+ }
+ }
+
+ const auto addon_count = std::count_if(nup->GetPackages().begin(), nup->GetPackages().end(),
+ [](const auto& package) {
+ return package.kind ==
+ NupContainer::PackageKind::Addon;
+ });
+ if (addon_count > 0) {
+ LOG_WARNING(Loader,
+ "NUP container has {} add-on content package(s); AoC mounting is not "
+ "supported yet",
+ addon_count);
+ }
+}
+
+AppLoader_NUP::~AppLoader_NUP() = default;
+
+FileType AppLoader_NUP::IdentifyType(const FileSys::VirtualFile& nup_file) {
+ return FileSys::NupContainer::IsNupFile(nup_file) ? FileType::NUP : FileType::Error;
+}
+
+AppLoader::LoadResult AppLoader_NUP::Load(Kernel::KProcess& process, Core::System& system) {
+ if (is_loaded) {
+ return {ResultStatus::ErrorAlreadyLoaded, {}};
+ }
+ if (nup->GetStatus() != ResultStatus::Success) {
+ return {nup->GetStatus(), {}};
+ }
+ if (!has_program || secondary_loader == nullptr) {
+ return {ResultStatus::ErrorNUPNoBasePackage, {}};
+ }
+
+ const auto result = secondary_loader->Load(process, system);
+ if (result.first != ResultStatus::Success) {
+ return result;
+ }
+
+ system.GetFileSystemController().RegisterProcess(
+ process.GetProcessId(), base_title_id,
+ std::make_shared<FileSys::RomFSFactory>(*this, system.GetContentProvider(),
+ system.GetFileSystemController()));
+
+ is_loaded = true;
+ return result;
+}
+
+ResultStatus AppLoader_NUP::VerifyIntegrity(
+ std::function<bool(size_t, size_t)> progress_callback) {
+ if (nup->GetStatus() != ResultStatus::Success) {
+ return nup->GetStatus();
+ }
+ return nup->VerifyIntegrity(std::move(progress_callback));
+}
+
+ResultStatus AppLoader_NUP::ReadRomFS(FileSys::VirtualFile& out_file) {
+ const auto base_index = nup->FindBasePackage();
+ const auto base_romfs = base_index ? nup->GetRomFSDir(*base_index) : FileSys::VirtualDir{};
+ const auto update_index = nup->FindBestUpdatePackage();
+ const auto update_romfs =
+ update_index ? nup->GetRomFSDir(*update_index) : FileSys::VirtualDir{};
+ // A standalone update carries the complete merged tree; a base+update pair
+ // overlays the update onto the base (SPEC §4.1.4).
+ const auto merged = FileSys::NupContainer::MergeShadowDirs(base_romfs, update_romfs);
+ if (merged == nullptr) {
+ return ResultStatus::ErrorNoRomFS;
+ }
+ out_file = FileSys::CreateRomFS(merged);
+ if (out_file == nullptr) {
+ return ResultStatus::ErrorNoRomFS;
+ }
+ return ResultStatus::Success;
+}
+
+bool AppLoader_NUP::IsRomFSUpdatable() const {
+ // The update is already merged into the exposed RomFS.
+ return false;
+}
+
+ResultStatus AppLoader_NUP::ReadProgramId(u64& out_program_id) {
+ if (!has_program) {
+ return ResultStatus::ErrorNotInitialized;
+ }
+ out_program_id = base_title_id;
+ return ResultStatus::Success;
+}
+
+ResultStatus AppLoader_NUP::ReadProgramIds(std::vector<u64>& out_program_ids) {
+ if (!has_program) {
+ return ResultStatus::ErrorNotInitialized;
+ }
+ out_program_ids.push_back(base_title_id);
+ return ResultStatus::Success;
+}
+
+ResultStatus AppLoader_NUP::ReadIcon(std::vector<u8>& buffer) {
+ if (icon_file == nullptr) {
+ return ResultStatus::ErrorNoIcon;
+ }
+ buffer = icon_file->ReadAllBytes();
+ return ResultStatus::Success;
+}
+
+ResultStatus AppLoader_NUP::ReadTitle(std::string& title) {
+ if (nacp_file == nullptr) {
+ return ResultStatus::ErrorNoControl;
+ }
+ title = nacp_file->GetApplicationName();
+ if (title.empty()) {
+ return ResultStatus::ErrorNoControl;
+ }
+ return ResultStatus::Success;
+}
+
+ResultStatus AppLoader_NUP::ReadControlData(FileSys::NACP& control) {
+ if (nacp_file == nullptr) {
+ return ResultStatus::ErrorNoControl;
+ }
+ control = *nacp_file;
+ return ResultStatus::Success;
+}
+
+ResultStatus AppLoader_NUP::ReadNSOModules(Modules& modules) {
+ if (secondary_loader == nullptr) {
+ return ResultStatus::ErrorNotInitialized;
+ }
+ return secondary_loader->ReadNSOModules(modules);
+}
+
+} // namespace Loader
diff --git a/src/core/loader/nup.h b/src/core/loader/nup.h
new file mode 100644
index 0000000000..1f9dd5922b
--- /dev/null
+++ b/src/core/loader/nup.h
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "core/file_sys/nup_container.h"
+#include "core/loader/loader.h"
+
+namespace FileSys {
+class NACP;
+class NupContainer;
+} // namespace FileSys
+
+namespace Loader {
+
+/// Loads a NUP (NX Package) container: loose ExeFS/RomFS trees for a base
+/// program plus its updates in one file. The newest update package is layered
+/// over the base; add-on content packages are recognized but not yet mounted.
+class AppLoader_NUP final : public AppLoader {
+public:
+ explicit AppLoader_NUP(FileSys::VirtualFile file_);
+ ~AppLoader_NUP() override;
+
+ /**
+ * Identifies whether or not the given file is a NUP container.
+ *
+ * @param nup_file The file to identify.
+ *
+ * @return FileType::NUP, or FileType::Error if the file is not a NUP.
+ */
+ static FileType IdentifyType(const FileSys::VirtualFile& nup_file);
+
+ FileType GetFileType() const override {
+ return FileType::NUP;
+ }
+
+ LoadResult Load(Kernel::KProcess& process, Core::System& system) override;
+
+ ResultStatus VerifyIntegrity(std::function<bool(size_t, size_t)> progress_callback) override;
+
+ ResultStatus ReadRomFS(FileSys::VirtualFile& out_file) override;
+ bool IsRomFSUpdatable() const override;
+ ResultStatus ReadProgramId(u64& out_program_id) override;
+ ResultStatus ReadProgramIds(std::vector<u64>& out_program_ids) override;
+ ResultStatus ReadIcon(std::vector<u8>& buffer) override;
+ ResultStatus ReadTitle(std::string& title) override;
+ ResultStatus ReadControlData(FileSys::NACP& control) override;
+ ResultStatus ReadNSOModules(Modules& modules) override;
+
+private:
+ std::unique_ptr<FileSys::NupContainer> nup;
+ std::unique_ptr<AppLoader> secondary_loader;
+
+ std::unique_ptr<FileSys::NACP> nacp_file;
+ FileSys::VirtualFile icon_file;
+ u64 base_title_id{};
+ std::string display_version;
+ bool has_program{};
+};
+
+} // namespace Loader