aboutsummaryrefslogtreecommitdiff
path: root/src/core/file_sys
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/file_sys')
-rw-r--r--src/core/file_sys/fsmitm_romfsbuild.cpp27
-rw-r--r--src/core/file_sys/nup_container.cpp676
-rw-r--r--src/core/file_sys/nup_container.h177
-rw-r--r--src/core/file_sys/nup_writer.cpp1459
-rw-r--r--src/core/file_sys/nup_writer.h60
-rw-r--r--src/core/file_sys/system_archive/system_archive.cpp19
-rw-r--r--src/core/file_sys/vfs/vfs_real.cpp37
-rw-r--r--src/core/file_sys/vfs/vfs_real.h8
8 files changed, 2432 insertions, 31 deletions
diff --git a/src/core/file_sys/fsmitm_romfsbuild.cpp b/src/core/file_sys/fsmitm_romfsbuild.cpp
index 8807bbd0f7..d34e24f177 100644
--- a/src/core/file_sys/fsmitm_romfsbuild.cpp
+++ b/src/core/file_sys/fsmitm_romfsbuild.cpp
@@ -55,15 +55,18 @@ static_assert(sizeof(RomFSFileEntry) == 0x20, "RomFSFileEntry has incorrect size
struct RomFSBuildFileContext;
+// The context's directories and files own the nodes; the links between them don't, or every
+// parent and child would own each other and no build would ever be freed (title 1's loose RomFS:
+// 206k files, ~50 MiB per build).
struct RomFSBuildDirectoryContext {
std::string path;
u32 cur_path_ofs = 0;
u32 path_len = 0;
u32 entry_offset = 0;
- std::shared_ptr<RomFSBuildDirectoryContext> parent;
- std::shared_ptr<RomFSBuildDirectoryContext> child;
- std::shared_ptr<RomFSBuildDirectoryContext> sibling;
- std::shared_ptr<RomFSBuildFileContext> file;
+ RomFSBuildDirectoryContext* parent = nullptr;
+ RomFSBuildDirectoryContext* child = nullptr;
+ RomFSBuildDirectoryContext* sibling = nullptr;
+ RomFSBuildFileContext* file = nullptr;
};
struct RomFSBuildFileContext {
@@ -73,8 +76,8 @@ struct RomFSBuildFileContext {
u32 entry_offset = 0;
u64 offset = 0;
u64 size = 0;
- std::shared_ptr<RomFSBuildDirectoryContext> parent;
- std::shared_ptr<RomFSBuildFileContext> sibling;
+ RomFSBuildDirectoryContext* parent = nullptr;
+ RomFSBuildFileContext* sibling = nullptr;
VirtualFile source;
};
@@ -168,7 +171,7 @@ bool RomFSBuildContext::AddDirectory(std::shared_ptr<RomFSBuildDirectoryContext>
num_dirs++;
dir_table_size +=
sizeof(RomFSDirectoryEntry) + Common::AlignUp(dir_ctx->path_len - dir_ctx->cur_path_ofs, 4);
- dir_ctx->parent = std::move(parent_dir_ctx);
+ dir_ctx->parent = parent_dir_ctx.get();
directories.emplace_back(std::move(dir_ctx));
return true;
@@ -180,7 +183,7 @@ bool RomFSBuildContext::AddFile(std::shared_ptr<RomFSBuildDirectoryContext> pare
num_files++;
file_table_size +=
sizeof(RomFSFileEntry) + Common::AlignUp(file_ctx->path_len - file_ctx->cur_path_ofs, 4);
- file_ctx->parent = std::move(parent_dir_ctx);
+ file_ctx->parent = parent_dir_ctx.get();
files.emplace_back(std::move(file_ctx));
return true;
@@ -245,11 +248,11 @@ std::vector<std::pair<u64, VirtualFile>> RomFSBuildContext::Build() {
Common::AlignUp(cur_file->path_len - cur_file->cur_path_ofs, 4));
prev_file = cur_file;
}
- // Assign deferred parent/sibling ownership.
+ // Assign deferred parent/sibling links.
for (auto it = files.rbegin(); it != files.rend(); ++it) {
auto& cur_file = *it;
cur_file->sibling = cur_file->parent->file;
- cur_file->parent->file = cur_file;
+ cur_file->parent->file = cur_file.get();
}
// Determine directory offsets.
@@ -260,11 +263,11 @@ std::vector<std::pair<u64, VirtualFile>> RomFSBuildContext::Build() {
static_cast<u32>(sizeof(RomFSDirectoryEntry) +
Common::AlignUp(cur_dir->path_len - cur_dir->cur_path_ofs, 4));
}
- // Assign deferred parent/sibling ownership.
+ // Assign deferred parent/sibling links.
for (auto it = directories.rbegin(); (*it) != root; ++it) {
auto& cur_dir = *it;
cur_dir->sibling = cur_dir->parent->child;
- cur_dir->parent->child = cur_dir;
+ cur_dir->parent->child = cur_dir.get();
}
// Create output map.
diff --git a/src/core/file_sys/nup_container.cpp b/src/core/file_sys/nup_container.cpp
new file mode 100644
index 0000000000..0e485b44ec
--- /dev/null
+++ b/src/core/file_sys/nup_container.cpp
@@ -0,0 +1,676 @@
+// 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 <cstddef>
+#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; // 0x00
+ u32_le root_dir; // 0x08
+ u32_le exefs_dir; // 0x0C
+ u32_le romfs_dir; // 0x10
+ u32_le version; // 0x14
+ u8 kind; // 0x18
+ u8 flags; // 0x19
+ u16_le reserved; // 0x1A
+ u32_le reserved2; // 0x1C
+ u64_le meta_offset; // 0x20
+ u32_le meta_size; // 0x28
+ std::array<u8, 20> reserved3; // 0x2C
+};
+static_assert(sizeof(PackageRecord) == PackageRecordSize);
+static_assert(offsetof(PackageRecord, title_id) == 0x00);
+static_assert(offsetof(PackageRecord, root_dir) == 0x08);
+static_assert(offsetof(PackageRecord, exefs_dir) == 0x0C);
+static_assert(offsetof(PackageRecord, romfs_dir) == 0x10);
+static_assert(offsetof(PackageRecord, version) == 0x14);
+static_assert(offsetof(PackageRecord, kind) == 0x18);
+static_assert(offsetof(PackageRecord, flags) == 0x19);
+static_assert(offsetof(PackageRecord, reserved) == 0x1A);
+static_assert(offsetof(PackageRecord, reserved2) == 0x1C);
+static_assert(offsetof(PackageRecord, meta_offset) == 0x20);
+static_assert(offsetof(PackageRecord, meta_size) == 0x28);
+
+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; // 0x00
+ u32_le name_length; // 0x04
+ u64_le size; // 0x08
+ u64_le extent_offset; // 0x10
+ u64_le extent_size; // 0x18
+ std::array<u8, 0x20> sha256; // 0x20
+ u8 codec; // 0x40
+ u8 codec_flags; // 0x41
+ u16_le reserved2; // 0x42
+ std::array<u8, 12> reserved3; // 0x44
+};
+static_assert(sizeof(FileRecord) == FileRecordSize);
+static_assert(offsetof(FileRecord, name_offset) == 0x00);
+static_assert(offsetof(FileRecord, name_length) == 0x04);
+static_assert(offsetof(FileRecord, size) == 0x08);
+static_assert(offsetof(FileRecord, extent_offset) == 0x10);
+static_assert(offsetof(FileRecord, extent_size) == 0x18);
+static_assert(offsetof(FileRecord, sha256) == 0x20);
+static_assert(offsetof(FileRecord, codec) == 0x40);
+static_assert(offsetof(FileRecord, codec_flags) == 0x41);
+static_assert(offsetof(FileRecord, reserved2) == 0x42);
+
+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/file_sys/nup_writer.cpp b/src/core/file_sys/nup_writer.cpp
new file mode 100644
index 0000000000..8c7728ed01
--- /dev/null
+++ b/src/core/file_sys/nup_writer.cpp
@@ -0,0 +1,1459 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/file_sys/nup_writer.h"
+
+#include <algorithm>
+#include <array>
+#include <cctype>
+#include <charconv>
+#include <cstring>
+#include <limits>
+#include <map>
+#include <memory>
+#include <optional>
+#include <span>
+#include <stdexcept>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <openssl/evp.h>
+#include <zstd.h>
+
+#include "common/fs/file.h"
+#include "common/fs/path_util.h"
+#include "core/file_sys/control_metadata.h"
+#include "core/file_sys/vfs/vfs_real.h"
+#include "core/loader/deconstructed_rom_directory.h"
+
+namespace FileSys::Nup {
+namespace {
+
+using Json = nlohmann::json;
+namespace fs = std::filesystem;
+
+constexpr std::array<u8, 4> Magic{'N', 'U', 'P', 0x01};
+constexpr std::array<u8, 4> TocMagic{'T', 'O', 'C', 0x01};
+constexpr u64 SuperblockSize = 0x80;
+constexpr u64 TocHeaderSize = 0x30;
+constexpr u64 PackageRecordSize = 0x40;
+constexpr u64 DirRecordSize = 0x18;
+constexpr u64 FileRecordSize = 0x50;
+constexpr u64 ExtentAlignment = 16;
+constexpr u32 NoDir = 0xFFFFFFFF;
+
+constexpr u8 KindBase = 0;
+constexpr u8 KindUpdate = 1;
+constexpr u8 KindAddon = 2;
+constexpr u8 FlagStandaloneUpdate = 0x01;
+constexpr u8 CodecRaw = 0;
+constexpr u8 CodecZstd = 1;
+
+constexpr u16 TlvNpdm = 0x0001;
+constexpr u16 TlvNacp = 0x0002;
+constexpr u16 TlvIcon = 0x0003;
+constexpr u16 TlvDisplayVersion = 0x0004;
+constexpr u16 TlvTitleName = 0x0005;
+constexpr u16 TlvPublisher = 0x0006;
+constexpr u16 TlvSourceDump = 0x0008;
+
+constexpr int JournalVersion = 1;
+constexpr u64 ProgressStep = 64ULL * 1024 * 1024;
+constexpr size_t MaxChunkFiles = 4096;
+constexpr size_t ReadBufferSize = 4 * 1024 * 1024;
+
+class PackError : public std::runtime_error {
+public:
+ PackError(PackResult result_, const std::string& message)
+ : std::runtime_error(message), result{result_} {}
+ PackResult result;
+};
+
+u64 AlignUp(u64 value, u64 alignment) {
+ return (value + alignment - 1) / alignment * alignment;
+}
+
+std::string ToUtf8(const fs::path& path) {
+ const auto u8 = path.generic_u8string();
+ return {u8.begin(), u8.end()};
+}
+
+fs::path FromUtf8(const std::string& value) {
+ return fs::path{std::u8string{value.begin(), value.end()}};
+}
+
+std::string Lower(std::string value) {
+ std::ranges::transform(value, value.begin(),
+ [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
+ return value;
+}
+
+fs::path WithSuffix(fs::path path, std::string_view suffix) {
+ path += suffix;
+ return path;
+}
+
+bool IsFile(const fs::path& path) {
+ std::error_code ec;
+ return fs::is_regular_file(path, ec);
+}
+
+fs::path FindDirectory(const fs::path& parent, std::string_view name) {
+ std::error_code ec;
+ if (parent.empty() || !fs::is_directory(parent, ec)) {
+ return {};
+ }
+ const auto wanted = Lower(std::string{name});
+ for (const auto& entry : fs::directory_iterator(parent, ec)) {
+ if (entry.is_directory(ec) && Lower(ToUtf8(entry.path().filename())) == wanted) {
+ return entry.path();
+ }
+ }
+ return {};
+}
+
+std::optional<u64> ParseDecimal(const std::string& name) {
+ u64 value{};
+ const auto* end = name.data() + name.size();
+ const auto [ptr, ec] = std::from_chars(name.data(), end, value);
+ if (name.empty() || ec != std::errc{} || ptr != end) {
+ return std::nullopt;
+ }
+ return value;
+}
+
+// The exefs folder a dump boots from: the newest updates/<version>, else base, else the root.
+// Each must hold main, main.npdm and a romfs folder next to it or under base.
+bool HasBootableExeFS(const fs::path& root) {
+ const auto bootable = [](const fs::path& exefs) {
+ return !exefs.empty() && IsFile(exefs / "main") && IsFile(exefs / "main.npdm");
+ };
+ fs::path exefs;
+ std::optional<u64> best_version;
+ if (const auto updates = FindDirectory(root, "updates"); !updates.empty()) {
+ std::error_code ec;
+ for (const auto& entry : fs::directory_iterator(updates, ec)) {
+ const auto version = ParseDecimal(ToUtf8(entry.path().filename()));
+ const auto candidate = FindDirectory(entry.path(), "exefs");
+ if (version && bootable(candidate) && (!best_version || *version > *best_version)) {
+ best_version = version;
+ exefs = candidate;
+ }
+ }
+ }
+ const auto base = FindDirectory(root, "base");
+ if (exefs.empty()) {
+ exefs = FindDirectory(base, "exefs");
+ }
+ if (exefs.empty()) {
+ exefs = FindDirectory(root, "exefs");
+ }
+ if (!bootable(exefs)) {
+ return false;
+ }
+ return !FindDirectory(exefs.parent_path(), "romfs").empty() ||
+ !FindDirectory(base, "romfs").empty();
+}
+
+std::optional<u64> ParseTitleId(const std::string& name) {
+ u64 value{};
+ const auto* end = name.data() + name.size();
+ const auto [ptr, ec] = std::from_chars(name.data(), end, value, 16);
+ if (name.size() != 16 || ec != std::errc{} || ptr != end) {
+ return std::nullopt;
+ }
+ return value;
+}
+
+std::string ToHex(std::span<const u8> data) {
+ static constexpr char digits[] = "0123456789abcdef";
+ std::string out;
+ out.reserve(data.size() * 2);
+ for (const u8 byte : data) {
+ out += digits[byte >> 4];
+ out += digits[byte & 0xF];
+ }
+ return out;
+}
+
+std::vector<u8> FromHex(const std::string& hex) {
+ if (hex.size() % 2 != 0) {
+ throw PackError(PackResult::IoError, "malformed journal");
+ }
+ std::vector<u8> out(hex.size() / 2);
+ for (size_t i = 0; i < out.size(); ++i) {
+ const auto [ptr, ec] = std::from_chars(hex.data() + i * 2, hex.data() + i * 2 + 2, out[i], 16);
+ if (ec != std::errc{} || ptr != hex.data() + i * 2 + 2) {
+ throw PackError(PackResult::IoError, "malformed journal");
+ }
+ }
+ return out;
+}
+
+std::vector<u8> ReadWholeFile(const fs::path& path) {
+ Common::FS::IOFile in{path, Common::FS::FileAccessMode::Read};
+ if (!in.IsOpen()) {
+ return {};
+ }
+ std::vector<u8> data(in.GetSize());
+ if (in.ReadSpan(std::span{data}) != data.size()) {
+ return {};
+ }
+ return data;
+}
+
+// Same as pack.py: the program ID at ACI0 + 0x10, whatever the NPDM header revision.
+std::optional<u64> NpdmProgramId(std::span<const u8> npdm) {
+ static constexpr std::array<u8, 4> aci0{'A', 'C', 'I', '0'};
+ const auto it = std::search(npdm.begin(), npdm.end(), aci0.begin(), aci0.end());
+ const auto offset = static_cast<size_t>(it - npdm.begin());
+ if (it == npdm.end() || offset + 0x18 > npdm.size()) {
+ return std::nullopt;
+ }
+ u64 id = 0;
+ for (size_t i = 0; i < 8; ++i) {
+ id |= static_cast<u64>(npdm[offset + 0x10 + i]) << (8 * i);
+ }
+ return id;
+}
+
+void PutLE(std::vector<u8>& buffer, size_t offset, u64 value, size_t bytes) {
+ for (size_t i = 0; i < bytes; ++i) {
+ buffer[offset + i] = static_cast<u8>(value >> (8 * i));
+ }
+}
+
+void PutBytes(std::vector<u8>& buffer, size_t offset, std::span<const u8> bytes) {
+ std::memcpy(buffer.data() + offset, bytes.data(), bytes.size());
+}
+
+using Digest = std::array<u8, 32>;
+
+class Sha256 {
+public:
+ Sha256() : ctx{EVP_MD_CTX_new()} {
+ if (!ctx) {
+ throw PackError(PackResult::IoError, "cannot initialise SHA-256");
+ }
+ Reset();
+ }
+ ~Sha256() {
+ EVP_MD_CTX_free(ctx);
+ }
+ Sha256(const Sha256&) = delete;
+ Sha256& operator=(const Sha256&) = delete;
+
+ void Reset() {
+ if (EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) != 1) {
+ throw PackError(PackResult::IoError, "cannot initialise SHA-256");
+ }
+ }
+
+ void Update(const void* data, size_t size) {
+ EVP_DigestUpdate(ctx, data, size);
+ }
+
+ Digest Final() {
+ Digest digest{};
+ unsigned int length{};
+ EVP_DigestFinal_ex(ctx, digest.data(), &length);
+ return digest;
+ }
+
+private:
+ EVP_MD_CTX* ctx;
+};
+
+class ZstdContext {
+public:
+ // Archives must be byte-identical across platforms, so compression is always
+ // single-threaded (zstd's multithreaded frames differ) and every frame parameter is set
+ // explicitly instead of relying on library defaults.
+ explicit ZstdContext(int level) : cctx{ZSTD_createCCtx()} {
+ if (!cctx || ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, level)) ||
+ ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 0)) ||
+ ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 1)) ||
+ ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 0)) ||
+ ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_dictIDFlag, 0))) {
+ throw PackError(PackResult::IoError, "cannot initialise zstd");
+ }
+ }
+ ~ZstdContext() {
+ ZSTD_freeCCtx(cctx);
+ }
+ ZstdContext(const ZstdContext&) = delete;
+ ZstdContext& operator=(const ZstdContext&) = delete;
+
+ // The pledged size makes zstd record the content size in the frame header, which the
+ // format requires.
+ void Begin(u64 size) {
+ if (ZSTD_isError(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only)) ||
+ ZSTD_isError(ZSTD_CCtx_setPledgedSrcSize(cctx, size))) {
+ throw PackError(PackResult::IoError, "cannot start zstd frame");
+ }
+ }
+
+ ZSTD_CCtx* get() const {
+ return cctx;
+ }
+
+private:
+ ZSTD_CCtx* cctx;
+};
+
+// One package and the metadata it is written with. The plan is everything that must survive a
+// low-storage run deleting its sources, so it is what the journal stores.
+struct PlanPackage {
+ u8 kind{};
+ u8 flags{};
+ u64 title_id{};
+ u32 version{};
+ std::vector<u8> meta;
+};
+
+struct PlanEntry {
+ u32 package{};
+ std::string path; // inside the package tree, e.g. "romfs/Data/file.bin"
+ std::string source; // relative to the dump root
+ bool directory{};
+};
+
+struct Plan {
+ std::vector<PlanPackage> packages;
+ std::vector<PlanEntry> entries;
+};
+
+struct Extent {
+ u64 size{};
+ Digest sha256{};
+ u64 offset{};
+ u64 stored_size{};
+ u8 codec{};
+};
+
+struct Layout {
+ struct Dir {
+ std::string name;
+ u32 first_child{};
+ u32 child_count{};
+ };
+ struct File {
+ std::string name;
+ size_t entry{};
+ };
+ struct Package {
+ u32 root_dir{};
+ u32 exefs_dir = NoDir;
+ u32 romfs_dir = NoDir;
+ };
+ std::vector<Dir> dirs;
+ std::vector<File> files;
+ std::vector<u64> children; // FileTag | file index, or dir index
+ std::vector<Package> packages;
+ std::string pool;
+ std::unordered_map<std::string, u32> pool_index;
+ u64 toc_size{};
+ u64 blob_offset{};
+
+ static constexpr u64 FileTag = 1ULL << 32;
+
+ u32 Intern(const std::string& name) {
+ const auto [it, inserted] = pool_index.try_emplace(name, static_cast<u32>(pool.size()));
+ if (inserted) {
+ pool += name;
+ }
+ return it->second;
+ }
+};
+
+// Children sorted by the bytes of their names: std::string compares as unsigned char.
+struct TreeDir {
+ struct Child {
+ std::unique_ptr<TreeDir> dir;
+ size_t file_entry{};
+ };
+ std::string name;
+ std::map<std::string, Child> children;
+
+ TreeDir* Descend(const std::string& component) {
+ auto& child = children[component];
+ if (!child.dir) {
+ child.dir = std::make_unique<TreeDir>();
+ child.dir->name = component;
+ }
+ return child.dir.get();
+ }
+};
+
+// Lays out nodes as pack.py does: directories in pre-order, each directory's child slots
+// contiguous, its files numbered in slot order before its subdirectories are visited.
+u32 AssignDir(TreeDir& dir, Layout& layout) {
+ const auto id = static_cast<u32>(layout.dirs.size());
+ layout.dirs.push_back({dir.name, static_cast<u32>(layout.children.size()),
+ static_cast<u32>(dir.children.size())});
+ const size_t slot_base = layout.children.size();
+ layout.children.resize(slot_base + dir.children.size());
+ size_t slot = slot_base;
+ for (auto& [name, child] : dir.children) {
+ if (!child.dir) {
+ layout.children[slot] = Layout::FileTag | layout.files.size();
+ layout.files.push_back({name, child.file_entry});
+ }
+ ++slot;
+ }
+ slot = slot_base;
+ for (auto& [name, child] : dir.children) {
+ if (child.dir) {
+ layout.children[slot] = AssignDir(*child.dir, layout);
+ }
+ ++slot;
+ }
+ return id;
+}
+
+Layout BuildLayout(const Plan& plan) {
+ std::vector<TreeDir> roots(plan.packages.size());
+ for (size_t i = 0; i < plan.entries.size(); ++i) {
+ const auto& entry = plan.entries[i];
+ TreeDir* dir = &roots.at(entry.package);
+ size_t start = 0;
+ while (true) {
+ const auto slash = entry.path.find('/', start);
+ const auto component = entry.path.substr(start, slash - start);
+ if (slash == std::string::npos) {
+ if (entry.directory) {
+ dir->Descend(component);
+ } else {
+ auto& child = dir->children[component];
+ if (child.dir) {
+ throw PackError(PackResult::InvalidSource,
+ "a file and a folder share the name " + entry.path);
+ }
+ child.file_entry = i;
+ }
+ break;
+ }
+ dir = dir->Descend(component);
+ start = slash + 1;
+ }
+ }
+
+ Layout layout;
+ for (auto& root : roots) {
+ Layout::Package package;
+ package.root_dir = AssignDir(root, layout);
+ const auto& root_dir = layout.dirs[package.root_dir];
+ for (u32 slot = root_dir.first_child; slot < root_dir.first_child + root_dir.child_count;
+ ++slot) {
+ const u64 child = layout.children[slot];
+ if (child & Layout::FileTag) {
+ continue;
+ }
+ const auto id = static_cast<u32>(child);
+ if (layout.dirs[id].name == "exefs") {
+ package.exefs_dir = id;
+ } else if (layout.dirs[id].name == "romfs") {
+ package.romfs_dir = id;
+ }
+ }
+ layout.packages.push_back(package);
+ }
+
+ if (layout.dirs.size() + layout.files.size() >= NoDir) {
+ throw PackError(PackResult::InvalidSource, "the dump has too many files");
+ }
+ for (const auto& dir : layout.dirs) {
+ layout.Intern(dir.name);
+ }
+ for (const auto& file : layout.files) {
+ layout.Intern(file.name);
+ }
+ if (layout.pool.size() > std::numeric_limits<u32>::max()) {
+ throw PackError(PackResult::InvalidSource, "the dump's file names are too long");
+ }
+
+ const u64 unpadded = TocHeaderSize + plan.packages.size() * PackageRecordSize +
+ layout.dirs.size() * DirRecordSize + layout.files.size() * FileRecordSize +
+ layout.children.size() * sizeof(u32) + layout.pool.size();
+ layout.toc_size = AlignUp(unpadded, ExtentAlignment);
+ layout.blob_offset = AlignUp(SuperblockSize + layout.toc_size, ExtentAlignment);
+ return layout;
+}
+
+std::vector<u8> SerializeTlvs(std::vector<std::pair<u16, std::vector<u8>>> records) {
+ std::ranges::stable_sort(records, {}, &std::pair<u16, std::vector<u8>>::first);
+ std::vector<u8> out;
+ for (const auto& [type, payload] : records) {
+ const size_t at = out.size();
+ out.resize(at + 8 + payload.size());
+ PutLE(out, at, type, 2);
+ PutLE(out, at + 4, payload.size(), 4);
+ PutBytes(out, at + 8, payload);
+ }
+ return out;
+}
+
+std::vector<u8> StringBytes(const std::string& value) {
+ return {value.begin(), value.end()};
+}
+
+// The dump's own loader supplies control data and the display version, the same way a loose
+// dump reports them when it boots (control.nacp beside it, else the RomFS's version tag).
+std::unique_ptr<Loader::AppLoader_DeconstructedRomDirectory> OpenLoader(
+ RealVfsFilesystem& vfs, const fs::path& package_dir) {
+ const auto exefs = FindDirectory(package_dir, "exefs");
+ if (exefs.empty() || !IsFile(exefs / "main")) {
+ return nullptr;
+ }
+ auto main = vfs.OpenFile(Common::FS::PathToUTF8String(exefs / "main"), OpenMode::Read);
+ if (!main) {
+ return nullptr;
+ }
+ return std::make_unique<Loader::AppLoader_DeconstructedRomDirectory>(std::move(main));
+}
+
+class Discovery {
+public:
+ Discovery(const fs::path& dump_root_, std::vector<fs::path> excluded_)
+ : dump_root{dump_root_}, excluded{std::move(excluded_)} {}
+
+ Plan Run() {
+ const auto base = FindDirectory(dump_root, "base");
+ const auto updates = FindDirectory(dump_root, "updates");
+ const auto dlc = FindDirectory(dump_root, "dlc");
+
+ std::optional<u64> base_title_id;
+ if (!base.empty() && HasContent(base)) {
+ base_title_id = AddPackage(KindBase, base, 0, {});
+ } else if (updates.empty() && HasContent(dump_root)) {
+ base_title_id = AddPackage(KindBase, dump_root, 0, {"base", "updates", "dlc"});
+ }
+
+ std::vector<std::pair<u64, fs::path>> versions;
+ 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) && HasContent(entry.path())) {
+ if (*version > std::numeric_limits<u32>::max()) {
+ throw PackError(PackResult::InvalidSource,
+ "update version out of range: " + ToUtf8(entry.path()));
+ }
+ versions.emplace_back(*version, entry.path());
+ }
+ }
+ }
+ std::ranges::sort(versions);
+ const u8 update_flags = base_title_id ? 0 : FlagStandaloneUpdate;
+ for (const auto& [version, path] : versions) {
+ AddPackage(KindUpdate, path, static_cast<u32>(version), {}, update_flags,
+ base_title_id.value_or(0));
+ }
+
+ if (!dlc.empty()) {
+ std::vector<std::pair<std::string, fs::path>> addons;
+ for (const auto& title : fs::directory_iterator(dlc, ec)) {
+ const auto title_name = ToUtf8(title.path().filename());
+ if (!ParseTitleId(title_name) || !title.is_directory(ec)) {
+ continue;
+ }
+ for (const auto& content : fs::directory_iterator(title.path(), ec)) {
+ if (content.is_directory(ec) && HasContent(content.path())) {
+ addons.emplace_back(title_name + "/" + ToUtf8(content.path().filename()),
+ content.path());
+ }
+ }
+ }
+ std::ranges::sort(addons);
+ for (const auto& [name, path] : addons) {
+ AddPackage(KindAddon, path, 0, {}, 0, *ParseTitleId(name.substr(0, 16)));
+ }
+ }
+
+ if (plan.packages.empty()) {
+ throw PackError(PackResult::InvalidSource, "no packages found in the dump");
+ }
+ AddApplicationMetadata();
+ for (size_t i = 0; i < plan.packages.size(); ++i) {
+ auto& tlvs = package_tlvs[i];
+ tlvs.emplace_back(TlvSourceDump, StringBytes(ToUtf8(dump_root.filename())));
+ plan.packages[i].meta = SerializeTlvs(std::move(tlvs));
+ }
+ return std::move(plan);
+ }
+
+private:
+ static bool HasContent(const fs::path& dir) {
+ return !FindDirectory(dir, "exefs").empty() || !FindDirectory(dir, "romfs").empty();
+ }
+
+ // Returns the package's title ID: the NPDM program ID, else the folder's or base's.
+ u64 AddPackage(u8 kind, const fs::path& dir, u32 version,
+ const std::vector<std::string>& skip, u8 flags = 0, u64 fallback_id = 0) {
+ const auto index = static_cast<u32>(plan.packages.size());
+ PlanPackage package{kind, flags, fallback_id, version, {}};
+ auto& tlvs = package_tlvs.emplace_back();
+
+ const auto exefs = FindDirectory(dir, "exefs");
+ const auto romfs = FindDirectory(dir, "romfs");
+ fs::path npdm_path = exefs.empty() ? fs::path{} : exefs / "main.npdm";
+ if (!IsFile(npdm_path)) {
+ npdm_path = dir / "main.npdm";
+ }
+ if (IsFile(npdm_path)) {
+ auto npdm = ReadWholeFile(npdm_path);
+ if (const auto id = NpdmProgramId(npdm); kind != KindAddon && id && *id != 0) {
+ package.title_id = *id;
+ }
+ tlvs.emplace_back(TlvNpdm, std::move(npdm));
+ }
+
+ if (kind != KindAddon) {
+ if (auto loader = OpenLoader(vfs, dir)) {
+ std::string display_version;
+ if (loader->ReadDisplayVersion(display_version) == Loader::ResultStatus::Success &&
+ !display_version.empty()) {
+ tlvs.emplace_back(TlvDisplayVersion, StringBytes(display_version));
+ }
+ loaders.emplace_back(index, std::move(loader));
+ }
+ }
+ plan.packages.push_back(std::move(package));
+
+ if (!exefs.empty()) {
+ CollectTree(index, exefs, "exefs");
+ }
+ if (!romfs.empty()) {
+ CollectTree(index, romfs, "romfs");
+ }
+ std::error_code ec;
+ for (const auto& entry : fs::directory_iterator(dir, ec)) {
+ const auto name = ToUtf8(entry.path().filename());
+ const auto lower = Lower(name);
+ if (entry.path() == exefs || entry.path() == romfs ||
+ std::ranges::find(skip, lower) != skip.end()) {
+ continue;
+ }
+ if (entry.is_directory(ec)) {
+ CollectTree(index, entry.path(), "data/" + name);
+ } else {
+ AddFile(index, entry, "data/" + name);
+ }
+ }
+ return plan.packages.back().title_id;
+ }
+
+ // Names, icon and control data belong to the base package, or the first one without a base.
+ // Our dumps rarely carry a control folder; when one does, the base's own wins, then updates.
+ void AddApplicationMetadata() {
+ const auto app = static_cast<u32>(std::distance(
+ plan.packages.begin(), std::ranges::find(plan.packages, KindBase, &PlanPackage::kind)));
+ const u32 target = app < plan.packages.size() ? app : 0;
+ std::ranges::stable_sort(loaders, [target](const auto& a, const auto& b) {
+ return (a.first == target) > (b.first == target);
+ });
+
+ auto& tlvs = package_tlvs[target];
+ bool have_control = false;
+ bool have_icon = false;
+ for (const auto& [index, loader] : loaders) {
+ NACP control;
+ if (!have_control &&
+ loader->ReadControlData(control) == Loader::ResultStatus::Success) {
+ have_control = true;
+ tlvs.emplace_back(TlvNacp, control.GetRawBytes());
+ if (const auto name = control.GetApplicationName(); !name.empty()) {
+ tlvs.emplace_back(TlvTitleName, StringBytes(name));
+ }
+ if (const auto publisher = control.GetDeveloperName(); !publisher.empty()) {
+ tlvs.emplace_back(TlvPublisher, StringBytes(publisher));
+ }
+ }
+ std::vector<u8> icon;
+ if (!have_icon && loader->ReadIcon(icon) == Loader::ResultStatus::Success &&
+ !icon.empty()) {
+ have_icon = true;
+ tlvs.emplace_back(TlvIcon, std::move(icon));
+ }
+ }
+ }
+
+ bool IsExcluded(const fs::path& path) const {
+ const auto normal = fs::absolute(path).lexically_normal();
+ return std::ranges::find(excluded, normal) != excluded.end();
+ }
+
+ void AddFile(u32 package, const fs::directory_entry& entry, const std::string& path) {
+ std::error_code ec;
+ if (entry.is_symlink(ec)) {
+ throw PackError(PackResult::InvalidSource,
+ "symlinks are not supported: " + ToUtf8(entry.path()));
+ }
+ if (!entry.is_regular_file(ec)) {
+ throw PackError(PackResult::InvalidSource,
+ "unsupported file type: " + ToUtf8(entry.path()));
+ }
+ // Dump-tool listings are excluded by the spec.
+ if (entry.path().filename() == ".listing" || IsExcluded(entry.path())) {
+ return;
+ }
+ plan.entries.push_back({package, path, Relative(entry.path()), false});
+ }
+
+ void CollectTree(u32 package, const fs::path& root, const std::string& prefix) {
+ plan.entries.push_back({package, prefix, Relative(root), true});
+ for (auto it = fs::recursive_directory_iterator(root);
+ it != fs::recursive_directory_iterator(); ++it) {
+ const auto& entry = *it;
+ const auto path = prefix + "/" + ToUtf8(fs::relative(entry.path(), root));
+ if (!entry.is_symlink() && entry.is_directory()) {
+ plan.entries.push_back({package, path, Relative(entry.path()), true});
+ } else {
+ AddFile(package, entry, path);
+ }
+ }
+ }
+
+ std::string Relative(const fs::path& path) const {
+ return ToUtf8(fs::relative(path, dump_root));
+ }
+
+ fs::path dump_root;
+ std::vector<fs::path> excluded;
+ RealVfsFilesystem vfs;
+ Plan plan;
+ std::vector<std::vector<std::pair<u16, std::vector<u8>>>> package_tlvs;
+ std::vector<std::pair<u32, std::unique_ptr<Loader::AppLoader_DeconstructedRomDirectory>>>
+ loaders;
+};
+
+Json PlanToJson(const Plan& plan) {
+ Json packages = Json::array();
+ for (const auto& package : plan.packages) {
+ packages.push_back(Json{{"kind", package.kind},
+ {"flags", package.flags},
+ {"title_id", package.title_id},
+ {"version", package.version},
+ {"meta", ToHex(package.meta)}});
+ }
+ Json entries = Json::array();
+ for (const auto& entry : plan.entries) {
+ entries.push_back(Json{{"package", entry.package},
+ {"path", entry.path},
+ {"source", entry.source},
+ {"directory", entry.directory}});
+ }
+ return Json{{"format", "nup-journal"},
+ {"version", JournalVersion},
+ {"packages", packages},
+ {"entries", entries}};
+}
+
+Plan PlanFromJson(const Json& json) {
+ if (json.at("format") != "nup-journal" || json.at("version") != JournalVersion) {
+ throw PackError(PackResult::IoError, "unsupported journal");
+ }
+ Plan plan;
+ for (const auto& package : json.at("packages")) {
+ plan.packages.push_back({package.at("kind").get<u8>(), package.at("flags").get<u8>(),
+ package.at("title_id").get<u64>(),
+ package.at("version").get<u32>(),
+ FromHex(package.at("meta").get<std::string>())});
+ }
+ for (const auto& entry : json.at("entries")) {
+ plan.entries.push_back({entry.at("package").get<u32>(), entry.at("path").get<std::string>(),
+ entry.at("source").get<std::string>(),
+ entry.at("directory").get<bool>()});
+ }
+ return plan;
+}
+
+void RemoveFile(const fs::path& path) {
+ std::error_code ec;
+ fs::remove(path, ec);
+}
+
+bool IsEmptyDirectory(const fs::path& path) {
+ std::error_code ec;
+ return fs::is_directory(path, ec) && fs::is_empty(path, ec) && !ec;
+}
+
+void RemoveEmptyTree(const fs::path& root) {
+ std::error_code ec;
+ std::vector<fs::path> directories;
+ for (auto it = fs::recursive_directory_iterator(root, ec);
+ !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) {
+ if (it->is_directory(ec)) {
+ directories.push_back(it->path());
+ }
+ }
+ for (auto it = directories.rbegin(); it != directories.rend(); ++it) {
+ if (IsEmptyDirectory(*it)) {
+ fs::remove(*it, ec);
+ }
+ }
+ if (IsEmptyDirectory(root)) {
+ fs::remove(root, ec);
+ }
+}
+
+void RemoveEmptyParents(fs::path path, const fs::path& dump_root) {
+ while (!path.empty() && path.has_relative_path()) {
+ std::error_code ec;
+ if (fs::equivalent(path, dump_root, ec) || ec || !IsEmptyDirectory(path)) {
+ break;
+ }
+ fs::remove(path, ec);
+ path = path.parent_path();
+ }
+}
+
+class Packer {
+public:
+ Packer(const fs::path& dump_root_, const fs::path& archive_, const PackOptions& options_,
+ const std::function<void(const PackProgress&)>& progress_,
+ const std::atomic_bool* cancel_)
+ : dump_root{dump_root_}, archive{archive_}, part{WithSuffix(archive_, ".part")},
+ journal{WithSuffix(archive_, ".journal")}, options{options_}, progress_cb{progress_},
+ cancel{cancel_} {}
+
+ PackResult Run() {
+ std::error_code ec;
+ if (fs::exists(archive, ec)) {
+ throw PackError(PackResult::IoError, "archive already exists: " + ToUtf8(archive));
+ }
+ // A journal without a complete plan line was cut short before anything was deleted.
+ const bool resume =
+ options.delete_sources && HasResumableJournal(archive) && LoadJournal();
+ if (resume) {
+ OpenForResume();
+ } else {
+ if (!IsLooseDump(dump_root)) {
+ throw PackError(PackResult::InvalidSource,
+ "expected exefs/main, exefs/main.npdm and a romfs folder");
+ }
+ std::vector<fs::path> excluded;
+ for (const auto& path : {archive, part, journal}) {
+ excluded.push_back(fs::absolute(path).lexically_normal());
+ }
+ plan = Discovery{dump_root, std::move(excluded)}.Run();
+ layout = BuildLayout(plan);
+ OpenNew();
+ }
+ extents.resize(layout.files.size());
+ done.resize(layout.files.size());
+ for (const auto& [index, extent] : journaled) {
+ extents[index] = extent;
+ done[index] = true;
+ Remember(extent);
+ }
+ CountTotals();
+ Report();
+
+ for (size_t i = 0; i < layout.files.size(); ++i) {
+ if (done[i]) {
+ continue;
+ }
+ PackFile(i);
+ if (options.delete_sources &&
+ (chunk_bytes >= options.chunk_bytes || chunk.size() >= MaxChunkFiles)) {
+ Checkpoint();
+ }
+ }
+ if (options.delete_sources) {
+ Checkpoint();
+ }
+
+ Finish();
+ out.Close();
+ fs::rename(part, archive, ec);
+ if (ec) {
+ throw PackError(PackResult::IoError, "cannot rename archive: " + ec.message());
+ }
+
+ if (options.delete_sources) {
+ RemoveFile(journal);
+ RemoveSourceFolders();
+ }
+ return PackResult::Success;
+ }
+
+ void Abort() {
+ out.Close();
+ if (!options.delete_sources) {
+ RemoveFile(part);
+ }
+ }
+
+private:
+ void OpenNew() {
+ RemoveFile(journal);
+ out.Open(part, Common::FS::FileAccessMode::Write);
+ if (!out.IsOpen()) {
+ throw PackError(PackResult::IoError, "cannot create " + ToUtf8(part));
+ }
+ WriteZeros(layout.blob_offset);
+ cursor = layout.blob_offset;
+ if (options.delete_sources) {
+ AppendJournal(PlanToJson(plan).dump() + '\n');
+ }
+ }
+
+ bool LoadJournal() {
+ const auto contents =
+ Common::FS::ReadStringFromFile(journal, Common::FS::FileType::BinaryFile);
+ size_t start = 0;
+ bool have_plan = false;
+ while (start < contents.size()) {
+ const auto line_end = contents.find('\n', start);
+ if (line_end == std::string::npos) {
+ break;
+ }
+ const auto line = std::string_view{contents}.substr(start, line_end - start);
+ start = line_end + 1;
+ Json json;
+ try {
+ json = Json::parse(line);
+ } catch (const Json::exception&) {
+ // Only the tail can be torn: the journal is synced before sources are deleted.
+ break;
+ }
+ if (!have_plan) {
+ plan = PlanFromJson(json);
+ layout = BuildLayout(plan);
+ resume_end = layout.blob_offset;
+ have_plan = true;
+ continue;
+ }
+ const auto index = json.at("file").get<size_t>();
+ const auto sha = FromHex(json.at("sha256").get<std::string>());
+ if (index >= layout.files.size() || sha.size() != 32) {
+ throw PackError(PackResult::IoError, "malformed journal");
+ }
+ Extent extent{json.at("size").get<u64>(), {}, json.at("offset").get<u64>(),
+ json.at("stored_size").get<u64>(), json.at("codec").get<u8>()};
+ std::ranges::copy(sha, extent.sha256.begin());
+ resume_end = std::max(resume_end, extent.offset + extent.stored_size);
+ journaled.emplace_back(index, extent);
+ }
+ return have_plan;
+ }
+
+ void OpenForResume() {
+ const u64 end = resume_end;
+ out.Open(part, Common::FS::FileAccessMode::ReadWrite);
+ if (!out.IsOpen()) {
+ throw PackError(PackResult::IoError, "cannot open " + ToUtf8(part));
+ }
+ if (out.GetSize() < end) {
+ throw PackError(PackResult::IoError, "partial archive is shorter than its journal; "
+ "delete " +
+ ToUtf8(part) + " and " + ToUtf8(journal) +
+ " to start over");
+ }
+ if (!out.SetSize(end) || !out.Seek(static_cast<s64>(end))) {
+ throw PackError(PackResult::IoError, "cannot truncate " + ToUtf8(part));
+ }
+ cursor = end;
+
+ // Journaled before an interruption, but the delete never happened.
+ for (const auto& [index, extent] : journaled) {
+ RemoveFile(SourceOf(index));
+ }
+ }
+
+ fs::path SourceOf(size_t file_index) const {
+ return dump_root / FromUtf8(plan.entries[layout.files[file_index].entry].source);
+ }
+
+ void CountTotals() {
+ for (size_t i = 0; i < layout.files.size(); ++i) {
+ ++progress.files_total;
+ if (done[i]) {
+ progress.bytes_total += extents[i].size;
+ progress.bytes_done += extents[i].size;
+ ++progress.files_done;
+ } else {
+ std::error_code ec;
+ const auto size = fs::file_size(SourceOf(i), ec);
+ if (ec) {
+ throw PackError(PackResult::InvalidSource,
+ "missing source file: " + ToUtf8(SourceOf(i)));
+ }
+ progress.bytes_total += size;
+ }
+ }
+ }
+
+ void Remember(const Extent& extent) {
+ if (extent.size == 0) {
+ return;
+ }
+ sizes_seen.insert(extent.size);
+ by_hash.try_emplace(extent.sha256, extent);
+ }
+
+ bool Cancelled() const {
+ return cancel && cancel->load();
+ }
+
+ void WriteZeros(u64 count) {
+ static constexpr std::array<u8, 4096> zeros{};
+ while (count > 0) {
+ const auto n = static_cast<size_t>(std::min<u64>(count, zeros.size()));
+ Write(zeros.data(), n);
+ count -= n;
+ }
+ }
+
+ void Write(const u8* data, size_t size) {
+ if (out.WriteSpan(std::span<const u8>{data, size}) != size) {
+ throw PackError(PackResult::IoError, "write failed, the disk may be full");
+ }
+ }
+
+ void AddProgress(u64 bytes) {
+ progress.bytes_done += bytes;
+ if (progress.bytes_done - last_report >= ProgressStep) {
+ Report();
+ }
+ }
+
+ // Reads the whole source through `sink`, checking that it still has the expected size. A file
+ // read more than once (hashed for dedup, or compressed and then stored raw) counts once.
+ template <typename Sink>
+ void ReadSource(Common::FS::IOFile& in, u64 size, Sink&& sink) {
+ if (!in.Seek(0)) {
+ throw PackError(PackResult::IoError, "cannot seek source");
+ }
+ buffer.resize(ReadBufferSize);
+ u64 total = 0;
+ while (true) {
+ if (Cancelled()) {
+ throw PackError(PackResult::Cancelled, "cancelled");
+ }
+ const auto read = in.ReadSpan(std::span{buffer});
+ if (read == 0) {
+ break;
+ }
+ total += read;
+ if (total > size) {
+ break;
+ }
+ if (total > file_counted) {
+ AddProgress(total - file_counted);
+ file_counted = total;
+ }
+ if (!sink(buffer.data(), read)) {
+ return;
+ }
+ }
+ if (total != size) {
+ throw PackError(PackResult::IoError, "a source file changed while packing");
+ }
+ }
+
+ Digest HashSource(Common::FS::IOFile& in, u64 size) {
+ hash.Reset();
+ ReadSource(in, size, [this](const u8* data, size_t n) {
+ hash.Update(data, n);
+ return true;
+ });
+ return hash.Final();
+ }
+
+ ZstdContext& Compressor() {
+ if (!compressor) {
+ compressor = std::make_unique<ZstdContext>(options.zstd_level);
+ }
+ return *compressor;
+ }
+
+ // Streams one zstd frame into the archive. Gives up (returns false) as soon as the output is
+ // no smaller than the input.
+ bool WriteCompressed(Common::FS::IOFile& in, u64 size, u64& stored) {
+ auto& zstd = Compressor();
+ zstd.Begin(size);
+ out_buffer.resize(ZSTD_CStreamOutSize());
+ stored = 0;
+ bool fits = true;
+ const auto drain = [&](ZSTD_inBuffer& input, ZSTD_EndDirective mode) {
+ while (true) {
+ ZSTD_outBuffer output{out_buffer.data(), out_buffer.size(), 0};
+ const size_t remaining = ZSTD_compressStream2(zstd.get(), &output, &input, mode);
+ if (ZSTD_isError(remaining)) {
+ throw PackError(PackResult::IoError,
+ std::string{"zstd: "} + ZSTD_getErrorName(remaining));
+ }
+ stored += output.pos;
+ if (stored >= size) {
+ return false;
+ }
+ Write(out_buffer.data(), output.pos);
+ const bool finished = mode == ZSTD_e_end ? remaining == 0
+ : input.pos == input.size;
+ if (finished) {
+ return true;
+ }
+ }
+ };
+ hash.Reset();
+ ReadSource(in, size, [&](const u8* data, size_t n) {
+ hash.Update(data, n);
+ ZSTD_inBuffer input{data, n, 0};
+ fits = drain(input, ZSTD_e_continue);
+ return fits;
+ });
+ if (!fits) {
+ return false;
+ }
+ ZSTD_inBuffer empty{nullptr, 0, 0};
+ return drain(empty, ZSTD_e_end);
+ }
+
+ void PackFile(size_t index) {
+ const auto source = SourceOf(index);
+ Common::FS::IOFile in{source, Common::FS::FileAccessMode::Read};
+ if (!in.IsOpen()) {
+ throw PackError(PackResult::IoError, "cannot read " + ToUtf8(source));
+ }
+ Extent extent;
+ extent.size = in.GetSize();
+ file_counted = 0;
+
+ std::optional<Digest> known_hash;
+ if (extent.size == 0) {
+ known_hash = HashSource(in, 0);
+ } else if (sizes_seen.contains(extent.size)) {
+ // Only a file whose size matches an earlier extent can be a duplicate; hash it before
+ // writing anything so the shared extent costs no output.
+ known_hash = HashSource(in, extent.size);
+ if (const auto it = by_hash.find(*known_hash);
+ it != by_hash.end() && it->second.size == extent.size) {
+ extent = it->second;
+ Complete(index, extent);
+ return;
+ }
+ }
+
+ if (extent.size == 0) {
+ extent.sha256 = *known_hash;
+ Complete(index, extent);
+ return;
+ }
+
+ const u64 start = AlignUp(cursor, ExtentAlignment);
+ Seek(cursor);
+ WriteZeros(start - cursor);
+ extent.offset = start;
+
+ bool compressed = false;
+ if (extent.size <= options.raw_threshold && options.zstd_level > 0) {
+ u64 stored = 0;
+ compressed = WriteCompressed(in, extent.size, stored);
+ if (compressed) {
+ extent.codec = CodecZstd;
+ extent.stored_size = stored;
+ extent.sha256 = hash.Final();
+ }
+ }
+ if (!compressed) {
+ Seek(start);
+ hash.Reset();
+ ReadSource(in, extent.size, [this](const u8* data, size_t n) {
+ hash.Update(data, n);
+ Write(data, n);
+ return true;
+ });
+ extent.codec = CodecRaw;
+ extent.stored_size = extent.size;
+ extent.sha256 = hash.Final();
+ }
+ cursor = start + extent.stored_size;
+ Remember(extent);
+ Complete(index, extent);
+ }
+
+ void Seek(u64 offset) {
+ if (!out.Seek(static_cast<s64>(offset))) {
+ throw PackError(PackResult::IoError, "cannot seek " + ToUtf8(part));
+ }
+ }
+
+ void Complete(size_t index, const Extent& extent) {
+ extents[index] = extent;
+ done[index] = true;
+ if (options.delete_sources) {
+ chunk.push_back(index);
+ chunk_bytes += extent.size;
+ }
+ ++progress.files_done;
+ Report();
+ }
+
+ void AppendJournal(const std::string& lines) {
+ Common::FS::IOFile log{journal, Common::FS::FileAccessMode::Append};
+ if (!log.IsOpen() || log.WriteString(lines) != lines.size() || !log.Commit()) {
+ throw PackError(PackResult::IoError, "cannot write " + ToUtf8(journal));
+ }
+ }
+
+ // Archive data first, then the journal, then the deletes, so an interruption loses nothing.
+ void Checkpoint() {
+ if (chunk.empty()) {
+ return;
+ }
+ if (!out.Commit()) {
+ throw PackError(PackResult::IoError, "cannot sync " + ToUtf8(part));
+ }
+ std::string lines;
+ for (const auto index : chunk) {
+ const auto& extent = extents[index];
+ lines += Json{{"file", index},
+ {"size", extent.size},
+ {"sha256", ToHex(extent.sha256)},
+ {"offset", extent.offset},
+ {"stored_size", extent.stored_size},
+ {"codec", extent.codec}}
+ .dump();
+ lines += '\n';
+ }
+ AppendJournal(lines);
+ for (const auto index : chunk) {
+ RemoveFile(SourceOf(index));
+ }
+ chunk.clear();
+ chunk_bytes = 0;
+ }
+
+ void Finish() {
+ // Meta blocks follow the last extent, as pack.py writes them.
+ Seek(cursor);
+ std::vector<std::pair<u64, u32>> meta;
+ for (const auto& package : plan.packages) {
+ if (package.meta.size() > std::numeric_limits<u32>::max()) {
+ throw PackError(PackResult::IoError, "package metadata is too large");
+ }
+ meta.emplace_back(package.meta.empty() ? 0 : cursor,
+ static_cast<u32>(package.meta.size()));
+ Write(package.meta.data(), package.meta.size());
+ cursor += package.meta.size();
+ }
+ const u64 file_size = cursor;
+
+ const auto toc = BuildToc(meta);
+ std::array<u8, 32> toc_hash{};
+ unsigned int hash_length{};
+ EVP_Digest(toc.data(), toc.size(), toc_hash.data(), &hash_length, EVP_sha256(), nullptr);
+
+ // UUID: SHA-256 over title_id (u64), version (u32) and kind (u8) of each package.
+ Sha256 uuid_hash;
+ for (const auto& package : plan.packages) {
+ std::vector<u8> identity(13);
+ PutLE(identity, 0, package.title_id, 8);
+ PutLE(identity, 8, package.version, 4);
+ identity[12] = package.kind;
+ uuid_hash.Update(identity.data(), identity.size());
+ }
+ const auto uuid = uuid_hash.Final();
+
+ std::vector<u8> super(SuperblockSize);
+ PutBytes(super, 0x00, Magic);
+ PutLE(super, 0x04, SuperblockSize, 2);
+ PutLE(super, 0x06, 1, 2);
+ PutLE(super, 0x08, SuperblockSize, 8);
+ PutLE(super, 0x10, toc.size(), 8);
+ PutBytes(super, 0x18, toc_hash);
+ PutLE(super, 0x38, layout.blob_offset, 8);
+ PutLE(super, 0x40, file_size, 8);
+ PutLE(super, 0x48, plan.packages.size(), 4);
+ PutBytes(super, 0x50, std::span{uuid}.first(16));
+
+ Seek(0);
+ Write(super.data(), super.size());
+ Write(toc.data(), toc.size());
+ if (!out.SetSize(file_size) || !out.Commit()) {
+ throw PackError(PackResult::IoError, "cannot finish " + ToUtf8(part));
+ }
+ }
+
+ // Every field is written little-endian at its documented offset. The package record is
+ // 0x40 bytes with 20 reserved bytes at 0x2C, as NupContainer reads it.
+ std::vector<u8> BuildToc(const std::vector<std::pair<u64, u32>>& meta) const {
+ std::vector<u8> toc(layout.toc_size);
+ const u64 dir_count = layout.dirs.size();
+ PutBytes(toc, 0x00, TocMagic);
+ PutLE(toc, 0x04, plan.packages.size(), 4);
+ PutLE(toc, 0x08, dir_count, 8);
+ PutLE(toc, 0x10, layout.files.size(), 8);
+
+ size_t pos = TocHeaderSize;
+ for (size_t i = 0; i < plan.packages.size(); ++i) {
+ const auto& package = plan.packages[i];
+ const auto& record = layout.packages[i];
+ PutLE(toc, pos + 0x00, package.title_id, 8);
+ PutLE(toc, pos + 0x08, record.root_dir, 4);
+ PutLE(toc, pos + 0x0C, record.exefs_dir, 4);
+ PutLE(toc, pos + 0x10, record.romfs_dir, 4);
+ PutLE(toc, pos + 0x14, package.version, 4);
+ toc[pos + 0x18] = package.kind;
+ toc[pos + 0x19] = package.flags;
+ PutLE(toc, pos + 0x20, meta[i].first, 8);
+ PutLE(toc, pos + 0x28, meta[i].second, 4);
+ pos += PackageRecordSize;
+ }
+ for (const auto& dir : layout.dirs) {
+ PutLE(toc, pos + 0x00, layout.pool_index.at(dir.name), 4);
+ PutLE(toc, pos + 0x04, dir.name.size(), 4);
+ PutLE(toc, pos + 0x08, dir.first_child, 4);
+ PutLE(toc, pos + 0x0C, dir.child_count, 4);
+ pos += DirRecordSize;
+ }
+ for (size_t i = 0; i < layout.files.size(); ++i) {
+ const auto& file = layout.files[i];
+ const auto& extent = extents[i];
+ PutLE(toc, pos + 0x00, layout.pool_index.at(file.name), 4);
+ PutLE(toc, pos + 0x04, file.name.size(), 4);
+ PutLE(toc, pos + 0x08, extent.size, 8);
+ // Empty files have no extent: offset, size and codec are all zero.
+ if (extent.size != 0) {
+ PutLE(toc, pos + 0x10, extent.offset, 8);
+ PutLE(toc, pos + 0x18, extent.stored_size, 8);
+ toc[pos + 0x40] = extent.codec;
+ }
+ PutBytes(toc, pos + 0x20, extent.sha256);
+ pos += FileRecordSize;
+ }
+ for (const u64 child : layout.children) {
+ const u64 id = (child & Layout::FileTag) ? dir_count + static_cast<u32>(child) : child;
+ PutLE(toc, pos, id, 4);
+ pos += sizeof(u32);
+ }
+ PutLE(toc, 0x18, pos, 8);
+ PutLE(toc, 0x20, layout.pool.size(), 8);
+ std::memcpy(toc.data() + pos, layout.pool.data(), layout.pool.size());
+ return toc;
+ }
+
+ void RemoveSourceFolders() {
+ for (const auto& entry : plan.entries) {
+ const auto depth = std::ranges::count(entry.path, '/');
+ const bool top = entry.directory &&
+ (depth == 0 || (depth == 1 && entry.path.starts_with("data/")));
+ if (top) {
+ const auto source = dump_root / FromUtf8(entry.source);
+ RemoveEmptyTree(source);
+ RemoveEmptyParents(source.parent_path(), dump_root);
+ }
+ }
+ }
+
+ void Report() {
+ last_report = progress.bytes_done;
+ if (progress_cb) {
+ progress_cb(progress);
+ }
+ }
+
+ fs::path dump_root;
+ fs::path archive;
+ fs::path part;
+ fs::path journal;
+ PackOptions options;
+ std::function<void(const PackProgress&)> progress_cb;
+ const std::atomic_bool* cancel;
+
+ Plan plan;
+ Layout layout;
+ std::vector<Extent> extents;
+ std::vector<bool> done;
+ std::vector<std::pair<size_t, Extent>> journaled;
+
+ struct DigestHash {
+ size_t operator()(const Digest& digest) const {
+ size_t value;
+ std::memcpy(&value, digest.data(), sizeof(value));
+ return value;
+ }
+ };
+ std::unordered_map<Digest, Extent, DigestHash> by_hash;
+ std::unordered_set<u64> sizes_seen;
+
+ Common::FS::IOFile out;
+ u64 cursor = 0;
+ u64 resume_end = 0;
+ std::vector<size_t> chunk;
+ u64 chunk_bytes = 0;
+ std::vector<u8> buffer;
+ std::vector<u8> out_buffer;
+ Sha256 hash;
+ std::unique_ptr<ZstdContext> compressor;
+ PackProgress progress{};
+ u64 last_report = 0;
+ u64 file_counted = 0;
+};
+
+} // namespace
+
+bool IsLooseDump(const fs::path& dir) {
+ return HasBootableExeFS(dir);
+}
+
+std::optional<fs::path> DumpRootFromMain(const fs::path& main) {
+ // The Qt game list stores the exefs folder rather than exefs/main.
+ std::error_code ec;
+ const auto exefs = fs::is_directory(main, ec) ? main : main.parent_path();
+ if (Lower(ToUtf8(exefs.filename())) != "exefs") {
+ return std::nullopt;
+ }
+ auto root = exefs.parent_path();
+ const auto name = Lower(ToUtf8(root.filename()));
+ if (name == "base") {
+ root = root.parent_path();
+ } else if (ParseDecimal(name) && Lower(ToUtf8(root.parent_path().filename())) == "updates") {
+ root = root.parent_path().parent_path();
+ }
+ if (!IsLooseDump(root)) {
+ return std::nullopt;
+ }
+ return root;
+}
+
+fs::path DefaultArchivePath(const fs::path& dump_root) {
+ auto root = dump_root;
+ if (!root.has_filename()) {
+ root = root.parent_path();
+ }
+ return root.parent_path() / (root.filename().u8string() + u8".nup");
+}
+
+bool HasResumableJournal(const fs::path& archive) {
+ std::error_code ec;
+ return fs::exists(WithSuffix(archive, ".part"), ec) &&
+ fs::exists(WithSuffix(archive, ".journal"), ec);
+}
+
+PackResult PackDump(const fs::path& dump_root, const fs::path& archive, const PackOptions& options,
+ const std::function<void(const PackProgress&)>& progress,
+ const std::atomic_bool* cancel, std::string* error_out) {
+ Packer packer{dump_root, archive, options, progress, cancel};
+ try {
+ return packer.Run();
+ } catch (const PackError& e) {
+ packer.Abort();
+ if (error_out) {
+ *error_out = e.what();
+ }
+ return e.result;
+ } catch (const std::exception& e) {
+ packer.Abort();
+ if (error_out) {
+ *error_out = e.what();
+ }
+ return PackResult::IoError;
+ }
+}
+
+} // namespace FileSys::Nup
diff --git a/src/core/file_sys/nup_writer.h b/src/core/file_sys/nup_writer.h
new file mode 100644
index 0000000000..231387b156
--- /dev/null
+++ b/src/core/file_sys/nup_writer.h
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <atomic>
+#include <filesystem>
+#include <functional>
+#include <optional>
+#include <string>
+
+#include "common/common_types.h"
+
+// Packs a loose dump into a NUP container. Every package is kept: base/, each
+// updates/<version>/ and each dlc/<titleid>/<n>/.
+namespace FileSys::Nup {
+
+struct PackProgress {
+ u64 bytes_done{};
+ u64 bytes_total{};
+ u64 files_done{};
+ u64 files_total{};
+};
+
+enum class PackResult { Success, Cancelled, InvalidSource, IoError };
+
+// NupContainer keeps a zstd file in memory only while it fits its 512 MiB decompression cache;
+// a larger one is decompressed again in full on every read, which makes big compressed RomFS
+// files unusable. Files above this size are therefore stored raw, with headroom below the cap so
+// that one file does not evict everything else from the cache.
+constexpr u64 DefaultRawThreshold = 256ULL * 1024 * 1024;
+
+struct PackOptions {
+ // Low-storage mode: delete each source file once its extent is synced and journaled.
+ bool delete_sources = false;
+ u64 chunk_bytes = 256ULL * 1024 * 1024;
+ int zstd_level = 3;
+ // Files larger than this are always stored raw.
+ u64 raw_threshold = DefaultRawThreshold;
+};
+
+// A loose dump holds exefs/main and main.npdm, directly or under base/ or updates/<version>/,
+// and a romfs folder. Folder names are matched case-insensitively.
+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);
+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
+// cancelled or failed low-storage run keeps its .part and .journal, and calling this again with
+// the same arguments resumes it.
+PackResult PackDump(const std::filesystem::path& dump_root, const std::filesystem::path& archive,
+ const PackOptions& options = {},
+ const std::function<void(const PackProgress&)>& progress = {},
+ const std::atomic_bool* cancel = nullptr, std::string* error_out = nullptr);
+
+bool HasResumableJournal(const std::filesystem::path& archive);
+
+} // namespace FileSys::Nup
diff --git a/src/core/file_sys/system_archive/system_archive.cpp b/src/core/file_sys/system_archive/system_archive.cpp
index 1743d01b12..c3931b6f62 100644
--- a/src/core/file_sys/system_archive/system_archive.cpp
+++ b/src/core/file_sys/system_archive/system_archive.cpp
@@ -4,6 +4,8 @@
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+#include <cstdlib>
+
#include "common/logging.h"
#include "core/file_sys/romfs.h"
#include "core/file_sys/system_archive/mii_model.h"
@@ -12,6 +14,7 @@
#include "core/file_sys/system_archive/system_archive.h"
#include "core/file_sys/system_archive/system_version.h"
#include "core/file_sys/system_archive/time_zone_binary.h"
+#include "core/file_sys/vfs/vfs_vector.h"
namespace FileSys::SystemArchive {
@@ -76,6 +79,22 @@ constexpr inline SystemArchiveDescriptor GetSystemArchive(u64 title_id) {
VirtualFile SynthesizeSystemArchive(const u64 title_id) {
auto const desc = GetSystemArchive(title_id);
+ if (desc.name == nullptr) {
+ // Not a system archive Eden knows (fmt throws on a null string, ending the process).
+ // Switch 2 system data (ids 04...) is not installed with Switch 1 firmware, and titles
+ // abort when it cannot be mounted (Nintendo Switch Sports Resort, 0400000000000849):
+ // serve an empty archive. EDEN_NX2_EMPTY_SYSTEM_DATA=0 fails the request instead.
+ static const bool empty_nx2 = [] {
+ const char* setting = std::getenv("EDEN_NX2_EMPTY_SYSTEM_DATA");
+ return setting == nullptr || setting[0] != '0';
+ }();
+ if (empty_nx2 && (title_id >> 56) == 0x04) {
+ LOG_WARNING(Service_FS, "Empty archive for Switch 2 system data {:#016x}", title_id);
+ return CreateRomFS(std::make_shared<VectorVfsDirectory>());
+ }
+ LOG_WARNING(Service_FS, "No system archive {:#016x} to synthesize", title_id);
+ return nullptr;
+ }
LOG_INFO(Service_FS, "Synthesizing system archive '{}' ({:#016x}).", desc.name, title_id);
if (desc.supplier != nullptr) {
if (auto const dir = desc.supplier(); dir != nullptr) {
diff --git a/src/core/file_sys/vfs/vfs_real.cpp b/src/core/file_sys/vfs/vfs_real.cpp
index 5b5b54017c..7cc5315a70 100644
--- a/src/core/file_sys/vfs/vfs_real.cpp
+++ b/src/core/file_sys/vfs/vfs_real.cpp
@@ -114,7 +114,6 @@ VfsEntryType RealVfsFilesystem::GetEntryType(std::string_view path_) const {
}
VirtualFile RealVfsFilesystem::OpenFileFromEntry(std::string_view path_, std::optional<u64> size,
- std::optional<std::string> parent_path,
OpenMode perms) {
const auto path = FS::SanitizePath(path_, FS::DirectorySeparator::PlatformDefault);
std::scoped_lock lk{list_lock};
@@ -132,14 +131,14 @@ VirtualFile RealVfsFilesystem::OpenFileFromEntry(std::string_view path_, std::op
auto reference = std::make_unique<FileReference>();
this->InsertReferenceIntoListLocked(*reference);
- auto file = std::make_shared<RealVfsFile>(*this, std::move(reference), path, perms, size, std::move(parent_path));
+ auto file = std::make_shared<RealVfsFile>(*this, std::move(reference), path, perms, size);
cache[path] = file;
return file;
}
VirtualFile RealVfsFilesystem::OpenFile(std::string_view path_, OpenMode perms) {
- return OpenFileFromEntry(path_, {}, {}, perms);
+ return OpenFileFromEntry(path_, {}, perms);
}
VirtualFile RealVfsFilesystem::CreateFile(std::string_view path_, OpenMode perms) {
@@ -257,7 +256,8 @@ std::unique_lock<std::mutex> RealVfsFilesystem::RefreshReference(const std::stri
return lk;
}
-void RealVfsFilesystem::DropReference(std::unique_ptr<FileReference>&& reference) {
+void RealVfsFilesystem::DropReference(std::unique_ptr<FileReference>&& reference,
+ const std::string& path) {
if (in_dtor)
return;
@@ -266,6 +266,11 @@ void RealVfsFilesystem::DropReference(std::unique_ptr<FileReference>&& reference
// Remove from list.
this->RemoveReferenceFromListLocked(*reference);
+ // Forget the path, unless it was opened again since this file's last reference went away.
+ if (const auto it = cache.find(path); it != cache.end() && it->second.expired()) {
+ cache.erase(it);
+ }
+
// Close the file.
if (reference->file) {
reference->file.reset();
@@ -309,14 +314,11 @@ void RealVfsFilesystem::RemoveReferenceFromListLocked(FileReference& reference)
}
RealVfsFile::RealVfsFile(RealVfsFilesystem& base_, std::unique_ptr<FileReference> reference_,
- const std::string& path_, OpenMode perms_, std::optional<u64> size_,
- std::optional<std::string> parent_path_)
- : base(base_), reference(std::move(reference_)), path(path_),
- parent_path(parent_path_ ? std::move(*parent_path_) : FS::GetParentPath(path_)),
- path_components(FS::SplitPathComponentsCopy(path_)), size(size_), perms(perms_) {}
+ const std::string& path_, OpenMode perms_, std::optional<u64> size_)
+ : base(base_), reference(std::move(reference_)), path(path_), size(size_), perms(perms_) {}
RealVfsFile::~RealVfsFile() {
- base.DropReference(std::move(reference));
+ base.DropReference(std::move(reference), path);
}
std::string RealVfsFile::GetName() const {
@@ -325,7 +327,14 @@ std::string RealVfsFile::GetName() const {
return FS::Android::GetFilename(path);
}
#endif
- return path_components.empty() ? "" : std::string(path_components.back());
+ // The last path component.
+ const auto end = path.find_last_not_of("\\/");
+ if (end == std::string::npos) {
+ return "";
+ }
+ const auto separator = path.find_last_of("\\/", end);
+ const auto begin = separator == std::string::npos ? 0 : separator + 1;
+ return path.substr(begin, end + 1 - begin);
}
std::size_t RealVfsFile::GetSize() const {
@@ -343,7 +352,7 @@ bool RealVfsFile::Resize(std::size_t new_size) {
}
VirtualDir RealVfsFile::GetContainingDirectory() const {
- return base.OpenDirectory(parent_path, perms);
+ return base.OpenDirectory(FS::GetParentPath(path), perms);
}
bool RealVfsFile::IsWritable() const {
@@ -372,7 +381,7 @@ std::size_t RealVfsFile::Write(const u8* data, std::size_t length, std::size_t o
}
bool RealVfsFile::Rename(std::string_view name) {
- return base.MoveFile(path, parent_path + '/' + std::string(name)) != nullptr;
+ return base.MoveFile(path, FS::GetParentPath(path) + '/' + std::string(name)) != nullptr;
}
// TODO(DarkLordZach): MSVC would not let me combine the following two functions using 'if
@@ -390,7 +399,7 @@ std::vector<VirtualFile> RealVfsDirectory::IterateEntries<RealVfsFile, VfsFile>(
&out](const std::filesystem::directory_entry& entry) {
const auto full_path_string = FS::PathToUTF8String(entry.path());
- out.emplace_back(base.OpenFileFromEntry(full_path_string, entry.file_size(), path, perms));
+ out.emplace_back(base.OpenFileFromEntry(full_path_string, entry.file_size(), perms));
return true;
};
diff --git a/src/core/file_sys/vfs/vfs_real.h b/src/core/file_sys/vfs/vfs_real.h
index a97f7d32ea..2df125b7eb 100644
--- a/src/core/file_sys/vfs/vfs_real.h
+++ b/src/core/file_sys/vfs/vfs_real.h
@@ -62,12 +62,11 @@ private:
friend class RealVfsFile;
std::unique_lock<std::mutex> RefreshReference(const std::string& path, OpenMode perms,
FileReference& reference);
- void DropReference(std::unique_ptr<FileReference>&& reference);
+ void DropReference(std::unique_ptr<FileReference>&& reference, const std::string& path);
private:
friend class RealVfsDirectory;
VirtualFile OpenFileFromEntry(std::string_view path, std::optional<u64> size,
- std::optional<std::string> parent_path,
OpenMode perms = OpenMode::Read);
private:
@@ -84,7 +83,7 @@ class RealVfsFile : public VfsFile {
public:
RealVfsFile(RealVfsFilesystem& base, std::unique_ptr<FileReference> reference,
const std::string& path, OpenMode perms = OpenMode::Read,
- std::optional<u64> size = {}, std::optional<std::string> parent_path = {});
+ std::optional<u64> size = {});
~RealVfsFile() override;
std::string GetName() const override;
@@ -101,9 +100,8 @@ private:
RealVfsFilesystem& base;
std::unique_ptr<FileReference> reference;
+ // Only the path: a loose RomFS keeps one of these per file (title 1: 206k).
std::string path;
- std::string parent_path;
- std::vector<std::string> path_components;
std::optional<u64> size;
OpenMode perms;
};