aboutsummaryrefslogtreecommitdiff
path: root/src/tests
diff options
context:
space:
mode:
authorrdenno <rhysdenno@gmail.com>2026-11-02 09:26:01 -0500
committerrdenno <rhysdenno@gmail.com>2026-11-02 09:26:01 -0500
commit2786bdf580e41bf0ba6e2a6452db73c4f5b97ed2 (patch)
tree2c59cf5d66b869af52f338f3dea6e679e590f7bb /src/tests
parent7ac108e2571d7cb39585c58994524131dd9d8c22 (diff)
parentc0cb3325e22756edb65c87d33ef298349ade0ad3 (diff)
Merge branch 'nx2-experimental' into fix/hw-shader-lowering
nx2-experimental lowers partial-mask TXD as well (Splatoon Raiders). Keep its RZ second destination and this branch's odd register for a lone third channel; take its wider F2I mode check with the .H1 source. Assisted-by: Claude Opus 5.5 (conflict resolution)
Diffstat (limited to 'src/tests')
-rw-r--r--src/tests/CMakeLists.txt2
-rw-r--r--src/tests/core/nup_writer.cpp392
-rw-r--r--src/tests/video_core/ampere_returns.cpp27
-rw-r--r--src/tests/video_core/vertex_repack.cpp111
4 files changed, 532 insertions, 0 deletions
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index dd6f5d97b5..a9be2ec7a8 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -17,8 +17,10 @@ add_executable(tests
common/undefined_fix.cpp
common/unique_function.cpp
core/core_timing.cpp
+ core/nup_writer.cpp
core/internal_network/network.cpp
video_core/memory_tracker.cpp
+ video_core/vertex_repack.cpp
video_core/ampere_returns.cpp
input_common/calibration_configuration_job.cpp
)
diff --git a/src/tests/core/nup_writer.cpp b/src/tests/core/nup_writer.cpp
new file mode 100644
index 0000000000..ce1a0717ed
--- /dev/null
+++ b/src/tests/core/nup_writer.cpp
@@ -0,0 +1,392 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <array>
+#include <atomic>
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <map>
+#include <string>
+#include <vector>
+
+#include <fmt/format.h>
+#include <openssl/evp.h>
+
+#include "core/file_sys/nup_container.h"
+#include "core/file_sys/nup_writer.h"
+#include "core/file_sys/vfs/vfs_real.h"
+
+namespace {
+
+namespace fs = std::filesystem;
+using FileSys::NupContainer;
+
+struct TempDir {
+ fs::path path = fs::temp_directory_path() /
+ ("nup-test-" +
+ std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
+ TempDir() {
+ fs::create_directories(path);
+ }
+ ~TempDir() {
+ std::error_code ec;
+ fs::remove_all(path, ec);
+ }
+};
+
+void Put(const fs::path& p, std::string_view value) {
+ fs::create_directories(p.parent_path());
+ std::ofstream(p, std::ios::binary) << value;
+}
+
+std::string ReadAll(const fs::path& p) {
+ std::ifstream in(p, std::ios::binary);
+ return {std::istreambuf_iterator<char>(in), {}};
+}
+
+std::string Read(const FileSys::VirtualFile& f) {
+ std::string out(f->GetSize(), '\0');
+ f->Read(reinterpret_cast<u8*>(out.data()), out.size(), 0);
+ return out;
+}
+
+// Enough of an NPDM for the packer: the program ID at ACI0 + 0x10.
+std::string Npdm(u64 program_id) {
+ std::string npdm(0x100, '\0');
+ npdm.replace(0x80, 4, "ACI0");
+ for (int i = 0; i < 8; ++i) {
+ npdm[0x90 + i] = static_cast<char>(program_id >> (8 * i));
+ }
+ return npdm;
+}
+
+std::array<u8, 32> Sha256(std::string_view data) {
+ std::array<u8, 32> out{};
+ unsigned int length{};
+ EVP_Digest(data.data(), data.size(), out.data(), &length, EVP_sha256(), nullptr);
+ return out;
+}
+
+u64 Le(const std::string& buf, size_t at, size_t bytes) {
+ u64 value = 0;
+ for (size_t i = 0; i < bytes; ++i) {
+ value |= static_cast<u64>(static_cast<u8>(buf[at + i])) << (8 * i);
+ }
+ return value;
+}
+
+struct RawFile {
+ std::string name;
+ u64 size;
+ std::array<u8, 32> sha256;
+ u64 extent_offset;
+ u64 extent_size;
+ u8 codec;
+};
+
+// Reads the file records straight from the TOC, which NupContainer does not expose.
+std::vector<RawFile> RawFiles(const std::string& nup) {
+ const u64 toc = Le(nup, 0x08, 8);
+ const u64 packages = Le(nup, toc + 0x04, 4);
+ const u64 dirs = Le(nup, toc + 0x08, 8);
+ const u64 files = Le(nup, toc + 0x10, 8);
+ const u64 pool = toc + Le(nup, toc + 0x18, 8);
+ std::vector<RawFile> out;
+ u64 pos = toc + 0x30 + packages * 0x40 + dirs * 0x18;
+ for (u64 i = 0; i < files; ++i, pos += 0x50) {
+ RawFile file{nup.substr(pool + Le(nup, pos, 4), Le(nup, pos + 4, 4)),
+ Le(nup, pos + 0x08, 8),
+ {},
+ Le(nup, pos + 0x10, 8),
+ Le(nup, pos + 0x18, 8),
+ static_cast<u8>(nup[pos + 0x40])};
+ std::copy_n(nup.begin() + static_cast<std::ptrdiff_t>(pos + 0x20), 32,
+ file.sha256.begin());
+ out.push_back(file);
+ }
+ return out;
+}
+
+// Every file under `dir` with its contents, keyed by its path relative to `dir`.
+std::map<std::string, std::string> SourceTree(const fs::path& dir) {
+ std::map<std::string, std::string> out;
+ for (const auto& entry : fs::recursive_directory_iterator(dir)) {
+ if (entry.is_regular_file() && entry.path().filename() != ".listing") {
+ out[fs::relative(entry.path(), dir).generic_string()] = ReadAll(entry.path());
+ }
+ }
+ return out;
+}
+
+void PackedTree(const FileSys::VirtualDir& dir, const std::string& prefix,
+ std::map<std::string, std::string>& out) {
+ if (dir == nullptr) {
+ return;
+ }
+ for (const auto& file : dir->GetFiles()) {
+ out[prefix + file->GetName()] = Read(file);
+ }
+ for (const auto& sub : dir->GetSubdirectories()) {
+ PackedTree(sub, prefix + sub->GetName() + "/", out);
+ }
+}
+
+std::map<std::string, std::string> PackedTree(const FileSys::VirtualDir& dir) {
+ std::map<std::string, std::string> out;
+ PackedTree(dir, "", out);
+ return out;
+}
+
+constexpr u64 TitleId = 0x0400ABCD00010000ULL;
+
+void MakeDump(const fs::path& root) {
+ Put(root / "Base/ExeFS/main", "base main");
+ Put(root / "Base/ExeFS/main.npdm", Npdm(TitleId));
+ Put(root / "Base/RomFS/shared.bin", std::string(5000, 's'));
+ Put(root / "Base/RomFS/copy/shared.bin", std::string(5000, 's'));
+ Put(root / "Base/RomFS/empty", "");
+ Put(root / "Base/RomFS/.listing", "listing");
+ Put(root / "Base/RomFS/System/Resource/ResourceSizeTable.Product.100.rsizetable", "rst");
+ Put(root / "Base/RomFS/big.bin", std::string(64 * 1024, 'b'));
+ Put(root / "Base/RomFS/random.bin", [] {
+ std::string data(3000, '\0');
+ u32 x = 12345;
+ for (auto& c : data) {
+ x = x * 1664525 + 1013904223;
+ c = static_cast<char>(x >> 24);
+ }
+ return data;
+ }());
+ Put(root / "updates/65536/exefs/main", "update main");
+ Put(root / "updates/65536/exefs/main.npdm", Npdm(TitleId));
+ Put(root / "updates/65536/romfs/shared.bin", std::string(5000, 's'));
+ Put(root / "updates/65536/romfs/System/Resource/ResourceSizeTable.Product.110.rsizetable",
+ "rst");
+ Put(root / "updates/65536/romfs/Zeta/a", "a");
+ Put(root / "updates/65536/romfs/alpha", "alpha");
+ Put(root / "updates/131072/exefs/main", "update 2 main");
+ Put(root / "updates/131072/exefs/main.npdm", Npdm(TitleId));
+ Put(root / "dlc/0400ABCD00011001/0/romfs/addon.bin", "addon data");
+}
+
+} // namespace
+
+TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContainer", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ MakeDump(root);
+ const auto archive = t.path / "Game.nup";
+ FileSys::Nup::PackOptions options{.raw_threshold = 16 * 1024};
+ std::string error;
+ REQUIRE(FileSys::Nup::PackDump(root, archive, options, {}, nullptr, &error) ==
+ FileSys::Nup::PackResult::Success);
+ INFO(error);
+ REQUIRE_FALSE(fs::exists(archive.string() + ".part"));
+
+ FileSys::RealVfsFilesystem real;
+ NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)};
+ REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success);
+ REQUIRE(nup.VerifyIntegrity({}) == Loader::ResultStatus::Success);
+
+ const auto& packages = nup.GetPackages();
+ REQUIRE(packages.size() == 4);
+ REQUIRE(packages[0].kind == NupContainer::PackageKind::Base);
+ REQUIRE(packages[0].title_id == TitleId);
+ REQUIRE(packages[0].version == 0);
+ REQUIRE(packages[0].npdm.size() == 0x100);
+ REQUIRE(packages[1].kind == NupContainer::PackageKind::Update);
+ REQUIRE(packages[1].version == 65536);
+ REQUIRE(packages[1].title_id == TitleId);
+ REQUIRE_FALSE(packages[1].standalone_update);
+ REQUIRE(packages[2].kind == NupContainer::PackageKind::Update);
+ REQUIRE(packages[2].version == 131072);
+ REQUIRE(packages[2].romfs_dir == 0xFFFFFFFF);
+ REQUIRE(packages[3].kind == NupContainer::PackageKind::Addon);
+ REQUIRE(packages[3].title_id == 0x0400ABCD00011001ULL);
+ REQUIRE(packages[3].exefs_dir == 0xFFFFFFFF);
+ REQUIRE(nup.FindBestUpdatePackage() == 2);
+
+ REQUIRE(PackedTree(nup.GetExeFSDir(0)) == SourceTree(root / "Base/ExeFS"));
+ REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(root / "Base/RomFS"));
+ REQUIRE(PackedTree(nup.GetExeFSDir(1)) == SourceTree(root / "updates/65536/exefs"));
+ REQUIRE(PackedTree(nup.GetRomFSDir(1)) == SourceTree(root / "updates/65536/romfs"));
+ REQUIRE(PackedTree(nup.GetExeFSDir(2)) == SourceTree(root / "updates/131072/exefs"));
+ REQUIRE(PackedTree(nup.GetRomFSDir(3)) == SourceTree(root / "dlc/0400ABCD00011001/0/romfs"));
+ REQUIRE_FALSE(nup.GetRomFSDir(0)->GetFile(".listing"));
+
+ const auto bytes = ReadAll(archive);
+ const auto files = RawFiles(bytes);
+ std::map<std::string, std::vector<RawFile>> by_name;
+ for (const auto& file : files) {
+ by_name[file.name].push_back(file);
+ if (file.size != 0) {
+ REQUIRE(file.extent_offset % 16 == 0);
+ }
+ }
+ const auto shared = by_name.at("shared.bin");
+ REQUIRE(shared.size() == 3);
+ for (const auto& file : shared) {
+ REQUIRE(file.sha256 == Sha256(std::string(5000, 's')));
+ REQUIRE(file.codec == 1);
+ REQUIRE(file.extent_offset == shared[0].extent_offset);
+ REQUIRE(file.extent_size == shared[0].extent_size);
+ }
+ const auto& empty = by_name.at("empty").at(0);
+ REQUIRE(empty.size == 0);
+ REQUIRE(empty.extent_offset == 0);
+ REQUIRE(empty.extent_size == 0);
+ REQUIRE(empty.codec == 0);
+ REQUIRE(empty.sha256 == Sha256(""));
+ // Highly compressible, but above the raw threshold.
+ const auto& big = by_name.at("big.bin").at(0);
+ REQUIRE(big.codec == 0);
+ REQUIRE(big.extent_size == 64 * 1024);
+ REQUIRE(big.sha256 == Sha256(std::string(64 * 1024, 'b')));
+ // Incompressible, so stored raw.
+ const auto& random = by_name.at("random.bin").at(0);
+ REQUIRE(random.codec == 0);
+ REQUIRE(random.extent_size == 3000);
+ REQUIRE_FALSE(by_name.contains(".listing"));
+}
+
+TEST_CASE("NUP marks updates standalone when the dump has no base", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ Put(root / "updates/65536/exefs/main", "main");
+ Put(root / "updates/65536/exefs/main.npdm", Npdm(TitleId));
+ Put(root / "updates/65536/romfs/file", "rom");
+ const auto archive = t.path / "Game.nup";
+ REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success);
+
+ FileSys::RealVfsFilesystem real;
+ NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)};
+ REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success);
+ REQUIRE(nup.GetPackages().size() == 1);
+ REQUIRE(nup.GetPackages()[0].kind == NupContainer::PackageKind::Update);
+ REQUIRE(nup.GetPackages()[0].standalone_update);
+ REQUIRE(nup.GetPackages()[0].title_id == TitleId);
+ REQUIRE(Read(nup.GetRomFSDir(0)->GetFile("file")) == "rom");
+}
+
+TEST_CASE("NUP packs a bare exefs/romfs dump as one base", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ Put(root / "ExeFS/main", "main");
+ Put(root / "ExeFS/main.npdm", Npdm(TitleId));
+ Put(root / "RomFS/file", "rom");
+ Put(root / "notes.txt", "extra");
+ const auto archive = t.path / "Game.nup";
+ REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success);
+
+ FileSys::RealVfsFilesystem real;
+ NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)};
+ REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success);
+ REQUIRE(nup.GetPackages().size() == 1);
+ REQUIRE(nup.GetPackages()[0].kind == NupContainer::PackageKind::Base);
+ REQUIRE_FALSE(nup.GetPackages()[0].standalone_update);
+ REQUIRE(Read(nup.GetExeFSDir(0)->GetFile("main")) == "main");
+ REQUIRE(Read(nup.GetRomFSDir(0)->GetFile("file")) == "rom");
+ const auto files = RawFiles(ReadAll(archive));
+ REQUIRE(std::ranges::count(files, std::string{"notes.txt"}, &RawFile::name) == 1);
+}
+
+TEST_CASE("NUP low storage resumes and deletes the packed sources", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ MakeDump(root);
+ const auto copy = t.path / "copy" / "Game";
+ fs::create_directories(copy);
+ fs::copy(root, copy, fs::copy_options::recursive);
+ const auto archive = t.path / "Game.nup";
+
+ std::atomic_bool cancel = false;
+ bool cancelled = false;
+ FileSys::Nup::PackOptions options{.delete_sources = true, .chunk_bytes = 1};
+ auto result = FileSys::Nup::PackDump(
+ root, archive, options,
+ [&](const auto& p) {
+ if (p.files_done >= 4 && !cancelled) {
+ cancel = true;
+ cancelled = true;
+ }
+ },
+ &cancel);
+ REQUIRE(result == FileSys::Nup::PackResult::Cancelled);
+ REQUIRE(FileSys::Nup::HasResumableJournal(archive));
+ REQUIRE_FALSE(fs::exists(root / "Base/ExeFS/main"));
+ REQUIRE_FALSE(fs::exists(root / "Base/ExeFS/main.npdm"));
+
+ cancel = false;
+ std::string error;
+ REQUIRE(FileSys::Nup::PackDump(root, archive, options, {}, &cancel, &error) ==
+ FileSys::Nup::PackResult::Success);
+ INFO(error);
+ REQUIRE_FALSE(fs::exists(archive.string() + ".journal"));
+ REQUIRE_FALSE(fs::exists(archive.string() + ".part"));
+ REQUIRE_FALSE(fs::exists(root / "Base/ExeFS"));
+ REQUIRE_FALSE(fs::exists(root / "updates"));
+ REQUIRE_FALSE(fs::exists(root / "dlc"));
+ // The listing is not packed, so it is left behind with the folders holding it.
+ REQUIRE(ReadAll(root / "Base/RomFS/.listing") == "listing");
+ REQUIRE_FALSE(fs::exists(root / "Base/RomFS/shared.bin"));
+
+ FileSys::RealVfsFilesystem real;
+ NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)};
+ REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success);
+ REQUIRE(nup.VerifyIntegrity({}) == Loader::ResultStatus::Success);
+ REQUIRE(nup.GetPackages().size() == 4);
+ const auto npdm = Npdm(TitleId);
+ REQUIRE(nup.GetPackages()[0].npdm == std::vector<u8>(npdm.begin(), npdm.end()));
+ REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(copy / "Base/RomFS"));
+ REQUIRE(PackedTree(nup.GetRomFSDir(1)) == SourceTree(copy / "updates/65536/romfs"));
+
+ // The resumed archive matches one packed in a single pass.
+ const auto again = t.path / "again.nup";
+ REQUIRE(FileSys::Nup::PackDump(copy, again) == FileSys::Nup::PackResult::Success);
+ REQUIRE(ReadAll(archive) == ReadAll(again));
+}
+
+TEST_CASE("NUP rejects overwrite and a dump without NPDM", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ Put(root / "exefs/main", "main");
+ Put(root / "romfs/a", "rom");
+ const auto archive = t.path / "out.nup";
+ Put(archive, "existing");
+ REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::IoError);
+ REQUIRE(ReadAll(archive) == "existing");
+ fs::remove(archive);
+ REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::InvalidSource);
+ REQUIRE_FALSE(fs::exists(archive.string() + ".part"));
+}
+
+// Archives are meant to be byte-identical on every platform. A change to this hash means
+// packing a dump no longer reproduces archives made by earlier builds (for example after a zstd
+// update), so make it deliberately.
+TEST_CASE("NUP output is reproducible", "[nup]") {
+ TempDir t;
+ const auto root = t.path / "Game";
+ MakeDump(root);
+ // Large and compressible, so it covers what used to be zstd's multithreaded path.
+ Put(root / "Base/RomFS/large.bin", [] {
+ std::string data(20 * 1024 * 1024, '\0');
+ u32 x = 1;
+ for (auto& c : data) {
+ x = x * 1664525 + 1013904223;
+ c = static_cast<char>('a' + (x >> 28));
+ }
+ return data;
+ }());
+ const auto archive = t.path / "Game.nup";
+ REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success);
+
+ std::string hex;
+ for (const u8 b : Sha256(ReadAll(archive))) {
+ hex += fmt::format("{:02x}", b);
+ }
+ REQUIRE(hex == "df8446903ab2c39a51af0304935eb4aa5d8ead105440976ceae7ab97133f6143");
+}
diff --git a/src/tests/video_core/ampere_returns.cpp b/src/tests/video_core/ampere_returns.cpp
index 56b8b374da..163758e07e 100644
--- a/src/tests/video_core/ampere_returns.cpp
+++ b/src/tests/video_core/ampere_returns.cpp
@@ -48,4 +48,31 @@ TEST_CASE("Ampere callees still reject writes to the link", "[video_core][ampere
REQUIRE(lowered.error == "native callee overwrites link register");
REQUIRE(lowered.error_offset == 0x30);
}
+
+TEST_CASE("Ampere callees can compare into predicates with an R0 link", "[video_core][ampere]") {
+ // ISETP's zero destination field is not a write to R0.
+ const std::array<Instruction, 5> code{{
+ {{0x00007802, 0x20, 0x00000f00, 0}},
+ {{0x00007944, 0x10, 0x03c00000, 0}},
+ {{0x0000794d, 0, 0x03800000, 0}},
+ {{0x0100720c, 0x00000002, 0x03f06070, 0}},
+ {{0x00007950, 0xffffffb0, 0x03c3ffff, 0}},
+ }};
+ REQUIRE(static_cast<bool>(LowerToMaxwell(code)));
+}
+
+TEST_CASE("Ampere callees reject a wide multiply over the link", "[video_core][ampere]") {
+ // IMAD.WIDE.U32 R54:R55 writes the R55 link.
+ const std::array<Instruction, 5> code{{
+ {{0x00377802, 0x20, 0x00000f00, 0}},
+ {{0x00007944, 0x10, 0x03c00000, 0}},
+ {{0x0000794d, 0, 0x03800000, 0}},
+ {{0x01367225, 0x00000002, 0x078e00ff, 0}},
+ {{0x37007950, 0xffffffb0, 0x03c3ffff, 0}},
+ }};
+ const auto lowered = LowerToMaxwell(code);
+ REQUIRE(!static_cast<bool>(lowered));
+ REQUIRE(lowered.error == "native callee overwrites link register");
+ REQUIRE(lowered.error_offset == 0x30);
+}
} // namespace
diff --git a/src/tests/video_core/vertex_repack.cpp b/src/tests/video_core/vertex_repack.cpp
new file mode 100644
index 0000000000..6409be3ca4
--- /dev/null
+++ b/src/tests/video_core/vertex_repack.cpp
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <catch2/catch_test_macros.hpp>
+#include "video_core/renderer_vulkan/vertex_repack.h"
+
+namespace {
+using Regs = Tegra::Engines::Maxwell3D::Regs;
+using Size = Regs::VertexAttribute::Size;
+
+Regs MakeLayout() {
+ Regs regs{};
+ for (auto& attr : regs.vertex_attrib_format) {
+ attr.constant.Assign(1);
+ }
+ regs.vertex_streams[0].stride.Assign(69);
+ auto& position = regs.vertex_attrib_format[0];
+ position.constant.Assign(0);
+ position.size.Assign(Size::Size_R32_G32_B32);
+ auto& packed = regs.vertex_attrib_format[1];
+ packed.constant.Assign(0);
+ packed.offset.Assign(65);
+ packed.size.Assign(Size::Size_A2_B10_G10_R10);
+ return regs;
+}
+}
+
+TEST_CASE("Vertex repacking preserves aligned fields and relocates packed tail", "[vertex_repack]") {
+ const auto regs = MakeLayout();
+ const auto mask = Vulkan::VertexRepackMask(regs);
+ REQUIRE(mask == 1);
+ REQUIRE(Vulkan::RepackedVertexOffset(mask, 0, 0) == 0);
+ REQUIRE(Vulkan::RepackedVertexOffset(mask, 0, 64) == 64);
+ REQUIRE(Vulkan::RepackedVertexOffset(mask, 0, 65) == 68);
+ REQUIRE(Vulkan::RepackedVertexStride(mask, 0, 69) == 72);
+ REQUIRE(Vulkan::RepackedVertexStride(mask, 1, 16) == 16);
+}
+
+TEST_CASE("Vertex repacking excludes overlapping and oversized attributes", "[vertex_repack]") {
+ auto regs = MakeLayout();
+ auto& attr = regs.vertex_attrib_format[2];
+ attr.constant.Assign(0);
+ attr.offset.Assign(64);
+ attr.size.Assign(Size::Size_R32);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0);
+ attr.offset.Assign(68);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0);
+ attr.constant.Assign(1);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 1);
+}
+
+TEST_CASE("Vertex repacking ignores constant invalid formats", "[vertex_repack]") {
+ auto regs = MakeLayout();
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 1);
+ regs.vertex_attrib_format[2].constant.Assign(0);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0);
+}
+
+TEST_CASE("Vertex repacking selects bindings independently", "[vertex_repack]") {
+ auto regs = MakeLayout();
+ regs.vertex_streams[31].stride.Assign(69);
+ auto& attr = regs.vertex_attrib_format[2];
+ attr = regs.vertex_attrib_format[1];
+ attr.buffer.Assign(31);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0x80000001U);
+ regs.vertex_streams[0].stride.Assign(72);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0x80000000U);
+ REQUIRE(Vulkan::RepackedVertexOffset(0x80000000U, 0, 65) == 65);
+}
+
+TEST_CASE("Vertex repacking requires the captured tail and stride", "[vertex_repack]") {
+ auto regs = MakeLayout();
+ regs.vertex_streams[0].stride.Assign(68);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0);
+ regs.vertex_streams[0].stride.Assign(69);
+ regs.vertex_attrib_format[1].offset.Assign(64);
+ REQUIRE(Vulkan::VertexRepackMask(regs) == 0);
+}
+
+TEST_CASE("Static vertex repacking requires explicit experimental opt-in", "[vertex_repack]") {
+ REQUIRE(Vulkan::VertexRepackInputSupported(true, false));
+ REQUIRE_FALSE(Vulkan::VertexRepackInputSupported(false, false));
+ REQUIRE(Vulkan::VertexRepackInputSupported(false, true));
+}
+
+TEST_CASE("Vertex repack planning keeps descriptor offsets aligned", "[vertex_repack]") {
+ for (const u32 alignment : {4U, 16U, 64U, 256U, 4096U}) {
+ for (const u32 offset : {0U, 1U, 3U, 255U, 4099U}) {
+ const auto plan = Vulkan::MakeVertexRepackParameters(offset, 70, alignment, 1U << 20, 65535);
+ REQUIRE(plan.has_value());
+ REQUIRE(plan->source_base % alignment == 0);
+ REQUIRE(plan->source_base + plan->source_bias == offset);
+ REQUIRE(plan->vertices == 2);
+ REQUIRE(plan->output_size == 144);
+ REQUIRE(plan->source_range >= plan->source_bias + 70);
+ REQUIRE(plan->source_range % 4 == 0);
+ REQUIRE(plan->dispatch_groups == 1);
+ }
+ }
+}
+
+TEST_CASE("Vertex repack planning rejects overflow and device-limit violations", "[vertex_repack]") {
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 0, 256, 1U << 20, 65535));
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 0, 1U << 20, 65535));
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 256, 71, 65535));
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(255, 69, 256, 323, 65535));
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 256, 1U << 20, 0));
+ REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 0xffffffffU, 256, ~u64{0}, 0xffffffffU));
+ const auto one = Vulkan::MakeVertexRepackParameters(0, 69, 256, 72, 1);
+ REQUIRE(one.has_value());
+ REQUIRE(one->source_range == 72);
+}