From 31b901b93b992580d608fc2f4db9ddac1d755e43 Mon Sep 17 00:00:00 2001 From: a55uka <59313695+a55uka@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:47 +0000 Subject: loader: add NUP (Unencrypted (Archival) Package) container support --- src/core/CMakeLists.txt | 4 + src/core/core.cpp | 21 +- src/core/file_sys/nup_container.cpp | 656 ++++++++++++++++++++++++++++++++++++ src/core/file_sys/nup_container.h | 177 ++++++++++ src/core/loader/loader.cpp | 35 +- src/core/loader/loader.h | 3 + src/core/loader/nup.cpp | 280 +++++++++++++++ src/core/loader/nup.h | 65 ++++ 8 files changed, 1238 insertions(+), 3 deletions(-) create mode 100644 src/core/file_sys/nup_container.cpp create mode 100644 src/core/file_sys/nup_container.h create mode 100644 src/core/loader/nup.cpp create mode 100644 src/core/loader/nup.h (limited to 'src/core') 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 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 +#include +#include +#include +#include + +#include +#include + +#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 magic; + u16_le header_size; + u16_le header_version; + u64_le toc_offset; + u64_le toc_size; + std::array toc_sha256; + u64_le blob_offset; + u64_le file_size; + u32_le package_count; + u32_le flags; + std::array uuid; + std::array 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 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 sha256; + u64_le extent_offset; + u64_le extent_size; + u8 codec; + u8 codec_flags; + u16_le reserved2; + std::array 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& 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 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(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(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 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 Decompress() const { + if (extent_size > backing->GetSize() || extent_offset > backing->GetSize() - extent_size) { + return {}; + } + const auto compressed = backing->ReadBytes(static_cast(extent_size), + static_cast(extent_offset)); + if (compressed.size() != extent_size) { + return {}; + } + std::vector out(static_cast(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 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 magic{}; + file->ReadObject(&magic, 0); + return magic == HeaderMagic; +} + +NupContainer::NupContainer(VirtualFile file_) + : file(std::move(file_)), cache(std::make_shared()) { + 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(toc_size), + static_cast(toc_offset)); + if (toc.size() != toc_size) { + status = Loader::ResultStatus::ErrorNUPBadFormat; + return false; + } + std::array 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(header.package_count) != super.package_count) { + status = Loader::ResultStatus::ErrorNUPBadFormat; + return false; + } + + const u64 needed = TocHeaderSize + static_cast(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(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(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(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(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(package.meta_size), + static_cast(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(payload), header.size); + break; + case MetaDisplayVersion: + package.display_version = + std::string(reinterpret_cast(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(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(payload) + record_pos, + data_length)); + } else { + package.localized_icons.emplace_back( + lang, std::vector(payload + record_pos, + payload + record_pos + data_length)); + } + record_pos += data_length; + } + break; + } + default: + break; + } + } + return true; +} + +std::optional NupContainer::FindPackage(PackageKind kind, + std::optional 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 NupContainer::FindBasePackage() const { + return FindPackage(PackageKind::Base); +} + +std::optional NupContainer::FindBestUpdatePackage() const { + std::optional 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(std::vector{}, node.name); + } + if (node.codec == CodecRaw) { + return std::make_shared( + file, static_cast(node.extent_size), + static_cast(node.extent_offset), node.name); + } + return std::make_shared(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(dirs.size()))); + } else { + auto sub = std::make_shared(std::vector{}, + std::vector{}, + dirs[child].name); + BuildDirTree(child, *sub); + out.AddDirectory(sub); + } + } +} + +VirtualDir NupContainer::BuildPackageDir(u32 dir_index) { + auto out = std::make_shared(std::vector{}, + std::vector{}, 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> 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> 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 merged_files; + merged_files.reserve(file_map.size()); + for (const auto& [_, entry] : file_map) { + merged_files.push_back(entry); + } + std::vector merged_dirs; + merged_dirs.reserve(dir_map.size()); + for (const auto& [_, entry] : dir_map) { + merged_dirs.push_back(entry); + } + return std::make_shared(std::move(merged_files), std::move(merged_dirs), + base->GetName()); +} + +std::vector NupContainer::ReadExtentRaw(const FileNode& node) const { + return file->ReadBytes(static_cast(node.extent_size), + static_cast(node.extent_offset)); +} + +Loader::ResultStatus NupContainer::VerifyIntegrity( + std::function 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 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 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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>> lru; + std::unordered_map>>::iterator> + index; + std::size_t bytes{}; + + std::vector* 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& 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 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 npdm; + std::string title_name; + std::vector> localized_titles; + std::vector icon; + std::vector>> 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& GetPackages() const { + return packages; + } + + std::optional FindPackage(PackageKind kind, + std::optional title_id = std::nullopt) const; + std::optional FindBasePackage() const; + /// Highest version among Update packages, if any. + std::optional 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 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 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 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 dirs; + std::vector files; + std::vector children; + std::vector packages; + + std::vector exefs_dir_cache; + std::vector romfs_dir_cache; + + std::shared_ptr 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(file)) { return *xci_type; + } else if (const auto nup_type = IdentifyFileLoader(file)) { + return *nup_type; } else if (const auto nro_type = IdentifyFileLoader(file)) { return *nro_type; } else if (const auto nca_type = IdentifyFileLoader(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 RESULT_MESSAGES{ +constexpr std::array 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 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 GetFileLoader(Core::System& system, FileSys::V case FileType::KIP: return std::make_unique(std::move(file)); + // NUP (NX Package) container format. + case FileType::NUP: + return std::make_unique(std::move(file)); + // NX deconstructed ROM directory. case FileType::DeconstructedRomDirectory: return std::make_unique(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 +#include + +#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 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 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(); + nacp->raw.supported_languages = static_cast(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(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 program_index; + std::optional 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(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(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(icon, "icon_" + lang + ".dat"); + break; + } + } + if (icon_file == nullptr && !program.localized_icons.empty()) { + icon_file = std::make_shared( + 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(*this, system.GetContentProvider(), + system.GetFileSystemController())); + + is_loaded = true; + return result; +} + +ResultStatus AppLoader_NUP::VerifyIntegrity( + std::function 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& 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& 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 +#include +#include + +#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 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& out_program_ids) override; + ResultStatus ReadIcon(std::vector& buffer) override; + ResultStatus ReadTitle(std::string& title) override; + ResultStatus ReadControlData(FileSys::NACP& control) override; + ResultStatus ReadNSOModules(Modules& modules) override; + +private: + std::unique_ptr nup; + std::unique_ptr secondary_loader; + + std::unique_ptr nacp_file; + FileSys::VirtualFile icon_file; + u64 base_title_id{}; + std::string display_version; + bool has_program{}; +}; + +} // namespace Loader -- cgit v1.2.3