aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPavelBARABANOV <pavelbarabanov94@gmail.com>2026-10-26 17:23:18 +0100
committercrueter <crueter@eden-emu.dev>2026-10-26 17:23:18 +0100
commit58968bd1274d8ebb1f159ad31500dde8d74abea6 (patch)
tree199d26b56c5dbc3f1ee198a10bd79d443989e668
parent05f36d990ce5301b310871451201a72e4c52b4df (diff)
[core] Support block-compressed NSZ and XCZ (#4552)
- [x] I have read and followed the [Contribution Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/CONTRIBUTING.md#code-contributions). - [x] I have read and followed the [AI Policy](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/AI.md). - [x] I have read and followed the [Coding Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/Coding.md) to the best of my ability. ------------------- - Add NCZBLOCK (nsz -B / .xcz) reader: each read decodes only the covered blocks and re-encrypts CTR sections, no disk output. - Allow installing solid .nsz to NAND via DecompressNCZForInstall (lazy, incremental decode with frontier, O(n) instead of O(n^2)). - Accept BKTR updates with thousands of sections (MaxSectionCount = 0x10000). - Wire .nsz/.xcz into loader, registered cache, scanner, and NAND install (content_manager.h), and reject solid NCZ from the game list. - Bump the pinned zstd to 33618c8 in cpmfile.json, which includes the upstream AArch64 revert ([33618c89](https://github.com/facebook/zstd/commit/33618c89e511637f8a6ef955b3501766d71feb53)) for a ZSTD_decodeSequence branch optimization that could corrupt data on ARM64; without it, solid NCZ decoding on Android fails with "Data corruption detected". NCZ format and CTR counter semantics follow the reference nsz tool and its Rust port: - https://github.com/nicoboss/nsz (Decompressor.py, BlockDecompressorReader.py) - https://docs.rs/nsz-rs/0.3.0/src/nsz_rs/decompress.rs.html The last-block edge case (decompressed size is an exact multiple of block size) is handled as described in https://github.com/nicoboss/nsz/issues/210 Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4552 Reviewed-by: CamilleLaVey <camillelavey99@gmail.com> Reviewed-by: Shinmegumi <shinmegumi@eden-emu.dev>
-rw-r--r--cpmfile.json4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt2
-rw-r--r--src/android/app/src/main/jni/game_metadata.cpp4
-rw-r--r--src/common/zstd_compression.cpp9
-rw-r--r--src/common/zstd_compression.h14
-rw-r--r--src/core/CMakeLists.txt2
-rw-r--r--src/core/crypto/ncz_decompressor.cpp430
-rw-r--r--src/core/crypto/ncz_decompressor.h35
-rw-r--r--src/core/file_sys/registered_cache.cpp46
-rw-r--r--src/core/file_sys/submission_package.cpp12
-rw-r--r--src/core/loader/loader.cpp48
-rw-r--r--src/core/loader/loader.h2
-rw-r--r--src/core/loader/nsp.cpp11
-rw-r--r--src/frontend_common/content_manager.h6
-rw-r--r--src/qt_common/game_list/worker.cpp10
-rw-r--r--src/qt_common/qt_common.cpp3
-rw-r--r--src/yuzu/main_window.cpp11
18 files changed, 624 insertions, 27 deletions
diff --git a/cpmfile.json b/cpmfile.json
index 9cac812b7a..0032734712 100644
--- a/cpmfile.json
+++ b/cpmfile.json
@@ -351,13 +351,13 @@
},
"zstd": {
"find_args": "MODULE",
- "hash": "cc5ad4b119a9c2ea57f0b71eeff01113bb506e0d17000159c5409cb8236d22e38c52d5e9e97e7947a4bf1b2dfc44b6c503ab2d9aedbd59458435c6a2849cb029",
+ "hash": "2b006c3c110c38bd2f5896b42d36a38931c8eae261f30552f9a9fd04458d218d8f1a1425f117fd12af545ed77042e4b2d1a1a9b6286c4d0b9d74ef79f20a8f08",
"min_version": "1.5",
"options": [
"ZSTD_BUILD_SHARED OFF"
],
"repo": "facebook/zstd",
"source_subdir": "build/cmake",
- "version": "b8d6101fba"
+ "version": "33618c8"
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
index 6717894c93..f5f0cda0e9 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -100,7 +100,7 @@ class Game(
companion object {
val extensions: Set<String> = HashSet(
- listOf("xci", "nsp", "nca", "nro")
+ listOf("xci", "nsp", "nca", "nro", "nsz", "xcz")
)
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
index 411b764e36..bb2337a310 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
@@ -109,7 +109,7 @@ object GameHelper {
// File extensions considered as external content, buuut should
// be done better imo.
- private val externalContentExtensions = setOf("nsp", "xci")
+ private val externalContentExtensions = setOf("nsp", "xci", "nsz", "xcz")
private fun scanContentContainersRecursive(
files: Array<MinimalDocumentFile>,
diff --git a/src/android/app/src/main/jni/game_metadata.cpp b/src/android/app/src/main/jni/game_metadata.cpp
index 49d92bca75..9b6cd44e9f 100644
--- a/src/android/app/src/main/jni/game_metadata.cpp
+++ b/src/android/app/src/main/jni/game_metadata.cpp
@@ -76,7 +76,9 @@ jboolean Java_org_yuzu_yuzu_1emu_utils_GameMetadata_getIsValid(JNIEnv* env, jobj
auto const file_type = loader->GetFileType();
if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error)
return false;
- if ((file_type == Loader::FileType::NSP || file_type == Loader::FileType::XCI) && !Loader::IsBootableGameContainer(file, file_type))
+ if ((file_type == Loader::FileType::NSP || file_type == Loader::FileType::XCI ||
+ file_type == Loader::FileType::NSZ || file_type == Loader::FileType::XCZ) &&
+ !Loader::IsBootableGameContainer(file, file_type))
return false;
u64 program_id = 0;
return loader->ReadProgramId(program_id) == Loader::ResultStatus::Success;
diff --git a/src/common/zstd_compression.cpp b/src/common/zstd_compression.cpp
index cb6ec171b5..7665482ee6 100644
--- a/src/common/zstd_compression.cpp
+++ b/src/common/zstd_compression.cpp
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -46,4 +49,10 @@ std::vector<u8> DecompressDataZSTD(std::span<const u8> compressed) {
return decompressed;
}
+std::size_t DecompressDataZSTD(std::span<const u8> compressed, std::span<u8> output) {
+ const std::size_t result =
+ ZSTD_decompress(output.data(), output.size(), compressed.data(), compressed.size());
+ return ZSTD_isError(result) ? 0 : result;
+}
+
} // namespace Common::Compression
diff --git a/src/common/zstd_compression.h b/src/common/zstd_compression.h
index a5ab2d05bd..819d7d201a 100644
--- a/src/common/zstd_compression.h
+++ b/src/common/zstd_compression.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -42,4 +45,15 @@ namespace Common::Compression {
*/
[[nodiscard]] std::vector<u8> DecompressDataZSTD(std::span<const u8> compressed);
+/**
+ * Decompresses a single Zstandard frame into a caller-provided buffer of known size.
+ *
+ * @param compressed the compressed source memory region.
+ * @param output the destination buffer. Its size is the maximum number of bytes to write.
+ *
+ * @return the number of bytes written, or 0 if the frame is invalid or doesn't fit. An empty
+ * frame also returns 0.
+ */
+[[nodiscard]] std::size_t DecompressDataZSTD(std::span<const u8> compressed, std::span<u8> output);
+
} // namespace Common::Compression
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 31f9931d3a..1b578f67ba 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -33,6 +33,8 @@ add_library(core STATIC
crypto/encryption_layer.h
crypto/key_manager.cpp
crypto/key_manager.h
+ crypto/ncz_decompressor.cpp
+ crypto/ncz_decompressor.h
crypto/partition_data_manager.cpp
crypto/partition_data_manager.h
crypto/xts_encryption_layer.cpp
diff --git a/src/core/crypto/ncz_decompressor.cpp b/src/core/crypto/ncz_decompressor.cpp
new file mode 100644
index 0000000000..5f56e55375
--- /dev/null
+++ b/src/core/crypto/ncz_decompressor.cpp
@@ -0,0 +1,430 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <zstd.h>
+
+#include "common/common_types.h"
+#include "common/logging.h"
+#include "common/zstd_compression.h"
+#include "core/crypto/aes_util.h"
+#include "core/crypto/key_manager.h"
+#include "core/crypto/ncz_decompressor.h"
+#include "core/file_sys/vfs/vfs.h"
+
+namespace Core::Crypto {
+
+namespace {
+
+constexpr u64 HeaderSize = 0x4000;
+constexpr std::size_t SectionWire = 0x40;
+constexpr std::size_t BlockFixed = 0x18;
+constexpr std::size_t Chunk = 0x100000;
+// Upper bound on NCZ section entries (64 bytes each, so the table is at most 4 MiB). A base NCA
+// has a handful of sections, but nsz stores an update's patch (BKTR) data one entry per
+// subsection, which is thousands of them.
+constexpr u64 MaxSectionCount = 0x10000;
+constexpr std::array<u8, 8> SectionsMagic{'N', 'C', 'Z', 'S', 'E', 'C', 'T', 'N'};
+constexpr std::array<u8, 8> BlockMagic{'N', 'C', 'Z', 'B', 'L', 'O', 'C', 'K'};
+
+struct Section {
+ s64 offset, size;
+ u64 crypto_type;
+ Key128 key, counter;
+ bool Ctr() const { return crypto_type == 3 || crypto_type == 4; }
+};
+
+struct BlockHeader {
+ u32 exponent;
+ u64 decompressed_size;
+ std::vector<u32> sizes;
+};
+
+u64 Le(const u8* p, std::size_t n) {
+ u64 v = 0;
+ for (std::size_t i = n; i-- > 0;) v = (v << 8) | p[i];
+ return v;
+}
+
+// Mirrors nsz AESCTR.seek(): upper 8 bytes = stored counter, lower 8 = absolute NCA offset / 16.
+void SetIv(Key128& iv, const Key128& base, u64 off) {
+ iv = base;
+ u64 blk = off >> 4;
+ for (std::size_t i = 0; i < 8; ++i) { iv[15 - i] = static_cast<u8>(blk & 0xFF); blk >>= 8; }
+}
+
+// Parse header + section table + optional block header. Returns payload_start.
+std::optional<std::pair<std::vector<Section>, std::optional<BlockHeader>>> ParseHeader(
+ const FileSys::VirtualFile& f, u64& payload_start) {
+ const u64 fsize = f->GetSize();
+ if (fsize < HeaderSize + 16) return std::nullopt;
+
+ const auto head = f->ReadBytes(16, HeaderSize);
+ if (head.size() != 16 || !std::equal(head.begin(), head.begin() + 8, SectionsMagic.begin()))
+ return std::nullopt;
+ const u64 count = Le(head.data() + 8, 8);
+ if (count == 0 || count > MaxSectionCount || count * SectionWire > fsize - HeaderSize - 16)
+ return std::nullopt;
+
+ const auto table = f->ReadBytes(count * SectionWire, HeaderSize + 16);
+ if (table.size() != count * SectionWire) return std::nullopt;
+
+ std::vector<Section> sections;
+ sections.reserve(count + 1);
+ for (u64 i = 0; i < count; ++i) {
+ const u8* e = table.data() + i * SectionWire;
+ Section s{};
+ s.offset = static_cast<s64>(Le(e, 8));
+ s.size = static_cast<s64>(Le(e + 8, 8));
+ s.crypto_type = Le(e + 16, 8);
+ std::memcpy(s.key.data(), e + 32, 16);
+ std::memcpy(s.counter.data(), e + 48, 16);
+ sections.push_back(s);
+ }
+
+ const u64 block_start = HeaderSize + 16 + count * SectionWire;
+ const auto fixed = f->ReadBytes(BlockFixed, block_start);
+ std::optional<BlockHeader> bh;
+ if (fixed.size() == BlockFixed &&
+ std::equal(fixed.begin(), fixed.begin() + 8, BlockMagic.begin())) {
+ BlockHeader b{};
+ b.exponent = fixed[11];
+ const s32 n = static_cast<s32>(Le(fixed.data() + 12, 4));
+ if (n <= 0) return std::nullopt;
+ b.decompressed_size = Le(fixed.data() + 16, 8);
+ const auto sizes = f->ReadBytes(static_cast<u64>(n) * 4, block_start + BlockFixed);
+ if (sizes.size() != static_cast<u64>(n) * 4) return std::nullopt;
+ b.sizes.resize(n);
+ for (s32 i = 0; i < n; ++i) b.sizes[i] = static_cast<u32>(Le(sizes.data() + i * 4, 4));
+ payload_start = block_start + BlockFixed + static_cast<u64>(n) * 4;
+ bh = std::move(b);
+ } else {
+ payload_start = block_start;
+ }
+
+ if (!sections.empty() && sections[0].offset > static_cast<s64>(HeaderSize)) {
+ Section fake{};
+ fake.offset = static_cast<s64>(HeaderSize);
+ fake.size = sections[0].offset - static_cast<s64>(HeaderSize);
+ sections.insert(sections.begin(), fake);
+ }
+ return std::make_pair(std::move(sections), std::move(bh));
+}
+
+// Sequential reader over independently-compressed blocks, one block cached (like nsz_rs).
+class BlockReader {
+public:
+ BlockReader(FileSys::VirtualFile f, const BlockHeader& bh, u64 payload_start)
+ : source{std::move(f)}, block_size{1ULL << bh.exponent}, sizes{bh.sizes},
+ decompressed_size{bh.decompressed_size} {
+ if (bh.exponent < 14 || bh.exponent > 32 || sizes.empty())
+ throw std::runtime_error("bad NCZBLOCK header");
+ u64 off = payload_start;
+ offsets.reserve(sizes.size());
+ for (u32 s : sizes) { offsets.push_back(off); off += s; }
+ }
+
+ // Fills `out` from `position`; returns bytes written.
+ std::size_t ReadAt(u64 position, std::span<u8> out) {
+ std::size_t done = 0;
+ while (done < out.size() && position < decompressed_size) {
+ const u64 id = position / block_size;
+ if (id >= sizes.size()) break;
+ if (cache_id != id && !Load(id)) break;
+ const u64 in = position - id * block_size;
+ const std::size_t n = static_cast<std::size_t>(
+ std::min<u64>(out.size() - done, cache.size() - in));
+ std::memcpy(out.data() + done, cache.data() + in, n);
+ done += n;
+ position += n;
+ }
+ return done;
+ }
+
+private:
+ bool Load(u64 id) {
+ u64 want = block_size;
+ if (id + 1 == sizes.size()) {
+ const u64 rem = decompressed_size % block_size;
+ if (rem > 0) want = rem; // avoid the 0-size bug from the original nsz
+ }
+ try { cache.resize(static_cast<std::size_t>(want)); }
+ catch (const std::bad_alloc&) { return false; }
+ const u32 csize = sizes[id];
+ if (csize < want) {
+ const auto comp = source->ReadBytes(csize, offsets[id]);
+ if (comp.size() != csize ||
+ Common::Compression::DecompressDataZSTD(comp, cache) != want) return false;
+ } else if (source->Read(cache.data(), cache.size(), offsets[id]) != cache.size()) {
+ return false;
+ }
+ cache_id = id;
+ return true;
+ }
+
+ FileSys::VirtualFile source;
+ u64 block_size;
+ std::vector<u64> offsets;
+ std::vector<u32> sizes;
+ u64 decompressed_size;
+ u64 cache_id = ~0ULL;
+ std::vector<u8> cache;
+};
+
+class BlockNczFile final : public FileSys::VfsFile {
+public:
+ BlockNczFile(std::string name_, std::vector<u8> hdr_, std::vector<Section> sections_,
+ std::unique_ptr<BlockReader> body_)
+ : name{std::move(name_)}, hdr{std::move(hdr_)}, sections{std::move(sections_)},
+ body{std::move(body_)} {
+ nca_size = HeaderSize;
+ for (const auto& s : sections)
+ nca_size = std::max<u64>(nca_size, static_cast<u64>(s.offset + s.size));
+ ciphers.reserve(sections.size());
+ for (const auto& s : sections)
+ ciphers.push_back(s.Ctr() ? std::make_unique<AESCipher<Key128>>(s.key, Mode::CTR)
+ : nullptr);
+ }
+
+ std::string GetName() const override { return name; }
+ std::size_t GetSize() const override { return static_cast<std::size_t>(nca_size); }
+ bool Resize(std::size_t) override { return false; }
+ FileSys::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 len, std::size_t off) const override {
+ if (off >= nca_size) return 0;
+ len = static_cast<std::size_t>(std::min<u64>(len, nca_size - off));
+ const u64 end = off + len;
+ u64 pos = off;
+ if (pos < HeaderSize) {
+ const u64 n = std::min<u64>(end, HeaderSize) - pos;
+ std::memcpy(data, hdr.data() + pos, static_cast<std::size_t>(n));
+ pos += n;
+ }
+ std::scoped_lock lock{mutex};
+ for (std::size_t i = 0; i < sections.size() && pos < end; ++i) {
+ const auto& s = sections[i];
+ const u64 begin = std::max<u64>(static_cast<u64>(s.offset), HeaderSize);
+ const u64 stop = std::max<u64>(static_cast<u64>(s.offset + s.size), begin);
+ if (stop <= pos) continue;
+ if (pos < begin) {
+ const u64 g = std::min(end, begin);
+ std::memset(data + (pos - off), 0, static_cast<std::size_t>(g - pos));
+ pos = g;
+ if (pos >= end) break;
+ }
+ const u64 pe = std::min(end, stop);
+ const std::span<u8> piece(data + (pos - off), static_cast<std::size_t>(pe - pos));
+ if (body->ReadAt(pos - HeaderSize, piece) != piece.size())
+ return static_cast<std::size_t>(pos - off);
+ if (ciphers[i]) {
+ Key128 iv{};
+ SetIv(iv, s.counter, pos);
+ ciphers[i]->SetIV(iv);
+ ciphers[i]->Transcode(piece.data(), piece.size(), piece.data(), Op::Encrypt);
+ }
+ pos = pe;
+ }
+ return len;
+ }
+
+ std::size_t Write(const u8*, std::size_t, std::size_t) override { return 0; }
+ bool Rename(std::string_view) override { return false; }
+
+private:
+ const std::string name;
+ const std::vector<u8> hdr;
+ const std::vector<Section> sections;
+ const std::unique_ptr<BlockReader> body;
+ std::vector<std::unique_ptr<AESCipher<Key128>>> ciphers;
+ mutable std::mutex mutex;
+ u64 nca_size{};
+};
+
+// Solid NCZ: one zstd stream over the whole body. Decoded incrementally into memory, up to the
+// requested offset, so sequential install reads are O(n) instead of O(n^2).
+class SolidNczFile final : public FileSys::VfsFile {
+public:
+ SolidNczFile(std::string name_, FileSys::VirtualFile source_,
+ std::vector<Section> sections_, u64 payload_start_)
+ : name{std::move(name_)}, source{std::move(source_)}, sections{std::move(sections_)},
+ payload_start{payload_start_} {
+ const auto raw_header = source->ReadBytes(HeaderSize, 0);
+ data.assign(raw_header.begin(), raw_header.end());
+ frontier = HeaderSize;
+ }
+
+ ~SolidNczFile() override {
+ if (stream != nullptr) ZSTD_freeDStream(stream);
+ }
+
+ std::string GetName() const override { return name; }
+ std::size_t GetSize() const override {
+ u64 size = HeaderSize;
+ for (const auto& s : sections)
+ size = std::max<u64>(size, static_cast<u64>(s.offset + s.size));
+ return static_cast<std::size_t>(size);
+ }
+ bool Resize(std::size_t) override { return false; }
+ FileSys::VirtualDir GetContainingDirectory() const override { return nullptr; }
+ bool IsWritable() const override { return false; }
+ bool IsReadable() const override { return true; }
+
+ std::size_t Read(u8* out, std::size_t len, std::size_t off) const override {
+ std::scoped_lock lock{mutex};
+ const u64 nca_size = GetSize();
+ if (off >= nca_size) return 0;
+ len = static_cast<std::size_t>(std::min<u64>(len, nca_size - off));
+ const u64 needed = off + len;
+ if (needed > frontier && !DecodeTo(needed)) return 0;
+ std::memcpy(out, data.data() + off, len);
+ return len;
+ }
+
+ std::size_t Write(const u8*, std::size_t, std::size_t) override { return 0; }
+ bool Rename(std::string_view) override { return false; }
+
+private:
+ bool DecodeTo(u64 needed) const {
+ if (stream == nullptr) {
+ stream = ZSTD_createDStream();
+ if (stream == nullptr) return false;
+ ZSTD_DCtx_setParameter(stream, ZSTD_d_windowLogMax, 31);
+ read_pos = payload_start;
+ }
+
+ const u64 file_size = source->GetSize();
+ std::vector<u8> out_buf(Chunk);
+
+ std::size_t index = 0;
+ for (; index < sections.size(); ++index) {
+ const auto& s = sections[index];
+ const u64 begin = std::max<u64>(static_cast<u64>(s.offset), HeaderSize);
+ const u64 end = std::max<u64>(static_cast<u64>(s.offset) + static_cast<u64>(s.size),
+ begin);
+ if (end > frontier) break;
+ }
+
+ for (; index < sections.size() && frontier < needed; ++index) {
+ const auto& s = sections[index];
+ const u64 begin = std::max<u64>(static_cast<u64>(s.offset), HeaderSize);
+ const u64 end = std::max<u64>(static_cast<u64>(s.offset) + static_cast<u64>(s.size),
+ begin);
+ if (end <= frontier) continue;
+ u64 pos = std::max<u64>(begin, frontier);
+
+ std::optional<AESCipher<Key128>> cipher;
+ if (s.Ctr()) cipher.emplace(s.key, Mode::CTR);
+
+ while (pos < end && frontier < needed) {
+ const u64 want = std::min<u64>(end - pos, out_buf.size());
+ std::size_t produced = 0;
+ while (produced < want) {
+ if (in.pos >= in.size) {
+ const u64 remaining = file_size - read_pos;
+ const std::size_t chunk = static_cast<std::size_t>(
+ std::min<u64>(Chunk, remaining));
+ if (chunk == 0) { LOG_ERROR(Crypto, "Solid NCZ: truncated"); return false; }
+ const auto bytes = source->ReadBytes(chunk, read_pos);
+ if (bytes.empty()) { LOG_ERROR(Crypto, "Solid NCZ: truncated"); return false; }
+ read_pos += bytes.size();
+ in_buf.assign(bytes.begin(), bytes.end());
+ in.src = in_buf.data();
+ in.size = in_buf.size();
+ in.pos = 0;
+ }
+ ZSTD_outBuffer outb{out_buf.data() + produced, want - produced, 0};
+ const std::size_t res = ZSTD_decompressStream(stream, &outb, &in);
+ if (ZSTD_isError(res)) {
+ LOG_ERROR(Crypto, "Solid NCZ: zstd error");
+ return false;
+ }
+ produced += outb.pos;
+ }
+ if (cipher) {
+ Key128 iv{};
+ SetIv(iv, s.counter, pos);
+ cipher->SetIV(iv);
+ cipher->Transcode(out_buf.data(), produced, out_buf.data(), Op::Encrypt);
+ }
+ data.insert(data.end(), out_buf.begin(), out_buf.begin() + produced);
+ pos += produced;
+ frontier = pos;
+ }
+ }
+ return frontier >= needed;
+ }
+
+ const std::string name;
+ const FileSys::VirtualFile source;
+ const std::vector<Section> sections;
+ const u64 payload_start;
+ mutable std::mutex mutex;
+ mutable std::vector<u8> data;
+ mutable ZSTD_DStream* stream = nullptr;
+ mutable u64 read_pos = 0;
+ mutable u64 frontier = 0;
+ mutable std::vector<u8> in_buf;
+ mutable ZSTD_inBuffer in{nullptr, 0, 0};
+};
+
+// Shared opener: block path always; solid only when allow_solid is true.
+FileSys::VirtualFile OpenNcz(const FileSys::VirtualFile& f, std::string_view name,
+ bool allow_solid) {
+ if (!f) return nullptr;
+ u64 ps = 0;
+ auto parsed = ParseHeader(f, ps);
+ if (!parsed) return nullptr;
+ auto& [sections, block] = *parsed;
+
+ if (block) {
+ auto hdr = f->ReadBytes(HeaderSize, 0);
+ if (hdr.size() != HeaderSize) return nullptr;
+ std::unique_ptr<BlockReader> body;
+ try { body = std::make_unique<BlockReader>(f, *block, ps); }
+ catch (const std::exception& e) {
+ LOG_ERROR(Crypto, "Failed to open NCZBLOCK: {}", e.what());
+ return nullptr;
+ }
+ return std::make_shared<BlockNczFile>(std::string{name}, std::move(hdr),
+ std::move(sections), std::move(body));
+ }
+
+ if (allow_solid) {
+ return std::make_shared<SolidNczFile>(std::string{name}, f, std::move(sections), ps);
+ }
+
+ LOG_DEBUG(Crypto, "Solid NCZ {} skipped (only NCZBLOCK is supported for play)", name);
+ return nullptr;
+}
+
+} // Anonymous namespace
+
+bool IsBlockCompressedNCZ(const FileSys::VirtualFile& f) {
+ if (!f) return false;
+ u64 ps = 0;
+ const auto p = ParseHeader(f, ps);
+ return p && p->second.has_value();
+}
+
+FileSys::VirtualFile DecompressNCZ(const FileSys::VirtualFile& f, std::string_view name) {
+ return OpenNcz(f, name, /*allow_solid=*/false);
+}
+
+FileSys::VirtualFile DecompressNCZForInstall(const FileSys::VirtualFile& f,
+ std::string_view name) {
+ return OpenNcz(f, name, /*allow_solid=*/true);
+}
+
+} // namespace Core::Crypto \ No newline at end of file
diff --git a/src/core/crypto/ncz_decompressor.h b/src/core/crypto/ncz_decompressor.h
new file mode 100644
index 0000000000..9557f8c517
--- /dev/null
+++ b/src/core/crypto/ncz_decompressor.h
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <string_view>
+
+#include "core/file_sys/vfs/vfs_types.h"
+
+namespace Core::Crypto {
+
+/**
+ * Opens an NCZBLOCK-compressed NCA (nsz -B / .xcz block format) as a VirtualFile backed by
+ * on-demand block decoding. Nothing is written to disk; each read decodes only the blocks it
+ * covers and re-encrypts CTR sections. Solid NCZ is not supported.
+ *
+ * @param ncz_file The raw NCZ payload (e.g. "<id>.ncz" entry from an NSZ/XCZ PFS0).
+ * @param output_name Name to give the returned VirtualFile (e.g. "<id>.nca").
+ *
+ * @return a VirtualFile for the NCA, or nullptr if the NCZ is malformed or not NCZBLOCK.
+ */
+[[nodiscard]] FileSys::VirtualFile DecompressNCZ(const FileSys::VirtualFile& ncz_file,
+ std::string_view output_name);
+
+/**
+ * Returns true if the given file is an NCZ payload in block-compressed (NCZBLOCK) form,
+ * i.e. one that DecompressNCZ() can open. Solid NCZ returns false.
+ */
+[[nodiscard]] bool IsBlockCompressedNCZ(const FileSys::VirtualFile& ncz_file);
+
+// Install to NAND: same as DecompressNCZ, but solid NCZ is decoded on demand too.
+[[nodiscard]] FileSys::VirtualFile DecompressNCZForInstall(
+ const FileSys::VirtualFile& ncz_file, std::string_view output_name);
+
+} // namespace Core::Crypto \ No newline at end of file
diff --git a/src/core/file_sys/registered_cache.cpp b/src/core/file_sys/registered_cache.cpp
index 1f430434f7..26e73d260a 100644
--- a/src/core/file_sys/registered_cache.cpp
+++ b/src/core/file_sys/registered_cache.cpp
@@ -16,6 +16,7 @@
#include "common/random.h"
#include "common/string_util.h"
#include "core/crypto/key_manager.h"
+#include "core/crypto/ncz_decompressor.h"
#include "core/file_sys/card_image.h"
#include "core/file_sys/common_funcs.h"
#include "core/file_sys/content_archive.h"
@@ -158,6 +159,25 @@ static std::shared_ptr<NSP> OpenContainerAsNsp(const VirtualFile& file, Loader::
return nullptr;
}
+// Checks that every .ncz entry in the container is NCZBLOCK. Solid NCZ has no random access,
+// and the whole point of this build is to reject it at scan time so such files never appear
+// in the game list. Containers without any .ncz (plain NSP/XCI) pass through.
+static bool ContainerIsFullyBlockCompressed(const std::shared_ptr<NSP>& nsp) {
+ if (!nsp) {
+ return false;
+ }
+ for (const auto& entry : nsp->GetFiles()) {
+ const auto name = entry->GetName();
+ if (name.size() < 4 || name.substr(name.size() - 4) != ".ncz") {
+ continue;
+ }
+ if (!Core::Crypto::IsBlockCompressedNCZ(entry)) {
+ return false; // solid NCZ -> reject the whole container
+ }
+ }
+ return true; // no .ncz at all, or all block-compressed
+}
+
template <typename Callback>
bool ForEachContainerEntry(const std::shared_ptr<NSP>& nsp, bool only_content,
std::optional<u64> base_program_id, Callback&& on_entry) {
@@ -811,7 +831,14 @@ std::vector<ContentProviderEntry> RegisteredCache::ListEntriesFilter(
}
static std::shared_ptr<NCA> GetNCAFromNSPForID(const NSP& nsp, const NcaID& id) {
- auto file = nsp.GetFile(fmt::format("{}.nca", Common::HexToString(id, false)));
+ const auto id_string = Common::HexToString(id, false);
+ auto file = nsp.GetFile(fmt::format("{}.nca", id_string));
+ if (file == nullptr) {
+ if (auto ncz_file = nsp.GetFile(fmt::format("{}.ncz", id_string))) {
+ file = Core::Crypto::DecompressNCZForInstall(
+ ncz_file, fmt::format("{}.nca", id_string));
+ }
+ }
if (file == nullptr) {
return nullptr;
}
@@ -1273,6 +1300,12 @@ bool ManualContentProvider::AddEntriesFromContainer(VirtualFile file, bool only_
return false;
}
+ // Reject solid NCZ (.nsz without -B): only block-compressed NCZ is supported.
+ if (!ContainerIsFullyBlockCompressed(nsp)) {
+ LOG_DEBUG(Service_FS, "Skipping solid-compressed container: {}", file->GetName());
+ return false;
+ }
+
return ForEachContainerEntry(
nsp, only_content, base_program_id,
[this](TitleType title_type, ContentRecordType content_type, u64 title_id,
@@ -1415,8 +1448,9 @@ void ExternalContentProvider::ScanDirectory(const VirtualDir& dir) {
}
const auto extension = Common::ToLower(filename.substr(dot_pos + 1));
-
- if (extension == "nsp") {
+ // .xcz is an XCI container, but its updates only work through the NSP/PFS0 path
+ // (DecompressNCZ), not ProcessXCI. So route .xcz here on purpose.
+ if (extension == "nsp" || extension == "nsz" || extension == "xcz") {
ProcessNSP(file);
} else if (extension == "xci") {
ProcessXCI(file);
@@ -1434,6 +1468,12 @@ void ExternalContentProvider::ProcessNSP(const VirtualFile& file) {
return;
}
+ // Reject solid NCZ (.nsz without -B): Eden only supports block-compressed NCZ.
+ if (!ContainerIsFullyBlockCompressed(nsp)) {
+ LOG_DEBUG(Service_FS, "Skipping solid-compressed container: {}", file->GetName());
+ return;
+ }
+
LOG_DEBUG(Service_FS, "Processing NSP file: {}", file->GetName());
AddExternalEntriesFromContainer(nsp, entries, versions, multi_version_entries);
}
diff --git a/src/core/file_sys/submission_package.cpp b/src/core/file_sys/submission_package.cpp
index 0ea323492b..1a39d819f6 100644
--- a/src/core/file_sys/submission_package.cpp
+++ b/src/core/file_sys/submission_package.cpp
@@ -12,6 +12,7 @@
#include "common/hex_util.h"
#include "common/logging.h"
#include "core/crypto/key_manager.h"
+#include "core/crypto/ncz_decompressor.h"
#include "core/file_sys/common_funcs.h"
#include "core/file_sys/content_archive.h"
#include "core/file_sys/nca_metadata.h"
@@ -247,6 +248,17 @@ void NSP::ReadNCAs(const std::vector<VirtualFile>& files) {
auto next_file = pfs->GetFile(fmt::format("{}.nca", id_string));
if (next_file == nullptr) {
+ auto ncz_file = pfs->GetFile(fmt::format("{}.ncz", id_string));
+ if (ncz_file != nullptr) {
+ next_file = Core::Crypto::DecompressNCZ(ncz_file, fmt::format("{}.nca", id_string));
+ if (next_file == nullptr) {
+ LOG_DEBUG(Service_FS, "Skipping solid or unsupported NCZ content {}.ncz", id_string);
+ continue;
+ }
+ }
+ }
+
+ if (next_file == nullptr) {
if (rec.type != ContentRecordType::DeltaFragment) {
LOG_WARNING(Service_FS,
"NCA with ID {}.nca is listed in content metadata, but cannot "
diff --git a/src/core/loader/loader.cpp b/src/core/loader/loader.cpp
index 90e23ccc11..92e2e2f602 100644
--- a/src/core/loader/loader.cpp
+++ b/src/core/loader/loader.cpp
@@ -82,23 +82,27 @@ std::shared_ptr<FileSys::NSP> OpenContainerAsNsp(FileSys::VirtualFile file, File
return nullptr;
}
- if (type == FileType::NSP) {
+ if (type == FileType::NSP || type == FileType::NSZ) {
auto nsp = std::make_shared<FileSys::NSP>(file, program_id, program_index);
return nsp->GetStatus() == ResultStatus::Success ? nsp : nullptr;
}
- if (type == FileType::XCI) {
+ if (type == FileType::XCI || type == FileType::XCZ) {
FileSys::XCI xci{file, program_id, program_index};
- if (xci.GetStatus() != ResultStatus::Success) {
- return nullptr;
+ if (xci.GetStatus() == ResultStatus::Success) {
+ auto secure_nsp = xci.GetSecurePartitionNSP();
+ if (secure_nsp != nullptr && secure_nsp->GetStatus() == ResultStatus::Success) {
+ return secure_nsp;
+ }
}
- auto secure_nsp = xci.GetSecurePartitionNSP();
- if (secure_nsp == nullptr || secure_nsp->GetStatus() != ResultStatus::Success) {
- return nullptr;
+ // PFS0-shaped .xcz (NSZ-style container) does not parse as XCI; fall back to NSP.
+ if (type == FileType::XCZ) {
+ auto nsp = std::make_shared<FileSys::NSP>(file, program_id, program_index);
+ return nsp->GetStatus() == ResultStatus::Success ? nsp : nullptr;
}
- return secure_nsp;
+ return nullptr;
}
return nullptr;
@@ -144,7 +148,8 @@ FileType IdentifyFile(FileSys::VirtualFile file) {
}
bool IsContainerType(FileType type) {
- return type == FileType::NSP || type == FileType::XCI;
+ return type == FileType::NSP || type == FileType::NSZ || type == FileType::XCI ||
+ type == FileType::XCZ;
}
bool IsBootableGameContainer(FileSys::VirtualFile file, FileType type, u64 program_id,
@@ -180,8 +185,12 @@ FileType GuessFromFilename(const std::string& name) {
return FileType::NCA;
else if (extension == "xci")
return FileType::XCI;
+ else if (extension == "xcz")
+ return FileType::XCZ;
else if (extension == "nsp")
return FileType::NSP;
+ else if (extension == "nsz")
+ return FileType::NSZ;
else if (extension == "kip")
return FileType::KIP;
return FileType::Unknown;
@@ -197,10 +206,14 @@ std::string GetFileTypeString(FileType type) {
return "NCA";
case FileType::XCI:
return "XCI";
+ case FileType::XCZ:
+ return "XCZ";
case FileType::NAX:
return "NAX";
case FileType::NSP:
return "NSP";
+ case FileType::NSZ:
+ return "NSZ";
case FileType::KIP:
return "KIP";
case FileType::DeconstructedRomDirectory:
@@ -326,12 +339,29 @@ static std::unique_ptr<AppLoader> GetFileLoader(Core::System& system, FileSys::V
system.GetContentProvider(), program_id,
program_index);
+ // NCZ-compressed XCI container. XCI-shaped .xcz goes to the XCI loader, while
+ // PFS0-shaped (NSZ-style) .xcz containers fall back to the NSP loader.
+ case FileType::XCZ: {
+ FileSys::XCI probe{file, program_id, program_index};
+ if (probe.GetStatus() == ResultStatus::Success &&
+ probe.GetSecurePartitionNSP() != nullptr) {
+ return std::make_unique<AppLoader_XCI>(std::move(file),
+ system.GetFileSystemController(),
+ system.GetContentProvider(), program_id,
+ program_index);
+ }
+ return std::make_unique<AppLoader_NSP>(std::move(file), system.GetFileSystemController(),
+ system.GetContentProvider(), program_id,
+ program_index);
+ }
+
// NX NAX (NintendoAesXts) file format.
case FileType::NAX:
return std::make_unique<AppLoader_NAX>(std::move(file));
// NX NSP (Nintendo Submission Package) file format
case FileType::NSP:
+ case FileType::NSZ:
return std::make_unique<AppLoader_NSP>(std::move(file), system.GetFileSystemController(),
system.GetContentProvider(), program_id,
program_index);
diff --git a/src/core/loader/loader.h b/src/core/loader/loader.h
index 95ce638da0..d3c0230cb0 100644
--- a/src/core/loader/loader.h
+++ b/src/core/loader/loader.h
@@ -42,7 +42,9 @@ enum class FileType {
NRO,
NCA,
NSP,
+ NSZ,
XCI,
+ XCZ,
NAX,
KIP,
DeconstructedRomDirectory,
diff --git a/src/core/loader/nsp.cpp b/src/core/loader/nsp.cpp
index cfc908b4ef..8b2c79394d 100644
--- a/src/core/loader/nsp.cpp
+++ b/src/core/loader/nsp.cpp
@@ -56,6 +56,17 @@ AppLoader_NSP::AppLoader_NSP(FileSys::VirtualFile file_,
AppLoader_NSP::~AppLoader_NSP() = default;
FileType AppLoader_NSP::IdentifyType(const FileSys::VirtualFile& nsp_file) {
+ // .nsz is NSP-compatible; return NSZ so the game list shows the right type.
+ if (nsp_file->GetName().ends_with(".nsz")) {
+ return FileType::NSZ;
+ }
+
+ // PFS0-shaped .xcz containers are handled by this loader too; report XCZ so the game
+ // list does not show them as NSP. XCI-shaped .xcz is identified by AppLoader_XCI.
+ if (nsp_file->GetName().ends_with(".xcz")) {
+ return FileType::XCZ;
+ }
+
const FileSys::NSP nsp(nsp_file);
if (nsp.GetStatus() != ResultStatus::Success) {
diff --git a/src/frontend_common/content_manager.h b/src/frontend_common/content_manager.h
index 4f3112e3fb..6d79098efa 100644
--- a/src/frontend_common/content_manager.h
+++ b/src/frontend_common/content_manager.h
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
@@ -163,7 +163,9 @@ inline InstallResult InstallNSP(Core::System& system, FileSys::VfsFilesystem& vf
std::shared_ptr<FileSys::NSP> nsp;
FileSys::VirtualFile file = vfs.OpenFile(filename, FileSys::OpenMode::Read);
- if (boost::to_lower_copy(file->GetName()).ends_with("nsp")) {
+ const auto lower_name = boost::to_lower_copy(file->GetName());
+
+ if (lower_name.ends_with("nsp") || lower_name.ends_with("nsz")) {
nsp = std::make_shared<FileSys::NSP>(file);
if (nsp->IsExtractedType()) {
return InstallResult::Failure;
diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp
index 586a26e8b9..6b6f93447d 100644
--- a/src/qt_common/game_list/worker.cpp
+++ b/src/qt_common/game_list/worker.cpp
@@ -360,7 +360,8 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
}
if (target == ScanTarget::PopulateGameList &&
- (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP) &&
+ (file_type == Loader::FileType::XCI || file_type == Loader::FileType::XCZ ||
+ file_type == Loader::FileType::NSP || file_type == Loader::FileType::NSZ) &&
!Loader::IsBootableGameContainer(file, file_type)) {
return true;
}
@@ -375,7 +376,9 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
program_id, file);
} else if (Settings::values.ext_content_from_game_dirs.GetValue() &&
(file_type == Loader::FileType::XCI ||
- file_type == Loader::FileType::NSP)) {
+ file_type == Loader::FileType::XCZ ||
+ file_type == Loader::FileType::NSP ||
+ file_type == Loader::FileType::NSZ)) {
void(provider->AddEntriesFromContainer(file));
}
} else {
@@ -402,7 +405,8 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
};
if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 &&
- (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) {
+ (file_type == Loader::FileType::XCI || file_type == Loader::FileType::XCZ ||
+ file_type == Loader::FileType::NSP || file_type == Loader::FileType::NSZ)) {
for (const auto id : program_ids) {
// dravee suggested this, only viable way to
// not show sub-games in qlaunch for now.
diff --git a/src/qt_common/qt_common.cpp b/src/qt_common/qt_common.cpp
index b9b8caf6e8..794aeaabb1 100644
--- a/src/qt_common/qt_common.cpp
+++ b/src/qt_common/qt_common.cpp
@@ -57,7 +57,8 @@ std::unique_ptr<EmuThread> emu_thread = nullptr;
const QStringList supported_file_extensions = {QStringLiteral("nro"), QStringLiteral("nso"),
QStringLiteral("nca"), QStringLiteral("xci"),
- QStringLiteral("nsp"), QStringLiteral("kip")};
+ QStringLiteral("xcz"), QStringLiteral("nsp"),
+ QStringLiteral("nsz"), QStringLiteral("kip")};
Core::Frontend::WindowSystemType GetWindowSystemType() {
// Determine WSI type based on Qt platform.
diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp
index 6e21265f9e..ad76e5b2db 100644
--- a/src/yuzu/main_window.cpp
+++ b/src/yuzu/main_window.cpp
@@ -2797,9 +2797,9 @@ void MainWindow::IncrementInstallProgress() {
void MainWindow::OnMenuInstallToNAND() {
const QString file_filter =
- tr("Installable Switch File (*.nca *.nsp *.xci);;Nintendo Content Archive "
- "(*.nca);;Nintendo Submission Package (*.nsp);;NX Cartridge "
- "Image (*.xci)");
+ tr("Installable Switch File (*.nca *.nsp *.nsz *.xci *.xcz);;Nintendo Content Archive "
+ "(*.nca);;Nintendo Submission Package (*.nsp *.nsz);;NX Cartridge "
+ "Image (*.xci *.xcz)");
QStringList filenames = QFileDialog::getOpenFileNames(
this, tr("Install Files"), QString::fromStdString(UISettings::values.roms_path),
@@ -2856,7 +2856,10 @@ void MainWindow::OnMenuInstallToNAND() {
QFuture<ContentManager::InstallResult> future;
ContentManager::InstallResult result;
- if (file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) {
+ if (file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive) ||
+ file.endsWith(QStringLiteral("nsz"), Qt::CaseInsensitive) ||
+ file.endsWith(QStringLiteral("xci"), Qt::CaseInsensitive) ||
+ file.endsWith(QStringLiteral("xcz"), Qt::CaseInsensitive)) {
const auto progress_callback = [this](size_t size, size_t progress) {
emit UpdateInstallProgress();
if (install_progress->wasCanceled()) {