diff options
Diffstat (limited to 'src')
98 files changed, 3746 insertions, 477 deletions
diff --git a/src/common/game_presets.h b/src/common/game_presets.h new file mode 100644 index 0000000000..83dd4d3c3f --- /dev/null +++ b/src/common/game_presets.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "common/settings.h" + +namespace Settings { + +inline constexpr u64 MarioKartWorldProgramId = 0x0400C3F00006E000ULL; + +inline void SetMarioKartWorldRecommendedSettings(Values& settings, bool global) { + const auto session = [global](auto& setting, auto value) { + setting.SetGlobal(global); + setting.SetValue(value); + }; + session(settings.renderer_backend, RendererBackend::Vulkan); + session(settings.resolution_setup, ResolutionSetup::Res1X); + session(settings.gpu_accuracy, GpuAccuracy::Low); + session(settings.use_asynchronous_shaders, true); + session(settings.astc_recompression, AstcRecompression::Bc3); + session(settings.vram_usage_mode, VramUsageMode::Conservative); + session(settings.use_reactive_flushing, false); + session(settings.vsync_mode, VSyncMode::FifoRelaxed); + session(settings.use_docked_mode, ConsoleMode::Handheld); + session(settings.scaling_filter, ScalingFilter::ScaleForce); + session(settings.anti_aliasing, AntiAliasing::Fxaa); +} + +inline bool ApplyMarioKartWorldPreset(Values& settings, u64 program_id, bool accepted) { + if (!accepted || program_id != MarioKartWorldProgramId) { + return false; + } + SetMarioKartWorldRecommendedSettings(settings, false); + return true; +} + +} // namespace Settings diff --git a/src/common/settings.h b/src/common/settings.h index 74e2006d15..6669f98f4c 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -358,7 +358,7 @@ struct Values { true}; SwitchableSetting<ScalingFilter> scaling_filter{linkage, - ScalingFilter::NearestNeighbor, + ScalingFilter::ScaleForce, "scaling_filter", Category::Renderer, Specialization::Default, @@ -387,7 +387,7 @@ struct Values { true}; SwitchableSetting<AntiAliasing> anti_aliasing{linkage, - AntiAliasing::None, + AntiAliasing::Fxaa, "anti_aliasing", Category::Renderer, Specialization::Default, @@ -770,11 +770,7 @@ struct Values { Setting<s32> current_user{linkage, 0, "current_user", Category::System}; SwitchableSetting<ConsoleMode> use_docked_mode{linkage, -#ifdef __ANDROID__ ConsoleMode::Handheld, -#else - ConsoleMode::Docked, -#endif "use_docked_mode", Category::System, Specialization::Radio, diff --git a/src/common/slot_vector.h b/src/common/slot_vector.h index f6da7a59d1..8e1c06e35a 100644 --- a/src/common/slot_vector.h +++ b/src/common/slot_vector.h @@ -52,12 +52,16 @@ public: const u64* const bitset = slot_vector->stored_bitset.data(); const u32 size = static_cast<u32>(slot_vector->stored_bitset.size()) * 64; if (id.Value() < size) { - do { - ++id.index; - } while (id.Value() < size && !IsValid(bitset)); - if (id.Value() == size) { - id = SlotId{}; + u32 index = id.Value() + 1; + while (index < size) { + const u64 word = bitset[index / 64] >> (index % 64); + if (word != 0) { + index += static_cast<u32>(std::countr_zero(word)); + break; + } + index = (index / 64 + 1) * 64; } + id = index < size ? SlotId{index} : SlotId{}; } return *this; } @@ -139,7 +143,7 @@ public: if (it == stored_bitset.end()) { return end(); } - const u32 word_index = static_cast<u32>(std::distance(it, stored_bitset.begin())); + const u32 word_index = static_cast<u32>(std::distance(stored_bitset.begin(), it)); const SlotId first_id{word_index * 64 + static_cast<u32>(std::countr_zero(*it))}; return Iterator(this, first_id); } diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index eeb3202d12..1ff9f271d2 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1290,4 +1290,9 @@ else() target_sources(core PRIVATE hle/service/ssl/ssl_backend_none.cpp) endif() +if (ENABLE_LTO AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" AND NOT MSVC) + # Fibers can migrate between host threads; keep kernel TLS lookups outside LTO callers. + set_source_files_properties(hle/kernel/kernel.cpp PROPERTIES COMPILE_OPTIONS -fno-lto) +endif() + create_target_directory_groups(core) diff --git a/src/core/device_memory_manager.inc b/src/core/device_memory_manager.inc index 3523e3cf1c..5931fd0882 100644 --- a/src/core/device_memory_manager.inc +++ b/src/core/device_memory_manager.inc @@ -626,6 +626,11 @@ void DeviceMemoryManager<Traits>::UpdatePagesCachedCountNoLock(DAddr addr, size_ const size_t page_end = Common::DivCeil(addr + size, Memory::YUZU_PAGESIZE); size_t page = addr >> Memory::YUZU_PAGEBITS; auto [asid, base_vaddress] = ExtractCPUBacking(page); + // Sem processo registrado nao ha o que marcar, e asid.id 0 indexaria um vetor vazio. Acontece + // no alvo de testes, que chama UpdatePagesCachedBatch num DeviceMemoryManager recem-criado. + if (asid.id >= registered_processes.size()) { + return; + } auto* memory_device_inter = registered_processes[asid.id]; const auto release_pending = [&] { if (uncache_bytes > 0) { diff --git a/src/core/file_sys/nup_container.cpp b/src/core/file_sys/nup_container.cpp index 0e485b44ec..27ac4486ca 100644 --- a/src/core/file_sys/nup_container.cpp +++ b/src/core/file_sys/nup_container.cpp @@ -8,11 +8,15 @@ #include <cstring> #include <list> #include <map> +#include <random> +#include <span> #include <unordered_map> +#include <fmt/format.h> #include <openssl/evp.h> #include <zstd.h> +#include "common/fs/path_util.h" #include "common/logging.h" #include "common/swap.h" #include "core/file_sys/vfs/vfs_offset.h" @@ -31,6 +35,7 @@ constexpr u32 TocMagic = 0x01434F54; // "TOC\x01" little-endian constexpr u8 CodecRaw = 0; constexpr u8 CodecZstd = 1; constexpr u8 PackageFlagStandaloneUpdate = 0x01; +constexpr u32 MaxDirDepth = 128; #pragma pack(push, 1) struct Superblock { @@ -142,8 +147,8 @@ bool Sha256(const u8* data, std::size_t size, std::array<u8, 0x20>& out) { 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. +/// A member file whose extent is zstd-compressed. Decompresses on first read and caches the +/// result (LRU-evicted); a file over half the cache cap is decompressed once to a temporary file. class NupVfsFile final : public VfsFile { public: NupVfsFile(VirtualFile backing_, u64 extent_offset_, u64 extent_size_, u64 uncompressed_size_, @@ -151,7 +156,7 @@ public: 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) {} + cache_index(cache_index_), cache_enabled(uncompressed_size <= cache->cap / 2) {} std::string GetName() const override { return name; @@ -183,6 +188,10 @@ public: } length = std::min(length, static_cast<std::size_t>(uncompressed_size) - offset); + if (!cache_enabled) { + return ReadSpilled(data, length, offset); + } + { std::scoped_lock lock{cache->mutex}; if (const auto* cached = cache->Find(cache_index)) { @@ -198,9 +207,7 @@ public: } { std::scoped_lock lock{cache->mutex}; - if (cache_enabled) { - cache->Insert(cache_index, scratch); - } + cache->Insert(cache_index, scratch); if (const auto* cached = cache->Find(cache_index)) { std::memcpy(data, cached->data() + offset, length); return length; @@ -220,6 +227,88 @@ public: } private: + std::size_t ReadSpilled(u8* data, std::size_t length, std::size_t offset) const { + std::shared_ptr<NupSpillFile> spill; + { + std::scoped_lock lock{cache->mutex}; + spill = cache->GetSpill(cache_index); + } + std::scoped_lock lock{spill->mutex}; + if (!spill->attempted) { + spill->attempted = true; + DecompressToFile(*spill); + } + if (!spill->file.IsOpen() || !spill->file.Seek(static_cast<s64>(offset))) { + return 0; + } + return spill->file.ReadSpan(std::span{data, length}); + } + + void DecompressToFile(NupSpillFile& spill) const { + const auto fail = [&](const char* reason) { + LOG_ERROR(Loader, "NUP zstd decompression of {} to a temporary file failed: {}", name, + reason); + spill.file.Close(); + if (!spill.path.empty()) { + std::error_code ec; + std::filesystem::remove(spill.path, ec); + } + }; + if (extent_size > backing->GetSize() || extent_offset > backing->GetSize() - extent_size) { + return fail("extent out of range"); + } + + std::random_device random; + std::error_code ec; + std::filesystem::create_directories(cache->spill_dir, ec); + spill.path = + cache->spill_dir / fmt::format("{:08x}{:08x}-{}.tmp", random(), random(), cache_index); + Common::FS::IOFile out{spill.path, Common::FS::FileAccessMode::Write}; + if (!out.IsOpen()) { + return fail("cannot create the temporary file"); + } + + std::unique_ptr<ZSTD_DCtx, decltype(&ZSTD_freeDCtx)> dctx{ZSTD_createDCtx(), + &ZSTD_freeDCtx}; + std::vector<u8> in_buffer(ZSTD_DStreamInSize()); + std::vector<u8> out_buffer(ZSTD_DStreamOutSize()); + u64 read = 0; + u64 written = 0; + bool frame_done = false; + while (!frame_done && read < extent_size) { + const auto chunk = + static_cast<std::size_t>(std::min<u64>(in_buffer.size(), extent_size - read)); + if (backing->Read(in_buffer.data(), chunk, + static_cast<std::size_t>(extent_offset + read)) != chunk) { + return fail("short read of the compressed data"); + } + read += chunk; + ZSTD_inBuffer input{in_buffer.data(), chunk, 0}; + ZSTD_outBuffer output{}; + do { + output = {out_buffer.data(), out_buffer.size(), 0}; + const std::size_t result = ZSTD_decompressStream(dctx.get(), &output, &input); + if (ZSTD_isError(result) || output.pos > uncompressed_size - written) { + return fail("corrupt or oversized data"); + } + if (out.WriteSpan(std::span<const u8>{out_buffer.data(), output.pos}) != + output.pos) { + return fail("cannot write the temporary file"); + } + written += output.pos; + frame_done = result == 0; + } while (!frame_done && (input.pos < input.size || output.pos == output.size)); + } + if (!frame_done || written != uncompressed_size) { + return fail("size mismatch"); + } + out.Close(); + spill.file.Open(spill.path, Common::FS::FileAccessMode::Read); + if (!spill.file.IsOpen()) { + fail("cannot reopen the temporary file"); + } + } + std::vector<u8> Decompress() const { if (extent_size > backing->GetSize() || extent_offset > backing->GetSize() - extent_size) { return {}; @@ -251,6 +340,13 @@ private: } // namespace +NupSpillFile::~NupSpillFile() { + file.Close(); + if (!path.empty()) { + std::error_code ec; + std::filesystem::remove(path, ec); + } +} bool NupContainer::IsNupFile(const VirtualFile& file) { if (file == nullptr || file->GetSize() < SuperblockSize) { @@ -261,8 +357,13 @@ bool NupContainer::IsNupFile(const VirtualFile& file) { return magic == HeaderMagic; } -NupContainer::NupContainer(VirtualFile file_) +NupContainer::NupContainer(VirtualFile file_, std::size_t memory_cache_cap, + std::filesystem::path spill_dir_) : file(std::move(file_)), cache(std::make_shared<NupDecompressCache>()) { + cache->cap = memory_cache_cap; + cache->spill_dir = spill_dir_.empty() + ? Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "nup" + : std::move(spill_dir_); if (file == nullptr) { return; } @@ -293,9 +394,9 @@ bool NupContainer::Parse() { 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) { + if (file_size != file->GetSize() || toc_offset < SuperblockSize || blob_offset > file_size || + toc_offset > blob_offset || toc_size > blob_offset - toc_offset || + blob_offset % 0x10 != 0) { status = Loader::ResultStatus::ErrorNUPBadFormat; return false; } @@ -325,15 +426,22 @@ bool NupContainer::Parse() { return false; } + // The counts come from the file, so bound them by the TOC size before multiplying. + if (header.dir_count > toc.size() / DirRecordSize || + header.file_count > toc.size() / FileRecordSize) { + 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) { + if (needed > toc.size() || header.pool_offset < needed || header.pool_offset > toc.size() || + header.pool_size > toc.size() - header.pool_offset || + (header.pool_offset - needed) % sizeof(u32) != 0) { status = Loader::ResultStatus::ErrorNUPBadFormat; return false; } + const u64 children_offset = needed; + const u64 children_bytes = header.pool_offset - needed; const u8* base = toc.data(); std::size_t pos = TocHeaderSize; @@ -358,8 +466,8 @@ bool NupContainer::Parse() { 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) { + if (record.meta_size > 0 && record.meta_offset != 0 && record.meta_offset <= file_size && + record.meta_size <= file_size - record.meta_offset) { package.meta_offset = record.meta_offset; package.meta_size = record.meta_size; } @@ -371,8 +479,9 @@ bool NupContainer::Parse() { 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) { + if (static_cast<u64>(record.first_child) + record.child_count > + children_bytes / sizeof(u32) || + static_cast<u64>(record.name_offset) + record.name_length > header.pool_size) { status = Loader::ResultStatus::ErrorNUPBadFormat; return false; } @@ -392,9 +501,9 @@ bool NupContainer::Parse() { (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) { + record.extent_offset % 0x10 != 0 || record.extent_offset > file_size || + record.extent_size > file_size - record.extent_offset)) || + static_cast<u64>(record.name_offset) + record.name_length > header.pool_size) { status = Loader::ResultStatus::ErrorNUPBadFormat; return false; } @@ -417,6 +526,11 @@ bool NupContainer::Parse() { } } + if (!ValidateDirTree()) { + status = Loader::ResultStatus::ErrorNUPBadFormat; + return false; + } + for (auto& package : packages) { if (!ParseMeta(package)) { status = Loader::ResultStatus::ErrorNUPBadFormat; @@ -426,6 +540,57 @@ bool NupContainer::Parse() { return true; } +bool NupContainer::ValidateDirTree() const { + enum : u8 { Unvisited, InProgress, Done }; + struct Frame { + u32 dir; + u32 next; + }; + std::vector<u8> state(dirs.size(), Unvisited); + std::vector<u32> height(dirs.size(), 0); + std::vector<Frame> stack; + for (u32 start = 0; start < dirs.size(); ++start) { + if (state[start] != Unvisited) { + continue; + } + state[start] = InProgress; + stack.push_back({start, 0}); + while (!stack.empty()) { + const Frame frame = stack.back(); + const DirNode& node = dirs[frame.dir]; + if (frame.next == node.child_count) { + state[frame.dir] = Done; + stack.pop_back(); + if (!stack.empty()) { + u32& parent_height = height[stack.back().dir]; + parent_height = std::max(parent_height, height[frame.dir] + 1); + } + continue; + } + ++stack.back().next; + const u32 child = children[static_cast<std::size_t>(node.first_child) + frame.next]; + if (child >= dirs.size()) { + continue; + } + if (state[child] == InProgress) { + LOG_ERROR(Loader, "NUP directory tree contains a cycle"); + return false; + } + if (state[child] == Done) { + height[frame.dir] = std::max(height[frame.dir], height[child] + 1); + } else { + state[child] = InProgress; + stack.push_back({child, 0}); + } + if (stack.size() + height[child] > MaxDirDepth) { + LOG_ERROR(Loader, "NUP directory tree is nested too deeply"); + return false; + } + } + } + return true; +} + bool NupContainer::ParseMeta(PackageInfo& package) { if (package.meta_offset == 0) { return true; @@ -547,7 +712,8 @@ VirtualFile NupContainer::OpenFile(std::size_t 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 u64 end = static_cast<u64>(node.first_child) + node.child_count; + for (u64 slot = node.first_child; slot < end; ++slot) { const u32 child = children[slot]; if (child >= dirs.size()) { out.AddFile(OpenFile(child - static_cast<u32>(dirs.size()))); diff --git a/src/core/file_sys/nup_container.h b/src/core/file_sys/nup_container.h index 118b137a7b..8787717d47 100644 --- a/src/core/file_sys/nup_container.h +++ b/src/core/file_sys/nup_container.h @@ -4,6 +4,7 @@ #pragma once #include <array> +#include <filesystem> #include <functional> #include <list> #include <memory> @@ -14,19 +15,36 @@ #include <vector> #include "common/common_types.h" +#include "common/fs/file.h" #include "core/file_sys/vfs/vfs_types.h" #include "core/file_sys/vfs/vfs_vector.h" #include "core/loader/loader.h" namespace FileSys { +/// A zstd member too large for the in-memory cache, decompressed once into a temporary file. +struct NupSpillFile { + ~NupSpillFile(); + + std::mutex mutex; + std::filesystem::path path; + Common::FS::IOFile file; + bool attempted{}; +}; + /// LRU over decompressed zstd extents, shared by the files of one container. struct NupDecompressCache { + static constexpr std::size_t DefaultCap = 512 * 1024 * 1024; + + using Entry = std::pair<std::size_t, std::vector<u8>>; + 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::list<Entry> lru; + std::unordered_map<std::size_t, std::list<Entry>::iterator> index; std::size_t bytes{}; + std::size_t cap{DefaultCap}; + std::filesystem::path spill_dir; + std::unordered_map<std::size_t, std::shared_ptr<NupSpillFile>> spills; std::vector<u8>* Find(std::size_t file_index) { const auto it = index.find(file_index); @@ -37,21 +55,31 @@ struct NupDecompressCache { return &it->second->second; } + /// Takes ownership of `data` only when it is inserted. void Insert(std::size_t file_index, std::vector<u8>& data) { - while (bytes + data.size() > DecompressCacheCap && !lru.empty()) { + if (index.contains(file_index)) { + return; + } + while (bytes + data.size() > cap && !lru.empty()) { bytes -= lru.back().second.size(); index.erase(lru.back().first); lru.pop_back(); } - if (bytes + data.size() > DecompressCacheCap) { + if (bytes + data.size() > cap) { return; } - lru.emplace_front(file_index, data); - index.emplace(file_index, lru.begin()); bytes += data.size(); + lru.emplace_front(file_index, std::move(data)); + index.emplace(file_index, lru.begin()); } - static constexpr std::size_t DecompressCacheCap = 512 * 1024 * 1024; + std::shared_ptr<NupSpillFile> GetSpill(std::size_t file_index) { + auto& spill = spills[file_index]; + if (!spill) { + spill = std::make_shared<NupSpillFile>(); + } + return spill; + } }; /** @@ -101,7 +129,11 @@ public: /// Recursively merges overlay into a copy of base; overlay paths win. static VirtualDir MergeShadowDirs(const VirtualDir& base, const VirtualDir& overlay); - explicit NupContainer(VirtualFile file_); + /// Zstd members larger than half of `memory_cache_cap` are decompressed to a temporary file + /// in `spill_dir` (the cache directory by default) instead of being held in memory. + explicit NupContainer(VirtualFile file_, + std::size_t memory_cache_cap = NupDecompressCache::DefaultCap, + std::filesystem::path spill_dir_ = {}); ~NupContainer(); Loader::ResultStatus GetStatus() const { @@ -119,7 +151,8 @@ public: 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. + /// the container, zstd extents are lazily decompressed, cached in memory + /// or, when large, in a temporary file. VirtualFile OpenFile(std::size_t file_index); VirtualDir GetExeFSDir(std::size_t package_index); @@ -151,6 +184,7 @@ private: bool Parse(); bool ParseMeta(PackageInfo& package); + bool ValidateDirTree() const; void BuildDirTree(u32 dir_index, VectorVfsDirectory& out); VirtualDir BuildPackageDir(u32 dir_index); std::vector<u8> ReadExtentRaw(const FileNode& node) const; diff --git a/src/core/file_sys/nup_writer.cpp b/src/core/file_sys/nup_writer.cpp index 8c7728ed01..4161969f98 100644 --- a/src/core/file_sys/nup_writer.cpp +++ b/src/core/file_sys/nup_writer.cpp @@ -8,6 +8,7 @@ #include <cctype> #include <charconv> #include <cstring> +#include <functional> #include <limits> #include <map> #include <memory> @@ -516,8 +517,9 @@ std::unique_ptr<Loader::AppLoader_DeconstructedRomDirectory> OpenLoader( class Discovery { public: - Discovery(const fs::path& dump_root_, std::vector<fs::path> excluded_) - : dump_root{dump_root_}, excluded{std::move(excluded_)} {} + Discovery(const fs::path& dump_root_, std::vector<fs::path> excluded_, + const Metadata& overrides_) + : dump_root{dump_root_}, excluded{std::move(excluded_)}, overrides{overrides_} {} Plan Run() { const auto base = FindDirectory(dump_root, "base"); @@ -678,6 +680,34 @@ private: tlvs.emplace_back(TlvIcon, std::move(icon)); } } + + if (!overrides.title_name.empty()) { + SetTlv(tlvs, TlvTitleName, StringBytes(overrides.title_name)); + } + if (!overrides.icon.empty()) { + SetTlv(tlvs, TlvIcon, overrides.icon); + } + if (!overrides.display_version.empty()) { + // The loader reports the newest update's version when there is one. + SetTlv(tlvs, TlvDisplayVersion, StringBytes(overrides.display_version)); + std::optional<u32> newest; + for (u32 i = 0; i < plan.packages.size(); ++i) { + if (plan.packages[i].kind == KindUpdate && + (!newest || plan.packages[i].version >= plan.packages[*newest].version)) { + newest = i; + } + } + if (newest && *newest != target) { + SetTlv(package_tlvs[*newest], TlvDisplayVersion, + StringBytes(overrides.display_version)); + } + } + } + + static void SetTlv(std::vector<std::pair<u16, std::vector<u8>>>& tlvs, u16 type, + std::vector<u8> payload) { + std::erase_if(tlvs, [type](const auto& tlv) { return tlv.first == type; }); + tlvs.emplace_back(type, std::move(payload)); } bool IsExcluded(const fs::path& path) const { @@ -722,6 +752,7 @@ private: fs::path dump_root; std::vector<fs::path> excluded; + Metadata overrides; RealVfsFilesystem vfs; Plan plan; std::vector<std::vector<std::pair<u16, std::vector<u8>>>> package_tlvs; @@ -838,7 +869,7 @@ public: for (const auto& path : {archive, part, journal}) { excluded.push_back(fs::absolute(path).lexically_normal()); } - plan = Discovery{dump_root, std::move(excluded)}.Run(); + plan = Discovery{dump_root, std::move(excluded), options.metadata}.Run(); layout = BuildLayout(plan); OpenNew(); } @@ -1421,6 +1452,68 @@ std::optional<fs::path> DumpRootFromMain(const fs::path& main) { return root; } +Metadata ProbeMetadata(const fs::path& dump_root) { + RealVfsFilesystem vfs; + const auto base = FindDirectory(dump_root, "base"); + const auto updates = FindDirectory(dump_root, "updates"); + + // Newest update first, then the base (or a bare dump's root). + std::vector<std::pair<u64, fs::path>> update_dirs; + 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)) { + update_dirs.emplace_back(*version, entry.path()); + } + } + } + std::ranges::sort(update_dirs, std::greater<>{}); + const fs::path base_dir = !base.empty() ? base : (updates.empty() ? dump_root : fs::path{}); + + Metadata result; + const auto probe = [&](const fs::path& dir, const std::string& label, bool names) { + const auto loader = OpenLoader(vfs, dir); + if (!loader) { + return; + } + if (result.display_version.empty()) { + std::string version; + if (loader->ReadDisplayVersion(version) == Loader::ResultStatus::Success && + !version.empty()) { + result.display_version = version; + result.version_source = label; + } + } + if (names) { + NACP control; + if (result.title_name.empty() && + loader->ReadControlData(control) == Loader::ResultStatus::Success) { + result.title_name = control.GetApplicationName(); + } + std::vector<u8> icon; + if (result.icon.empty() && + loader->ReadIcon(icon) == Loader::ResultStatus::Success) { + result.icon = std::move(icon); + } + } + }; + for (const auto& [version, dir] : update_dirs) { + probe(dir, "update " + std::to_string(version), false); + } + if (!base_dir.empty()) { + probe(base_dir, "base", true); + } + // Names and icons fall back to the updates when the base has none. + for (const auto& [version, dir] : update_dirs) { + if (!result.title_name.empty() && !result.icon.empty()) { + break; + } + probe(dir, "update " + std::to_string(version), true); + } + return result; +} + fs::path DefaultArchivePath(const fs::path& dump_root) { auto root = dump_root; if (!root.has_filename()) { diff --git a/src/core/file_sys/nup_writer.h b/src/core/file_sys/nup_writer.h index 231387b156..dd7b7e0883 100644 --- a/src/core/file_sys/nup_writer.h +++ b/src/core/file_sys/nup_writer.h @@ -8,6 +8,7 @@ #include <functional> #include <optional> #include <string> +#include <vector> #include "common/common_types.h" @@ -24,12 +25,22 @@ struct PackProgress { 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. +// NupContainer keeps a zstd file in memory only while it fits half of its 512 MiB decompression +// cache; a larger one is decompressed to a temporary file on first read, which costs time and +// disk space. Files above this size are therefore stored raw. constexpr u64 DefaultRawThreshold = 256ULL * 1024 * 1024; +// Application metadata written to the base package (or the first package when there is no base). +// In PackOptions an empty field keeps what the dump supplies; ProbeMetadata fills all of them. +struct Metadata { + std::string title_name; + // JPEG or PNG bytes; 256x256 JPEG as distributed on-console. + std::vector<u8> icon; + std::string display_version; + // Where ProbeMetadata found display_version, e.g. "update 458752" or "base". + std::string version_source; +}; + struct PackOptions { // Low-storage mode: delete each source file once its extent is synced and journaled. bool delete_sources = false; @@ -37,6 +48,8 @@ struct PackOptions { int zstd_level = 3; // Files larger than this are always stored raw. u64 raw_threshold = DefaultRawThreshold; + // Overrides for the name, icon and display version found in the dump. + Metadata metadata; }; // A loose dump holds exefs/main and main.npdm, directly or under base/ or updates/<version>/, @@ -45,6 +58,9 @@ 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); +// Reads the name, icon and display version a dump would be packed with. The version comes from +// the newest update that reports one, else the base. +Metadata ProbeMetadata(const std::filesystem::path& dump_root); 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 diff --git a/src/core/hle/kernel/k_priority_queue.h b/src/core/hle/kernel/k_priority_queue.h index 89a0c5bbea..5d93455d33 100644 --- a/src/core/hle/kernel/k_priority_queue.h +++ b/src/core/hle/kernel/k_priority_queue.h @@ -11,6 +11,7 @@ #include <bitset> #include <concepts> #include <cstddef> +#include <type_traits> #include "common/alignment.h" #include "common/assert.h" @@ -196,12 +197,7 @@ public: constexpr Member* GetFront(s32 core) const { ASSERT(IsValidCore(core)); - const s32 priority = s32([](auto const& e) { - for (size_t i = 0; i < e.size(); ++i) - if (e[i]) - return i; - return e.size(); - }(m_available_priorities[core])); + const s32 priority = s32(GetNextSet(m_available_priorities[core], size_t(-1))); if (priority <= LowestPriority) { return m_queues[priority].GetFront(core); } else { @@ -222,7 +218,17 @@ public: template<size_t N> constexpr size_t GetNextSet(std::bitset<N> const& bit, size_t n) const { - for (size_t i = n + 1; i < bit.size(); i++) + const size_t first = n + 1; + if (first >= bit.size()) { + return bit.size(); + } + if constexpr (N <= 64) { + if (!std::is_constant_evaluated()) { + const u64 remaining = bit.to_ullong() >> first; + return remaining != 0 ? first + size_t(std::countr_zero(remaining)) : bit.size(); + } + } + for (size_t i = first; i < bit.size(); i++) if (bit[i]) return i; return bit.size(); diff --git a/src/core/hle/kernel/kernel.cpp b/src/core/hle/kernel/kernel.cpp index f006d129a7..9e6ccc2d89 100644 --- a/src/core/hle/kernel/kernel.cpp +++ b/src/core/hle/kernel/kernel.cpp @@ -79,7 +79,7 @@ struct KernelCore::Impl { // And we have the guarantee that the data won't move out of the way so we can safely // take a reference to it. This isn't always universally true but this is "global" data // so it will be statically given a TLS slot anyways. - static inline thread_local ThreadLocalData tls_data = {}; + static thread_local ThreadLocalData tls_data; explicit Impl(Core::System& system_, KernelCore& kernel_) : system{system_} { tls_data.lock = true; @@ -861,6 +861,8 @@ struct KernelCore::Impl { Core::System& system; }; +thread_local ThreadLocalData KernelCore::Impl::tls_data{}; + KernelCore::KernelCore(Core::System& system) : impl{std::make_unique<Impl>(system, *this)} {} KernelCore::~KernelCore() = default; diff --git a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp index 40313a10fb..c5b23bef3e 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp @@ -176,7 +176,7 @@ void nvhost_as_gpu::Nx2Map(u64 gpu_address, u64 cpu_address, u64 size) { Nx2GpuMemoryRegistry::Get().RecordDeviceRange(cpu_address, size, device_address); // On the GPU thread, like unmaps: a GMMU map takes the texture cache lock, and the callers hold // nx2:gpu locks that the GPU thread takes under that lock (sparse blocks backed on write). - system.GPU().RunInOrder([gmmu = gmmu.get(), gpu_address, device_address, size] { + system.GPU().MapInOrder([gmmu = gmmu.get(), gpu_address, device_address, size] { gmmu->Map(gpu_address, device_address, size, Tegra::PTEKind::PITCH, false); }); LOG_DEBUG(Service_NVDRV, "Switch 2 GPU mapping {:#x}+{:#x} -> CPU {:#x}", gpu_address, size, @@ -253,7 +253,7 @@ void nvhost_as_gpu::Nx2Sparse(u64 gpu_address, u64 size, bool create) { } nx2_sparse_ranges[gpu_address] = {size, device_address}; // On the GPU thread, as in Nx2Map. - system.GPU().RunInOrder([gmmu = gmmu.get(), gpu_address, device_address, size] { + system.GPU().MapInOrder([gmmu = gmmu.get(), gpu_address, device_address, size] { gmmu->Map(gpu_address, device_address, size, Tegra::PTEKind::PITCH, false); }); LOG_DEBUG(Service_NVDRV, "Switch 2 sparse region {:#x}+{:#x} -> device {:#x}", gpu_address, @@ -270,7 +270,7 @@ void nvhost_as_gpu::Nx2BackSparse(u64 gpu_address, u64 cpu_address, u64 size) { --it; const auto& [start, range] = *it; const auto& [region_size, device_address] = range; - if (gpu_address + size > start + region_size) { + if (size > region_size || gpu_address - start > region_size - size) { return; } system.Host1x().MemoryManager().Map(device_address + (gpu_address - start), cpu_address, size, @@ -287,7 +287,7 @@ void nvhost_as_gpu::Nx2SparseTile(u64 gpu_address, u64 cpu_address, u64 size) { --it; const auto& [start, range] = *it; const auto& [region_size, device_address] = range; - if (gpu_address + size > start + region_size) { + if (size > region_size || gpu_address - start > region_size - size) { return; } // In GPU-thread order: command lists submitted before the request still see the old tile. @@ -295,7 +295,7 @@ void nvhost_as_gpu::Nx2SparseTile(u64 gpu_address, u64 cpu_address, u64 size) { // whose contents are now what the game's memory holds there. const DAddr device = device_address + (gpu_address - start); auto& smmu = system.Host1x().MemoryManager(); - system.GPU().RunInOrder([&smmu, device, cpu_address, size, asid = session->asid] { + system.GPU().MapInOrder([&smmu, device, cpu_address, size, asid = session->asid] { smmu.Unmap(device, size); if (cpu_address != 0) { smmu.Map(device, cpu_address, size, asid, true); diff --git a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp index 848c15d6f8..79586446d5 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp @@ -445,7 +445,10 @@ NvResult nvhost_gpu::SubmitGPFIFOImpl(IoctlSubmitGpfifo& params, Tegra::CommandL const char* value = std::getenv("EDEN_NX2_PB_SNAPSHOT"); return value == nullptr || value[0] != '0'; }(); - if (snapshot && channel_state->memory_manager) { + if (snapshot && channel_state->memory_manager && channel_state->payload) { + // Before the copy: a suspension during it must count as one after it. + entries.snapshot_suspensions = + channel_state->payload->dma_pusher.suspensions.load(std::memory_order_acquire); size_t words = 0; for (const auto& entry : entries.command_lists) { words += entry.size; diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 37c797fa3f..259681a2a2 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -15,6 +15,7 @@ #include <cstdlib> #include <cstdio> #include <initializer_list> +#include <limits> #include <atomic> #include <deque> #include <map> @@ -1076,13 +1077,18 @@ private: } const u64 gpu_address = request.gpu_address; const u64 size = request.size; + if (size > std::numeric_limits<u64>::max() - gpu_address) { + LOG_WARNING(Service, "nx2:gpu 216 [{:#x}, +{:#x}] wraps the address space", gpu_address, + size); + return; + } auto& registry = Nvidia::Devices::Nx2GpuMemoryRegistry::Get(); auto it = m_sparse_regions.upper_bound(gpu_address); SparseRegion* region = nullptr; u64 start = 0; if (it != m_sparse_regions.begin()) { --it; - if (gpu_address + size <= it->first + it->second.size) { + if (size <= it->second.size && gpu_address - it->first <= it->second.size - size) { start = it->first; region = &it->second; } @@ -1145,7 +1151,7 @@ private: return std::nullopt; } --it; - if (gpu_address + size > it->first + it->second) { + if (size > it->second || gpu_address - it->first > it->second - size) { return std::nullopt; } return it->first; @@ -1240,7 +1246,7 @@ private: return std::nullopt; } --it; - if (gpu_address + size > it->first + it->second.size) { + if (size > it->second.size || gpu_address - it->first > it->second.size - size) { return std::nullopt; } return it->second.cpu_address + (gpu_address - it->first); @@ -1705,37 +1711,41 @@ private: LOG_INFO(Service, "nx2:vi2 229 present #{} token={:#x} slot={} image={}", m_presents, u64{w[2]} | (u64{w[3]} << 32), w[4], w[5]); } - // Wait for the previous frame's fence instead of this one, so the game prepares frame N+1 - // while the GPU thread runs frame N (the console keeps several frames in flight). Waiting - // for this frame serialized the two: 8-56 ms of every 34-105 ms frame on title 1. - // EDEN_NX2_FRAMES_AHEAD=0 restores the wait for the current frame. - static const bool frames_ahead = [] { + // Wait for this frame's fence, so the game doesn't write the next frame's data (constant + // buffers holding texture handles, vertex data, Pokopia's glyph atlas) while the GPU + // thread still reads this frame's. But title 1's GPU waits on semaphores its CPU releases + // only after this present returns: while a channel stays suspended on one, wait for the + // previous frame's fence instead and let the game prepare frame N+1 meanwhile (waiting + // for this frame there cost 8-56 ms of every 34-105 ms frame, or never finished). + // EDEN_NX2_FRAMES_AHEAD=1 always waits for the previous frame, =0 always for this one. + static const int frames_ahead = [] { const char* value = std::getenv("EDEN_NX2_FRAMES_AHEAD"); - return value == nullptr || value[0] != '0'; + return value == nullptr ? -1 : value[0] == '1' ? 1 : 0; }(); const auto entered = std::chrono::steady_clock::now(); - if (frames_ahead) { + if (frames_ahead == 1 || (frames_ahead == -1 && !WaitForFrame(~w[22], w[24], true))) { LOG_DEBUG(Service, "nx2:vi2 present #{} fence ({}, {:#x}), waiting for ({}, {:#x}); now {:#x}", m_presents, ~w[22], w[24], m_previous_fence_id, m_previous_fence_value, ~w[22] < 192 ? system.Host1x().GetSyncpointManager().GetGuestSyncpointValue(~w[22]) : 0U); - // Only run a frame ahead while the GPU keeps up. With the previous frame still on the - // GPU here, the GPU is the limit and running ahead gains nothing; and Splatoon Raiders, - // which reuses its command memory as if the GPU were at most a frame behind, rewrote - // command lists Eden had not read yet (misread commands, then crashes, with a slow - // GPU). Wait for this frame too then. - const bool gpu_behind = - m_previous_fence_id < 192 && - !system.Host1x().GetSyncpointManager().IsReadyGuest(m_previous_fence_id, - m_previous_fence_value); + // With EDEN_NX2_FRAMES_AHEAD=1, only run a frame ahead while the GPU keeps up. With + // the previous frame still on the GPU here, the GPU is the limit and running ahead + // gains nothing; and Splatoon Raiders, which reuses its command memory as if the GPU + // were at most a frame behind, rewrote command lists Eden had not read yet (misread + // commands, then crashes, with a slow GPU). Wait for this frame too then. Not when a + // suspended channel brought us here: this frame can't finish before the present + // returns. + const bool gpu_behind = frames_ahead == 1 && m_previous_fence_id < 192 && + !system.Host1x().GetSyncpointManager().IsReadyGuest( + m_previous_fence_id, m_previous_fence_value); WaitForFrame(m_previous_fence_id, m_previous_fence_value); if (gpu_behind) { WaitForFrame(~w[22], w[24]); } - m_previous_fence_id = ~w[22]; - m_previous_fence_value = w[24]; - } else { + } else if (frames_ahead == 0) { WaitForFrame(~w[22], w[24]); } + m_previous_fence_id = ~w[22]; + m_previous_fence_value = w[24]; const auto waited = std::chrono::steady_clock::now(); Composite(w[5], ~w[22], w[24], w[7]); const auto composited = std::chrono::steady_clock::now(); @@ -1832,9 +1842,12 @@ private: m_next_vblank += interval; } - void WaitForFrame(u32 fence_id, u32 fence_value) { + /// Waits until the fence is reached. With `unless_gpu_waits`, gives up after a few ms once a + /// GPU channel was seen suspended on a semaphore (it may be one this thread releases later). + /// Returns whether the fence was reached. + bool WaitForFrame(u32 fence_id, u32 fence_value, bool unless_gpu_waits = false) { if (fence_id >= 192) { - return; + return true; } auto& syncpoints = system.Host1x().GetSyncpointManager(); const auto start = std::chrono::steady_clock::now(); @@ -1859,11 +1872,24 @@ private: wake->cv.notify_all(); }); std::unique_lock lk{wake->mutex}; + // Title 1's channels resume and suspend again while they wait on this thread, so any + // suspension counts, not only an unbroken one. + bool gpu_waited = false; while (!wake->done && system.IsPoweredOn() && std::chrono::steady_clock::now() < deadline) { - wake->cv.wait_for(lk, std::chrono::milliseconds{50}); + if (unless_gpu_waits) { + gpu_waited |= system.GPU().HasSuspendedChannels(); + if (gpu_waited && + std::chrono::steady_clock::now() - start > std::chrono::milliseconds{3}) { + return false; + } + } + wake->cv.wait_for(lk, std::chrono::milliseconds{unless_gpu_waits ? 1 : 50}); } } + if (unless_gpu_waits && !syncpoints.IsReadyGuest(fence_id, fence_value)) { + return false; + } if (!syncpoints.IsReadyGuest(fence_id, fence_value) && m_wait_timeouts++ < 64) { LOG_WARNING(Service, "nx2:vi2 present #{} fence ({}, {}) not reached in 4 s (now {})", m_presents, fence_id, fence_value, syncpoints.GetGuestSyncpointValue(fence_id)); @@ -1875,6 +1901,7 @@ private: std::chrono::steady_clock::now() - start) .count()); } + return syncpoints.IsReadyGuest(fence_id, fence_value); } // Shows a presented native image: word 5 is the image id, ~word 22 / word 24 the GPU fence diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index 04b05d188f..49352a5b49 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -6,6 +6,7 @@ #include <algorithm> #include <cctype> +#include <chrono> #include <cstring> #include <map> #include <memory> @@ -80,6 +81,24 @@ struct PatchCollection { namespace { +FileSys::VirtualDir FindLooseRomFSDirectory(const FileSys::VirtualDir& parent) { + if (parent == nullptr) { + return nullptr; + } + if (const auto dir = parent->GetSubdirectory("romfs")) { + return dir; + } + for (const auto& dir : parent->GetSubdirectories()) { + const auto name = dir->GetName(); + if (name.size() == 5 && std::equal(name.begin(), name.end(), "romfs", [](char a, char b) { + return std::tolower(static_cast<unsigned char>(a)) == b; + })) { + return dir; + } + } + return nullptr; +} + // RomFS for a loose ExeFS: the first .romfs file next to main, else a romfs/ directory next to // the exefs/ directory (the usual layout of an extracted title). FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { @@ -92,7 +111,7 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { } } if (const auto parent = exefs_dir->GetParentDirectory()) { - if (const auto romfs_dir = parent->GetSubdirectory("romfs")) { + if (const auto romfs_dir = FindLooseRomFSDirectory(parent)) { // Building it walks every file and keeps an object for each (title 1: 206k files, ~150 // MiB), so loaders of the same directory share one build while any of them holds it. static std::mutex cache_mutex; @@ -103,7 +122,12 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { return built; } LOG_INFO(Loader, "Using RomFS directory next to the ExeFS"); + const auto started = std::chrono::steady_clock::now(); auto built = FileSys::CreateRomFS(romfs_dir); + LOG_INFO(Loader, "Loose RomFS image ready: elapsed_ms={}", + std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - started) + .count()); cached = built; return built; } @@ -111,27 +135,19 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { return nullptr; } -// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name -// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario -// Kart World 1.8.0). A hotfix can keep its minor version's tables (Product.110 in a 1.1.1). -std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { - if (romfs_dir == nullptr) { - return {}; - } - const auto system_dir = romfs_dir->GetSubdirectory("System"); - const auto resource_dir = system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr; - if (resource_dir == nullptr) { - return {}; - } - constexpr std::string_view Prefix = "ResourceSizeTable.Product."; +// The newest three-digit tag in a directory's file names of the form <prefix>NNN.<anything>. +std::string NewestProductTag(const FileSys::VirtualDir& dir, std::string_view prefix) { std::string best; - for (const auto& file : resource_dir->GetFiles()) { + if (dir == nullptr) { + return best; + } + for (const auto& file : dir->GetFiles()) { const std::string file_name = file->GetName(); - if (!file_name.starts_with(Prefix) || file_name.size() < Prefix.size() + 4) { + if (!file_name.starts_with(prefix) || file_name.size() < prefix.size() + 4) { continue; } - const std::string_view digits = std::string_view{file_name}.substr(Prefix.size(), 3); - if (file_name[Prefix.size() + 3] != '.' || + const std::string_view digits = std::string_view{file_name}.substr(prefix.size(), 3); + if (file_name[prefix.size() + 3] != '.' || !std::all_of(digits.begin(), digits.end(), [](char c) { return c >= '0' && c <= '9'; })) { continue; } @@ -139,6 +155,27 @@ std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { best = version; } } + return best; +} + +// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name +// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario +// Kart World 1.8.0), and some name their game data list instead (GameData/GameDataList.Product.320.* +// in Donkey Kong Bananza 3.2.0). A hotfix can keep its minor version's tables (Product.110 in a +// 1.1.1). +std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { + if (romfs_dir == nullptr) { + return {}; + } + const auto system_dir = romfs_dir->GetSubdirectory("System"); + std::string best = NewestProductTag( + system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr, + "ResourceSizeTable.Product."); + if (const auto game_data = NewestProductTag(romfs_dir->GetSubdirectory("GameData"), + "GameDataList.Product."); + game_data > best) { + best = game_data; + } if (best.empty()) { return {}; } @@ -249,7 +286,7 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(FileSys romfs_exefs_dir = file_dir; if (const auto title_dir = file_dir->GetParentDirectory()) { - romfs_version = RomFSProductVersion(title_dir->GetSubdirectory("romfs")); + romfs_version = RomFSProductVersion(FindLooseRomFSDirectory(title_dir)); } } diff --git a/src/core/memory.cpp b/src/core/memory.cpp index cfc39916db..1f7315f3c1 100644 --- a/src/core/memory.cpp +++ b/src/core/memory.cpp @@ -549,6 +549,8 @@ struct Memory::Impl { } void RasterizerArmReadTraps(u64 vaddr, u64 size) { + // Clean cached read areas also need invalidation when reactive flushing is enabled. + read_trap_generation.fetch_add(1, std::memory_order_release); if (vaddr == 0 || size == 0 || !AddressSpaceContains(*current_page_table, vaddr, size) || !ReadTrapsEnabled()) { return; @@ -781,6 +783,19 @@ struct Memory::Impl { gpu_device_memory = &system.Host1x().MemoryManager(); } const size_t core = system.GetCurrentHostThreadID(); + if (core >= Core::Hardware::NUM_CPU_CORES) { + // A service thread has no slot in the arrays below, and it does not use a slot of a + // core. It keeps no area, so it asks on each read. + if (static std::atomic_flag logged; !logged.test_and_set(std::memory_order_relaxed)) { + LOG_INFO(HW_Memory, "Read of cached guest memory from a thread that is not a core"); + } + Common::ScratchBuffer<u32> scratch; + gpu_device_memory->ApplyOpOnPointer(p, scratch, [&](DAddr address) { + DisarmReadTraps(v_address, size); + (void)system.GPU().OnCPURead(address, size); + }); + return; + } auto& current_area = rasterizer_read_areas[core]; if (const u64 generation = read_trap_generation.load(std::memory_order_acquire); read_area_generations[core] != generation) { diff --git a/src/input_common/drivers/sdl_driver.cpp b/src/input_common/drivers/sdl_driver.cpp index c3e9a066b3..a308554071 100644 --- a/src/input_common/drivers/sdl_driver.cpp +++ b/src/input_common/drivers/sdl_driver.cpp @@ -3,6 +3,7 @@ // SPDX-FileCopyrightText: 2018 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include <algorithm> #include <array> #include <numbers> #include "common/logging.h" @@ -115,7 +116,15 @@ public: } } - LOG_INFO(Input, "Controller motion capabilities: accel={} gyro={}", has_accel, has_gyro); + // EnableMotion is also called to recover a stalled sensor, so only log when the reported + // capabilities actually change instead of on every restart. + if (!motion_capabilities_logged || logged_accel != has_accel || logged_gyro != has_gyro) { + LOG_INFO(Input, "Controller motion capabilities: accel={} gyro={}", has_accel, + has_gyro); + motion_capabilities_logged = true; + logged_accel = has_accel; + logged_gyro = has_gyro; + } } bool HasMotion() const { @@ -163,16 +172,21 @@ public: // Motion data is invalid if (motion.accel_x == 0 && motion.gyro_x == 0 && motion.accel_y == 0 && motion.gyro_y == 0 && motion.accel_z == 0 && motion.gyro_z == 0) { - if (motion_error_count++ < 200) { + if (motion_error_count++ < motion_restart_threshold) { return false; } - // Try restarting the sensor + // Try restarting the sensor. Back off exponentially so a sensor that keeps reporting + // zeros (observed on the Steam Deck) isn't toggled several times a + // second for the whole session. motion_error_count = 0; + motion_restart_threshold = + std::min(motion_restart_threshold * 2, MaxMotionRestartThreshold); EnableMotion(); return false; } motion_error_count = 0; + motion_restart_threshold = InitialMotionRestartThreshold; motion.delta_timestamp = time_difference; return true; } @@ -393,7 +407,13 @@ private: mutable std::mutex mutex; u64 last_motion_update{}; + static constexpr std::size_t InitialMotionRestartThreshold = 200; + static constexpr std::size_t MaxMotionRestartThreshold = 200 * 64; std::size_t motion_error_count{}; + std::size_t motion_restart_threshold{InitialMotionRestartThreshold}; + bool motion_capabilities_logged{false}; + bool logged_accel{false}; + bool logged_gyro{false}; bool has_gyro{false}; bool has_accel{false}; bool has_vibration{false}; diff --git a/src/input_common/drivers/udp_client.cpp b/src/input_common/drivers/udp_client.cpp index 620e0cf630..a7840db32f 100644 --- a/src/input_common/drivers/udp_client.cpp +++ b/src/input_common/drivers/udp_client.cpp @@ -603,7 +603,7 @@ CalibrationConfigurationJob::CalibrationConfigurationJob( const std::string& host, u16 port, std::function<void(Status)> status_callback, std::function<void(u16, u16, u16, u16)> data_callback) { - std::thread([=, this] { + worker = std::jthread([=, this] { u16 min_x{UINT16_MAX}; u16 min_y{UINT16_MAX}; u16 max_x{}; @@ -647,7 +647,7 @@ CalibrationConfigurationJob::CalibrationConfigurationJob( complete_event.Wait(); socket.Stop(); worker_thread.join(); - }).detach(); + }); } CalibrationConfigurationJob::~CalibrationConfigurationJob() { diff --git a/src/input_common/drivers/udp_client.h b/src/input_common/drivers/udp_client.h index cea9f579ac..eac3df07aa 100644 --- a/src/input_common/drivers/udp_client.h +++ b/src/input_common/drivers/udp_client.h @@ -183,6 +183,7 @@ public: private: Common::Event complete_event; + std::jthread worker; }; void TestCommunication(const std::string& host, u16 port, diff --git a/src/qt_common/config/uisettings.cpp b/src/qt_common/config/uisettings.cpp index 6f1e7df3d5..8d593b8d9e 100644 --- a/src/qt_common/config/uisettings.cpp +++ b/src/qt_common/config/uisettings.cpp @@ -30,7 +30,7 @@ namespace UISettings { // This must be in alphabetical order according to action name as it must have the same order as // UISetting::values.shortcuts, which is alphabetically ordered. // clang-format off -const std::array<Shortcut, 33> default_hotkeys{{ +const std::array<Shortcut, 32> default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+M"), std::string("Home+Dpad_Right"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("-"), std::string("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("="), std::string("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, @@ -45,7 +45,6 @@ const std::array<Shortcut, 33> default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Exit Eglantine")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+Q"), std::string("Home+Minus"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Fullscreen")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F11"), std::string("Home+B"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load File")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+O"), std::string(""), Qt::WidgetWithChildrenShortcut, false}}, - {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Load/Remove Amiibo")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("F2"), std::string("Home+A"), Qt::WidgetWithChildrenShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Browse Public Game Lobby")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+B"), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Create Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+N"), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Direct Connect to Room")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+C"), std::string(""), Qt::ApplicationShortcut, false}}, diff --git a/src/qt_common/config/uisettings.h b/src/qt_common/config/uisettings.h index a790cfcc22..89185061cc 100644 --- a/src/qt_common/config/uisettings.h +++ b/src/qt_common/config/uisettings.h @@ -244,7 +244,7 @@ void SaveWindowState(); void RestoreWindowState(std::unique_ptr<QtConfig>& qtConfig); // sync with uisettings.cpp -extern const std::array<Shortcut, 33> default_hotkeys; +extern const std::array<Shortcut, 32> default_hotkeys; } // namespace UISettings diff --git a/src/qt_common/game_list/discovery.h b/src/qt_common/game_list/discovery.h new file mode 100644 index 0000000000..8d2054d308 --- /dev/null +++ b/src/qt_common/game_list/discovery.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <algorithm> +#include <cctype> +#include <filesystem> +#include <unordered_set> +#include <utility> +#include <vector> + +namespace GameListDiscovery { + +struct Result { + std::vector<std::filesystem::path> files; + std::vector<std::filesystem::path> directories; + std::size_t skipped_romfs{}; + std::size_t errors{}; +}; + +inline bool IsExeFS(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path / "main", ec) && + std::filesystem::is_regular_file(path / "main.npdm", ec); +} + +template <typename Supported, typename Stopped> +Result Discover(const std::filesystem::path& root, bool deep, Supported supported, + Stopped stopped) { + namespace fs = std::filesystem; + Result result; + std::unordered_set<fs::path> files; + std::unordered_set<fs::path> directories; + const auto add_file = [&](const fs::path& path) { + if (files.insert(path).second) { + result.files.push_back(path); + } + }; + const auto watch = [&](const fs::path& path) { + if (directories.insert(path).second) { + result.directories.push_back(path); + } + }; + const auto probe = [&](const fs::path& path) { + if (IsExeFS(path)) { + watch(path); + add_file(path / "main"); + } + }; + const auto probe_layouts = [&](const fs::path& path) { + for (const auto& content : {path, path / "base"}) { + probe(content / "exefs"); + const auto updates = content / "updates"; + std::error_code ec; + if (!fs::is_directory(updates, ec)) { + continue; + } + watch(updates); + for (fs::directory_iterator it{updates, fs::directory_options::skip_permission_denied, + ec}; + !ec && it != fs::directory_iterator{} && !stopped(); it.increment(ec)) { + probe(it->path() / "exefs"); + } + } + }; + + std::vector<std::pair<fs::path, unsigned>> pending{{root, 0}}; + while (!pending.empty() && !stopped()) { + auto [directory, depth] = std::move(pending.back()); + pending.pop_back(); + watch(directory); + if (IsExeFS(directory)) { + add_file(directory / "main"); + continue; + } + if (!deep) { + probe_layouts(directory); + } + std::error_code ec; + for (fs::directory_iterator it{directory, fs::directory_options::skip_permission_denied, + ec}; + !ec && it != fs::directory_iterator{} && !stopped(); it.increment(ec)) { + const auto& entry = *it; + std::error_code entry_ec; + if (entry.is_directory(entry_ec)) { + if (entry.is_symlink(entry_ec)) { + continue; + } + auto name = entry.path().filename().u8string(); + std::transform(name.begin(), name.end(), name.begin(), [](char8_t c) { + return static_cast<char8_t>(std::tolower(static_cast<unsigned char>(c))); + }); + if (name == u8"romfs" && IsExeFS(directory / "exefs")) { + ++result.skipped_romfs; + continue; + } + if (deep || depth == 0) { + pending.emplace_back(entry.path(), depth + 1); + } + } else if (entry.is_regular_file(entry_ec) && depth == 0 && !deep) { + if (supported(entry.path())) { + add_file(entry.path()); + } + } else if (entry.is_regular_file(entry_ec) && deep && supported(entry.path())) { + add_file(entry.path()); + } + } + result.errors += ec ? 1 : 0; + } + return result; +} + +} // namespace GameListDiscovery diff --git a/src/qt_common/game_list/model.cpp b/src/qt_common/game_list/model.cpp index 4f3916b04d..23d8ec1f43 100644 --- a/src/qt_common/game_list/model.cpp +++ b/src/qt_common/game_list/model.cpp @@ -44,9 +44,21 @@ GameListModel::GameListModel(std::shared_ptr<FileSys::VfsFilesystem> vfs_, setSortRole(GameListItemPath::SortRole); } -GameListModel::~GameListModel() = default; +GameListModel::~GameListModel() { + StopWorker(); +} + +void GameListModel::SetScanEnabled(bool enabled) { + scan_enabled = enabled; + if (!enabled) { + StopWorker(); + } +} void GameListModel::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { + if (!scan_enabled || system.IsPoweredOn()) { + return; + } emit PopulatingStarted(); StopWorker(); @@ -62,11 +74,14 @@ void GameListModel::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { } void GameListModel::WorkerEvent() { - current_worker->ProcessEvents(this); + if (current_worker) { + current_worker->ProcessEvents(this); + } } void GameListModel::AddDirEntry(GameListDir* entry_items) { if (m_flat) { + delete entry_items; return; } invisibleRootItem()->appendRow(entry_items); @@ -172,7 +187,7 @@ void GameListModel::StopWorker() { void GameListModel::Repopulate() { // CreateFactories replaces the content providers a running game reads from. Stopping // emulation repopulates the list anyway, so skip watcher-driven reloads until then. - if (QtCommon::system->IsPoweredOn()) { + if (!scan_enabled || QtCommon::system->IsPoweredOn()) { LOG_INFO(Frontend, "Emulation is running, deferring game list reload."); return; } diff --git a/src/qt_common/game_list/model.h b/src/qt_common/game_list/model.h index b850895d2f..e664623640 100644 --- a/src/qt_common/game_list/model.h +++ b/src/qt_common/game_list/model.h @@ -51,6 +51,7 @@ public: void PopulateAsync(QVector<UISettings::GameDir>& game_dirs); void WorkerEvent(); + void SetScanEnabled(bool enabled); bool IsEmpty() const; @@ -81,6 +82,7 @@ private: void StopWorker(); bool m_flat = false; + bool scan_enabled = true; std::shared_ptr<FileSys::VfsFilesystem> vfs; FileSys::ManualContentProvider* provider; diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp index 48cfe523b3..bd3d81dac4 100644 --- a/src/qt_common/game_list/worker.cpp +++ b/src/qt_common/game_list/worker.cpp @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include <algorithm> +#include <chrono> #include <filesystem> #include <memory> #include <string> @@ -36,10 +37,23 @@ #include "qt_common/game_list/game_list_p.h" #include "qt_common/game_list/model.h" +#include "qt_common/game_list/discovery.h" #include "qt_common/game_list/worker.h" namespace { +struct PendingRow { + explicit PendingRow(QList<QStandardItem*> row) : items{std::move(row)} {} + PendingRow(const PendingRow&) = delete; + QList<QStandardItem*> items; + ~PendingRow() { + qDeleteAll(items); + } + QList<QStandardItem*> Release() { + return std::exchange(items, {}); + } +}; + QString GetGameListCachedObject(const std::string& filename, const std::string& ext, const std::function<QString()>& generator) { if (!UISettings::values.cache_game_list || filename == "0000000000000000") { @@ -212,36 +226,6 @@ QString LooseExeFSName(const std::string& physical_name) { return name; } -// A loose dump's game list entry is its small main executable, so the size column would only -// show a few dozen MB. Sum the dump's real contents instead: the ExeFS directory plus the -// RomFS directory next to it, matching the loader's loose RomFS discovery. -u64 LooseDumpSize(const std::filesystem::path& main_path) { - const auto exefs_dir = main_path.parent_path(); - - u64 total{}; - std::error_code ec; - const auto accumulate = [&total, &ec](const std::filesystem::path& dir) { - for (std::filesystem::recursive_directory_iterator it{ - dir, std::filesystem::directory_options::skip_permission_denied, ec}; - !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { - std::error_code entry_ec; - if (it->is_regular_file(entry_ec)) { - const auto size = it->file_size(entry_ec); - if (!entry_ec) { - total += size; - } - } - } - }; - - accumulate(exefs_dir); - const auto romfs_dir = exefs_dir.parent_path() / "romfs"; - if (std::filesystem::is_directory(romfs_dir, ec)) { - accumulate(romfs_dir); - } - return total; -} - QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, Loader::AppLoader& loader, bool updatable = true) { QString out; @@ -359,7 +343,7 @@ void GameListWorker::RecordEvent(F&& func) { emit DataAvailable(); } -void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { +void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir, GameListItemType parent_type) { using namespace FileSys; const auto& cache = system.GetContentProviderUnion(); @@ -367,13 +351,13 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application, ContentRecordType::Program); - if (parent_dir->type() == static_cast<int>(GameListItemType::SdmcDir)) { + if (parent_type == GameListItemType::SdmcDir) { installed_games = cache.ListEntriesFilterOrigin( ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program); - } else if (parent_dir->type() == static_cast<int>(GameListItemType::UserNandDir)) { + } else if (parent_type == GameListItemType::UserNandDir) { installed_games = cache.ListEntriesFilterOrigin( ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program); - } else if (parent_dir->type() == static_cast<int>(GameListItemType::SysNandDir)) { + } else if (parent_type == GameListItemType::SysNandDir) { installed_games = cache.ListEntriesFilterOrigin( ContentProviderUnionSlot::SysNAND, TitleType::Application, ContentRecordType::Program); } @@ -408,11 +392,12 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { auto entry = MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, program_id, play_time_manager, patch); - RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + auto pending = std::make_shared<PendingRow>(std::move(entry)); + RecordEvent([=](GameListModel* model) { model->AddEntry(pending->Release(), parent_dir); }); } } -void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, +void GameListWorker::ScanFileSystem(ScanTarget target, const std::vector<std::filesystem::path>& files, GameListDir* parent_dir) { const auto process_candidate = [this, target, parent_dir](const std::filesystem::path& path) { @@ -459,7 +444,7 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa std::vector<u64> program_ids; loader->ReadProgramIds(program_ids); - const auto addEntry = [this, &path, physical_name, + const auto addEntry = [this, physical_name, parent_dir](std::unique_ptr<Loader::AppLoader>& app_loader, const u64 id) { std::vector<u8> icon; @@ -477,14 +462,21 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa const FileSys::PatchManager patch{id, system.GetFileSystemController(), system.GetContentProvider()}; - const u64 size = IsExtractedNCAMain(physical_name) - ? LooseDumpSize(path) - : Common::FS::GetSize(physical_name); + const bool loose_dump = IsExtractedNCAMain(physical_name); + const u64 size = loose_dump ? 0 : Common::FS::GetSize(physical_name); auto entry = MakeGameListEntry(physical_name, name, size, icon, *app_loader, id, play_time_manager, patch); - RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + if (loose_dump) { + entry[GameListModel::COLUMN_SIZE]->setText(tr("Not scanned")); + entry[GameListModel::COLUMN_SIZE]->setToolTip( + tr("Folder size is not calculated during game discovery.")); + } + auto pending = std::make_shared<PendingRow>(std::move(entry)); + RecordEvent([=](GameListModel* model) { + model->AddEntry(pending->Release(), parent_dir); + }); }; if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && @@ -509,67 +501,27 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa } }; - const auto callback = [this, target, deep_scan, - &process_candidate](const std::filesystem::path& path) -> bool { + for (const auto& path : files) { if (stop_requested) { - // Breaks the callback loop. - return false; - } - - if (Common::FS::IsDir(path)) { - watch_list.append(QString::fromStdString(Common::FS::PathToUTF8String(path))); - - // Shallow scans stop at the first level, so probe the standard layouts of loose - // ExeFS dumps: <game>/exefs/main, <game>/base/exefs/main and their updates in - // <root>/updates/<title id>/exefs/main. - if (!deep_scan && target == ScanTarget::PopulateGameList) { - for (const auto& content_root : {path, path / "base"}) { - const auto exefs_main = content_root / "exefs" / "main"; - if (Common::FS::IsFile(exefs_main)) { - process_candidate(exefs_main); - } - - const auto updates = content_root / "updates"; - if (Common::FS::IsDir(updates)) { - Common::FS::IterateDirEntries( - updates, - [&process_candidate](const std::filesystem::path& entry) { - const auto update_main = entry / "exefs" / "main"; - if (Common::FS::IsFile(update_main)) { - process_candidate(update_main); - } - return true; - }, - Common::FS::DirEntryFilter::Directory); - } - } - } - - return true; + break; } - - const auto physical_name = Common::FS::PathToUTF8String(path); - if (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name)) { - process_candidate(path); + // Loose executables cannot contribute NCA content to the manual provider. + if (target == ScanTarget::FillManualContentProvider && + IsExtractedNCAMain(Common::FS::PathToUTF8String(path))) { + continue; } - - return true; - }; - - if (deep_scan) { - Common::FS::IterateDirEntriesRecursively(dir_path, callback, - Common::FS::DirEntryFilter::All); - } else { - Common::FS::IterateDirEntries(dir_path, callback, Common::FS::DirEntryFilter::All); + process_candidate(path); } } void GameListWorker::run() { + const auto started = std::chrono::steady_clock::now(); watch_list.clear(); provider->ClearAllEntries(); const auto DirEntryReady = [&](GameListDir* game_list_dir) { - RecordEvent([=](GameListModel* model) { model->AddDirEntry(game_list_dir); }); + auto pending = std::make_shared<std::unique_ptr<GameListDir>>(game_list_dir); + RecordEvent([=](GameListModel* model) { model->AddDirEntry(pending->release()); }); }; for (UISettings::GameDir& game_dir : game_dirs) { @@ -596,17 +548,35 @@ void GameListWorker::run() { scan = true; } + const auto parent_type = static_cast<GameListItemType>(game_list_dir->type()); DirEntryReady(game_list_dir); if (scan) { - ScanFileSystem(ScanTarget::FillManualContentProvider, game_dir.path, game_dir.deep_scan, - game_list_dir); - ScanFileSystem(ScanTarget::PopulateGameList, game_dir.path, game_dir.deep_scan, - game_list_dir); + const auto discovery = GameListDiscovery::Discover( + std::filesystem::u8path(game_dir.path), game_dir.deep_scan, + [](const auto& path) { + const auto name = Common::FS::PathToUTF8String(path); + return HasSupportedFileExtension(name) || IsExtractedNCAMain(name); + }, + [this] { return stop_requested.load(); }); + for (const auto& directory : discovery.directories) { + watch_list.append(QString::fromStdString(Common::FS::PathToUTF8String(directory))); + } + LOG_INFO(Frontend, + "Discovery: candidates={} directories={} skipped_romfs={} errors={} cancelled={}", + discovery.files.size(), discovery.directories.size(), discovery.skipped_romfs, + discovery.errors, stop_requested.load()); + ScanFileSystem(ScanTarget::FillManualContentProvider, discovery.files, game_list_dir); + ScanFileSystem(ScanTarget::PopulateGameList, discovery.files, game_list_dir); } else { - AddTitlesToGameList(game_list_dir); + AddTitlesToGameList(game_list_dir, parent_type); } } + watch_list.removeDuplicates(); + LOG_INFO(Frontend, "Game discovery finished: elapsed_ms={} watched={} cancelled={}", + std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - started).count(), + watch_list.size(), stop_requested.load()); RecordEvent([this](GameListModel* model) { model->DonePopulating(watch_list); }); processing_completed.Set(); } diff --git a/src/qt_common/game_list/worker.h b/src/qt_common/game_list/worker.h index cbc1fa7021..b627d9eb94 100644 --- a/src/qt_common/game_list/worker.h +++ b/src/qt_common/game_list/worker.h @@ -8,8 +8,10 @@ #include <atomic> #include <deque> +#include <filesystem> #include <memory> #include <string> +#include <vector> #include <QList> #include <QObject> @@ -28,6 +30,7 @@ class System; class GameListDir; class GameListModel; class QStandardItem; +enum class GameListItemType; namespace FileSys { class NCA; @@ -73,14 +76,14 @@ private: void RecordEvent(F&& func); private: - void AddTitlesToGameList(GameListDir* parent_dir); + void AddTitlesToGameList(GameListDir* parent_dir, GameListItemType parent_type); enum class ScanTarget { FillManualContentProvider, PopulateGameList, }; - void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + void ScanFileSystem(ScanTarget target, const std::vector<std::filesystem::path>& files, GameListDir* parent_dir); std::shared_ptr<FileSys::VfsFilesystem> vfs; diff --git a/src/qt_common/util/game.cpp b/src/qt_common/util/game.cpp index ddbe979765..ba669c3acf 100644 --- a/src/qt_common/util/game.cpp +++ b/src/qt_common/util/game.cpp @@ -430,8 +430,10 @@ void CreateShortcut(const std::string& game_path, const u64 program_id, game_title = fmt::format("{:016X}", program_id); if (control.first != nullptr) { game_title = control.first->GetApplicationName(); - } else { + } else if (loader != nullptr) { loader->ReadTitle(game_title); + } else { + LOG_WARNING(Frontend, "Could not load game title from {:s}", game_path); } } @@ -448,7 +450,8 @@ void CreateShortcut(const std::string& game_path, const u64 program_id, std::vector<u8> icon_image_file{}; if (control.second != nullptr) { icon_image_file = control.second->ReadAllBytes(); - } else if (loader->ReadIcon(icon_image_file) != Loader::ResultStatus::Success) { + } else if (loader == nullptr || + loader->ReadIcon(icon_image_file) != Loader::ResultStatus::Success) { LOG_WARNING(Frontend, "Could not read icon from {:s}", game_path); } @@ -535,6 +538,10 @@ void CreateHomeMenuShortcut(ShortcutTarget target) { } bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image) { + if (image.isNull()) { + return false; + } + #if defined(WIN32) #pragma pack(push, 2) struct IconDir { diff --git a/src/shader_recompiler/ir_opt/texture_pass.cpp b/src/shader_recompiler/ir_opt/texture_pass.cpp index 8a72f565dc..b6aca0a118 100644 --- a/src/shader_recompiler/ir_opt/texture_pass.cpp +++ b/src/shader_recompiler/ir_opt/texture_pass.cpp @@ -16,6 +16,7 @@ #include <limits> #include <boost/container/small_vector.hpp> +#include "common/scope_exit.h" #include "common/settings.h" #include "shader_recompiler/environment.h" #include "shader_recompiler/frontend/ir/basic_block.h" @@ -345,7 +346,8 @@ static inline u32 GetTextureHandleCached(Environment& env, const ConstBufferAddr // Cached variants of existing helpers // A handle computed per draw has no descriptor to read at translation time: assume an ordinary -// floating-point 2D texture, the instruction's own type deciding the rest. +// floating-point 2D texture, the instruction's own type deciding the rest (see the TexturePass +// ImageQueryDimensions case, which has none). static inline TextureType ReadTextureTypeCached(Environment& env, const ConstBufferAddr& cbuf) { if (cbuf.handle_program != ~0U) return TextureType::Color2D; return env.ReadTextureType(GetTextureHandleCached(env, cbuf)); @@ -436,9 +438,10 @@ bool IsUndefined(IR::Opcode opcode) { opcode == IR::Opcode::UndefU64; } -// Values already emitted by the program being built, by the slot that keeps them, and how deep -// in nested BuildHandleProgram calls the build is. +// Values already emitted by the program being built, by the slot that keeps them, how many slots +// are in use, and how deep in nested BuildHandleProgram calls the build is. thread_local std::unordered_map<const IR::Inst*, u32> g_handle_slots; +thread_local u32 g_handle_slot_count{}; thread_local int g_handle_depth{}; bool BuildHandleNode(const IR::Value& value, std::vector<u32>& code, int& budget, @@ -453,7 +456,15 @@ bool BuildHandleProgram(const IR::Value& value, std::vector<u32>& code, int& bud const bool outermost = g_handle_depth++ == 0; if (outermost) { g_handle_slots.clear(); + g_handle_slot_count = 0; } + SCOPE_EXIT { + --g_handle_depth; + if (outermost) { + g_handle_slots.clear(); + g_handle_slot_count = 0; + } + }; const IR::Inst* const inst{value.InstRecursive()}; bool result; if (const auto it = g_handle_slots.find(inst); it != g_handle_slots.end()) { @@ -470,23 +481,25 @@ bool BuildHandleProgram(const IR::Value& value, std::vector<u32>& code, int& bud } else { g_handle_slots.emplace(inst, ~0U); const size_t start = code.size(); + const u32 first_slot = g_handle_slot_count; result = BuildHandleNode(value, code, budget, block); g_handle_slots.erase(inst); if (!result) { + // The Tee of every value the dropped code kept goes with it: a later use of one of + // them (another Phi input) must build it again, not load a slot nothing wrote. code.resize(start); + std::erase_if(g_handle_slots, [first_slot](const auto& entry) { + return entry.second != ~0U && entry.second >= first_slot; + }); + g_handle_slot_count = first_slot; } if (result && inst->UseCount() > 1 && g_handle_slots.size() < 256) { - const u32 slot = static_cast<u32>( - std::ranges::count_if(g_handle_slots, [](const auto& entry) { return entry.second != ~0U; })); + const u32 slot = g_handle_slot_count++; g_handle_slots.emplace(inst, slot); code.push_back(static_cast<u32>(Op::Tee)); code.push_back(slot); } } - --g_handle_depth; - if (outermost) { - g_handle_slots.clear(); - } return result; } @@ -1010,7 +1023,8 @@ public: desc.secondary_cbuf_offset == existing.secondary_cbuf_offset && desc.secondary_shift_left == existing.secondary_shift_left && desc.count == existing.count && desc.size_shift == existing.size_shift && - desc.has_secondary == existing.has_secondary; + desc.has_secondary == existing.has_secondary && + desc.handle_program == existing.handle_program; }); } @@ -1018,7 +1032,8 @@ public: const u32 index{Add(image_buffer_descriptors, desc, [&desc](const auto& existing) { return desc.format == existing.format && desc.cbuf_index == existing.cbuf_index && desc.cbuf_offset == existing.cbuf_offset && desc.count == existing.count && - desc.size_shift == existing.size_shift; + desc.size_shift == existing.size_shift && + desc.handle_program == existing.handle_program; })}; image_buffer_descriptors[index].is_written |= desc.is_written; image_buffer_descriptors[index].is_read |= desc.is_read; @@ -1036,7 +1051,8 @@ public: desc.secondary_cbuf_index == existing.secondary_cbuf_index && desc.secondary_cbuf_offset == existing.secondary_cbuf_offset && desc.secondary_shift_left == existing.secondary_shift_left && - desc.count == existing.count && desc.size_shift == existing.size_shift; + desc.count == existing.count && desc.size_shift == existing.size_shift && + desc.handle_program == existing.handle_program; })}; // TODO: Read this from TIC texture_descriptors[index].is_multisample |= desc.is_multisample; @@ -1049,7 +1065,8 @@ public: return desc.type == existing.type && desc.format == existing.format && desc.cbuf_index == existing.cbuf_index && desc.cbuf_offset == existing.cbuf_offset && desc.count == existing.count && - desc.size_shift == existing.size_shift; + desc.size_shift == existing.size_shift && + desc.handle_program == existing.handle_program; })}; image_descriptors[index].is_written |= desc.is_written; image_descriptors[index].is_read |= desc.is_read; @@ -1171,6 +1188,10 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo u32 last_cbuf_offset{}; bool last_is_integer{false}; const auto is_texture_pixel_format_integer{[&](const ConstBufferAddr& cbuf_addr) { + if (cbuf_addr.handle_program != ~0U) { + // Every handle program is at cbuf 0 offset 0: the cache below can't tell them apart. + return IsTexturePixelFormatIntegerCached(env, cbuf_addr); + } if (has_last_is_integer && last_cbuf_index == cbuf_addr.index && last_cbuf_offset == cbuf_addr.offset) { return last_is_integer; @@ -1181,6 +1202,18 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo has_last_is_integer = true; return last_is_integer; }}; + // A handle program has no descriptor to read a type from, and ImageQueryDimensions carries + // none: it takes the type of another instruction using the same handle. + std::unordered_map<u32, TextureType> handle_program_types; + for (const TextureInst& texture_inst : to_replace) { + const IR::Opcode opcode{IndexedInstruction(*texture_inst.inst)}; + if (texture_inst.cbuf.handle_program != ~0U && opcode != IR::Opcode::ImageQueryDimensions && + !IsStorageImageOpcode(opcode)) { + handle_program_types.try_emplace( + texture_inst.cbuf.handle_program, + texture_inst.inst->Flags<IR::TextureInstInfo>().type.Value()); + } + } for (TextureInst& texture_inst : to_replace) { // TODO: Handle arrays IR::Inst* const inst{texture_inst.inst}; @@ -1191,7 +1224,12 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo bool is_multisample{false}; switch (inst->GetOpcode()) { case IR::Opcode::ImageQueryDimensions: - flags.type.Assign(ReadTextureTypeCached(env, cbuf)); + if (const auto it{handle_program_types.find(cbuf.handle_program)}; + it != handle_program_types.end()) { + flags.type.Assign(it->second); + } else { + flags.type.Assign(ReadTextureTypeCached(env, cbuf)); + } inst->SetFlags(flags); break; case IR::Opcode::ImageSampleImplicitLod: @@ -1256,6 +1294,7 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo .cbuf_offset = cbuf.offset, .count = count, .size_shift = size_shift, + .handle_program = cbuf.handle_program, }); } else { index = descriptors.Add(ImageDescriptor{ @@ -1268,6 +1307,7 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo .cbuf_offset = cbuf.offset, .count = count, .size_shift = size_shift, + .handle_program = cbuf.handle_program, }); } break; @@ -1284,6 +1324,7 @@ void TexturePass(Environment& env, IR::Program& program, const HostTranslateInfo .secondary_shift_left = cbuf.secondary_shift_left, .count = count, .size_shift = size_shift, + .handle_program = cbuf.handle_program, }); } else { count = std::min(count, sampled_dynamic_cap); diff --git a/src/shader_recompiler/shader_info.h b/src/shader_recompiler/shader_info.h index eeb8d768d7..e2b341b0c3 100644 --- a/src/shader_recompiler/shader_info.h +++ b/src/shader_recompiler/shader_info.h @@ -189,6 +189,9 @@ struct TextureBufferDescriptor { u32 secondary_shift_left; u32 count; u32 size_shift; + /// Index in Info::handle_programs when the handle is computed at draw time (read from a + /// storage buffer), ~0 when it is the constant buffer word. + u32 handle_program{~0U}; auto operator<=>(const TextureBufferDescriptor&) const = default; }; @@ -203,6 +206,9 @@ struct ImageBufferDescriptor { u32 cbuf_offset; u32 count; u32 size_shift; + /// Index in Info::handle_programs when the handle is computed at draw time (read from a + /// storage buffer), ~0 when it is the constant buffer word. + u32 handle_program{~0U}; auto operator<=>(const ImageBufferDescriptor&) const = default; }; @@ -240,6 +246,9 @@ struct ImageDescriptor { u32 cbuf_offset; u32 count; u32 size_shift; + /// Index in Info::handle_programs when the handle is computed at draw time (read from a + /// storage buffer), ~0 when it is the constant buffer word. + u32 handle_program{~0U}; auto operator<=>(const ImageDescriptor&) const = default; }; diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index a9be2ec7a8..1eb1040e34 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -9,19 +9,26 @@ add_executable(tests common/cityhash.cpp common/container_hash.cpp common/fibers.cpp + common/game_presets.cpp common/host_memory.cpp common/param_package.cpp common/range_map.cpp common/ring_buffer.cpp common/scratch_buffer.cpp + common/slot_vector.cpp common/undefined_fix.cpp common/unique_function.cpp core/core_timing.cpp core/nup_writer.cpp + core/priority_queue.cpp core/internal_network/network.cpp video_core/memory_tracker.cpp video_core/vertex_repack.cpp video_core/ampere_returns.cpp + video_core/native_macro_fifo.cpp + video_core/uniform_stream_reuse.cpp + video_core/shader_invalidation.cpp + video_core/descriptor_table.cpp input_common/calibration_configuration_job.cpp ) diff --git a/src/tests/common/game_presets.cpp b/src/tests/common/game_presets.cpp new file mode 100644 index 0000000000..481f5c5e1d --- /dev/null +++ b/src/tests/common/game_presets.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <memory> +#include <catch2/catch_test_macros.hpp> +#include "common/game_presets.h" + +namespace { +void SetConflictingPreferences(Settings::Values& settings, bool per_game) { + const auto set = [per_game](auto& setting, auto value) { + setting.SetGlobal(!per_game); + setting.SetValue(value); + }; + set(settings.renderer_backend, Settings::RendererBackend::Null); + set(settings.resolution_setup, Settings::ResolutionSetup::Res2X); + set(settings.gpu_accuracy, Settings::GpuAccuracy::High); + set(settings.use_asynchronous_shaders, false); + set(settings.astc_recompression, Settings::AstcRecompression::Uncompressed); + set(settings.vram_usage_mode, Settings::VramUsageMode::Aggressive); + set(settings.use_reactive_flushing, true); + set(settings.vsync_mode, Settings::VSyncMode::Immediate); + set(settings.scaling_filter, Settings::ScalingFilter::NearestNeighbor); + set(settings.anti_aliasing, Settings::AntiAliasing::Smaa); +} + +void CheckPreset(const Settings::Values& settings) { + CHECK(settings.renderer_backend.GetValue() == Settings::RendererBackend::Vulkan); + CHECK(settings.resolution_setup.GetValue() == Settings::ResolutionSetup::Res1X); + CHECK(settings.gpu_accuracy.GetValue() == Settings::GpuAccuracy::Low); + CHECK(settings.use_asynchronous_shaders.GetValue()); + CHECK(settings.astc_recompression.GetValue() == Settings::AstcRecompression::Bc3); + CHECK(settings.vram_usage_mode.GetValue() == Settings::VramUsageMode::Conservative); + CHECK_FALSE(settings.use_reactive_flushing.GetValue()); + CHECK(settings.vsync_mode.GetValue() == Settings::VSyncMode::FifoRelaxed); + CHECK(settings.use_docked_mode.GetValue() == Settings::ConsoleMode::Handheld); + CHECK(settings.scaling_filter.GetValue() == Settings::ScalingFilter::ScaleForce); + CHECK(settings.anti_aliasing.GetValue() == Settings::AntiAliasing::Fxaa); +} + +void CheckPreferences(const Settings::Values& settings, bool global = false) { + CHECK(settings.renderer_backend.GetValue(global) == Settings::RendererBackend::Null); + CHECK(settings.resolution_setup.GetValue(global) == Settings::ResolutionSetup::Res2X); + CHECK(settings.gpu_accuracy.GetValue(global) == Settings::GpuAccuracy::High); + CHECK_FALSE(settings.use_asynchronous_shaders.GetValue(global)); + CHECK(settings.astc_recompression.GetValue(global) == + Settings::AstcRecompression::Uncompressed); + CHECK(settings.vram_usage_mode.GetValue(global) == Settings::VramUsageMode::Aggressive); + CHECK(settings.use_reactive_flushing.GetValue(global)); + CHECK(settings.vsync_mode.GetValue(global) == Settings::VSyncMode::Immediate); + CHECK(settings.scaling_filter.GetValue(global) == Settings::ScalingFilter::NearestNeighbor); + CHECK(settings.anti_aliasing.GetValue(global) == Settings::AntiAliasing::Smaa); +} +} // namespace + +TEST_CASE("Display filter defaults use ScaleForce and FXAA", "[game_presets]") { + auto settings = std::make_unique<Settings::Values>(); + CHECK(settings->scaling_filter.GetDefault() == Settings::ScalingFilter::ScaleForce); + CHECK(settings->anti_aliasing.GetDefault() == Settings::AntiAliasing::Fxaa); +} + +TEST_CASE("Accepted MKW settings replace visible global preferences", "[game_presets]") { + auto settings = std::make_unique<Settings::Values>(); + SetConflictingPreferences(*settings, false); + Settings::SetMarioKartWorldRecommendedSettings(*settings, true); + CheckPreset(*settings); + CHECK(settings->renderer_backend.UsingGlobal()); + CHECK(settings->gpu_accuracy.UsingGlobal()); + CHECK(settings->renderer_backend.GetValue(true) == Settings::RendererBackend::Vulkan); + CHECK(settings->gpu_accuracy.GetValue(true) == Settings::GpuAccuracy::Low); +} + +TEST_CASE("MKW preset overrides saved settings for the session", "[game_presets]") { + auto settings = std::make_unique<Settings::Values>(); + SetConflictingPreferences(*settings, false); + SetConflictingPreferences(*settings, true); + REQUIRE( + Settings::ApplyMarioKartWorldPreset(*settings, Settings::MarioKartWorldProgramId, true)); + CheckPreset(*settings); + CheckPreferences(*settings, true); + for (const auto& restore : settings->linkage.restore_functions) { + restore(); + } + CheckPreferences(*settings); + CHECK(settings->renderer_backend.UsingGlobal()); + CHECK(settings->resolution_setup.UsingGlobal()); + REQUIRE( + Settings::ApplyMarioKartWorldPreset(*settings, Settings::MarioKartWorldProgramId, true)); + CheckPreset(*settings); +} + +TEST_CASE("MKW preset does not change unrelated games", "[game_presets]") { + auto settings = std::make_unique<Settings::Values>(); + SetConflictingPreferences(*settings, false); + CHECK_FALSE(Settings::ApplyMarioKartWorldPreset(*settings, 0, true)); + CHECK_FALSE(Settings::ApplyMarioKartWorldPreset(*settings, + Settings::MarioKartWorldProgramId + 1, true)); + CheckPreferences(*settings); + CHECK(settings->renderer_backend.UsingGlobal()); + CHECK(settings->resolution_setup.UsingGlobal()); +} + +TEST_CASE("Declining the MKW preset preserves global and per-game preferences", "[game_presets]") { + auto settings = std::make_unique<Settings::Values>(); + for (bool per_game : {false, true}) { + SetConflictingPreferences(*settings, per_game); + CHECK_FALSE(Settings::ApplyMarioKartWorldPreset(*settings, + Settings::MarioKartWorldProgramId, false)); + CheckPreferences(*settings); + CHECK(settings->renderer_backend.UsingGlobal() == !per_game); + CHECK(settings->resolution_setup.UsingGlobal() == !per_game); + } +} diff --git a/src/tests/common/slot_vector.cpp b/src/tests/common/slot_vector.cpp new file mode 100644 index 0000000000..d8f242085c --- /dev/null +++ b/src/tests/common/slot_vector.cpp @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <map> +#include <random> +#include <vector> +#include <catch2/catch_test_macros.hpp> +#include "common/slot_vector.h" + +TEST_CASE("SlotVector visits live entries across empty bitmap words", "[common]") { + Common::SlotVector<u32> slots; + std::vector<Common::SlotId> ids; + for (u32 i = 0; i < 513; ++i) { + ids.push_back(slots.insert(i)); + } + for (u32 i = 0; i < 512; ++i) { + slots.erase(ids[i]); + } + auto it = slots.begin(); + REQUIRE(it != slots.end()); + REQUIRE((*it).first == ids[512]); + REQUIRE(*(*it).second == 512); + REQUIRE(++it == slots.end()); + slots.erase(ids[512]); + REQUIRE(slots.begin() == slots.end()); +} + +TEST_CASE("SlotVector sparse iteration matches a live-entry model", "[common]") { + Common::SlotVector<u32> slots; + std::map<u32, u32> live; + std::mt19937 random{9813}; + for (u32 batch = 0; batch < 200; ++batch) { + for (u32 i = 0; i < 100; ++i) { + if (!live.empty() && (random() % 3) == 0) { + auto selected = live.begin(); + std::advance(selected, random() % live.size()); + slots.erase(Common::SlotId{selected->first}); + live.erase(selected); + } else { + const u32 value = random(); + const auto id = slots.insert(value); + live.emplace(id.Value(), value); + } + } + auto expected = live.begin(); + for (const auto [id, value] : slots) { + REQUIRE(expected != live.end()); + REQUIRE(id.Value() == expected->first); + REQUIRE(*value == expected->second); + ++expected; + } + REQUIRE(expected == live.end()); + } +} diff --git a/src/tests/core/core_timing.cpp b/src/tests/core/core_timing.cpp index 81898a1d38..6b424cc52c 100644 --- a/src/tests/core/core_timing.cpp +++ b/src/tests/core/core_timing.cpp @@ -33,6 +33,9 @@ std::optional<std::chrono::nanoseconds> HostCallbackTemplate(s64 time, struct ScopeInit final { ScopeInit() { + callbacks_ran_flags.reset(); + delays.fill(0); + expected_callback = 0; core_timing.SetMulticore(true); core_timing.Initialize([]() {}); } diff --git a/src/tests/core/nup_writer.cpp b/src/tests/core/nup_writer.cpp index ce1a0717ed..1ce9661218 100644 --- a/src/tests/core/nup_writer.cpp +++ b/src/tests/core/nup_writer.cpp @@ -19,6 +19,7 @@ #include "core/file_sys/nup_container.h" #include "core/file_sys/nup_writer.h" #include "core/file_sys/vfs/vfs_real.h" +#include "core/file_sys/vfs/vfs_vector.h" namespace { @@ -173,6 +174,23 @@ void MakeDump(const fs::path& root) { Put(root / "dlc/0400ABCD00011001/0/romfs/addon.bin", "addon data"); } +void SetLe(std::string& buf, size_t at, u64 value, size_t bytes) { + for (size_t i = 0; i < bytes; ++i) { + buf[at + i] = static_cast<char>(value >> (8 * i)); + } +} + +// Applies `patch` to a valid archive, fixes up the TOC hash and opens the result. +template <typename Patch> +Loader::ResultStatus StatusAfterPatch(std::string nup, Patch&& patch) { + const u64 toc = Le(nup, 0x08, 8); + patch(nup, toc); + const auto hash = Sha256(std::string_view{nup}.substr(toc, Le(nup, 0x10, 8))); + std::copy(hash.begin(), hash.end(), nup.begin() + 0x18); + const auto data = std::vector<u8>(nup.begin(), nup.end()); + return NupContainer{std::make_shared<FileSys::VectorVfsFile>(data, "bad.nup")}.GetStatus(); +} + } // namespace TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContainer", "[nup]") { @@ -197,9 +215,11 @@ TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContain REQUIRE(packages[0].kind == NupContainer::PackageKind::Base); REQUIRE(packages[0].title_id == TitleId); REQUIRE(packages[0].version == 0); + REQUIRE(packages[0].display_version == "1.0.0"); REQUIRE(packages[0].npdm.size() == 0x100); REQUIRE(packages[1].kind == NupContainer::PackageKind::Update); REQUIRE(packages[1].version == 65536); + REQUIRE(packages[1].display_version == "1.1.0"); REQUIRE(packages[1].title_id == TitleId); REQUIRE_FALSE(packages[1].standalone_update); REQUIRE(packages[2].kind == NupContainer::PackageKind::Update); @@ -208,7 +228,7 @@ TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContain REQUIRE(packages[3].kind == NupContainer::PackageKind::Addon); REQUIRE(packages[3].title_id == 0x0400ABCD00011001ULL); REQUIRE(packages[3].exefs_dir == 0xFFFFFFFF); - REQUIRE(nup.FindBestUpdatePackage() == 2); + REQUIRE(nup.FindBestUpdatePackage() == std::size_t{2}); REQUIRE(PackedTree(nup.GetExeFSDir(0)) == SourceTree(root / "Base/ExeFS")); REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(root / "Base/RomFS")); @@ -253,6 +273,102 @@ TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContain REQUIRE_FALSE(by_name.contains(".listing")); } +TEST_CASE("NUP reads zstd members above the memory cache through a temporary file", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + std::string large(300 * 1024, '\0'); + u32 x = 7; + for (auto& c : large) { + x = x * 1664525 + 1013904223; + c = static_cast<char>('a' + (x >> 28)); + } + Put(root / "Base/RomFS/large.bin", large); + const auto archive = t.path / "Game.nup"; + FileSys::Nup::PackOptions options{.raw_threshold = 1024 * 1024}; + REQUIRE(FileSys::Nup::PackDump(root, archive, options) == FileSys::Nup::PackResult::Success); + for (const auto& file : RawFiles(ReadAll(archive))) { + if (file.name == "large.bin") { + REQUIRE(file.codec == 1); + } + } + + const auto spill_dir = t.path / "spill"; + FileSys::RealVfsFilesystem real; + { + // Members above 32 KiB do not fit the 64 KiB cache budget and are spilled to disk. + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read), 64 * 1024, + spill_dir}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + const auto file = nup.GetRomFSDir(0)->GetFile("large.bin"); + REQUIRE(file != nullptr); + REQUIRE(file->GetSize() == large.size()); + + std::string chunk(4096, '\0'); + for (size_t offset = 0; offset < large.size(); offset += chunk.size()) { + const size_t count = + file->Read(reinterpret_cast<u8*>(chunk.data()), chunk.size(), offset); + REQUIRE(count == std::min(chunk.size(), large.size() - offset)); + REQUIRE(chunk.compare(0, count, large, offset, count) == 0); + } + for (const size_t offset : {size_t{0}, size_t{123456}, size_t{77}, large.size() - 5}) { + const size_t count = file->Read(reinterpret_cast<u8*>(chunk.data()), 100, offset); + REQUIRE(count == std::min<size_t>(100, large.size() - offset)); + REQUIRE(chunk.compare(0, count, large, offset, count) == 0); + } + REQUIRE(file->Read(reinterpret_cast<u8*>(chunk.data()), 10, large.size()) == 0); + + REQUIRE(std::distance(fs::directory_iterator(spill_dir), fs::directory_iterator{}) > 0); + REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(root / "Base/RomFS")); + REQUIRE(nup.VerifyIntegrity({}) == Loader::ResultStatus::Success); + } + REQUIRE(std::distance(fs::directory_iterator(spill_dir), fs::directory_iterator{}) == 0); +} + +TEST_CASE("NUP rejects a TOC whose counts or offsets wrap", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success); + const auto nup = ReadAll(archive); + const auto no_patch = [](std::string&, u64) {}; + REQUIRE(StatusAfterPatch(nup, no_patch) == Loader::ResultStatus::Success); + + // 2^61 directories wrap dir_count * 0x18 back to the real record size. + REQUIRE(StatusAfterPatch(nup, [](std::string& buf, u64 toc) { + SetLe(buf, toc + 0x08, Le(buf, toc + 0x08, 8) + (1ULL << 61), 8); + }) == Loader::ResultStatus::ErrorNUPBadFormat); + + const auto first_dir = [](const std::string& buf, u64 toc) { + return toc + 0x30 + Le(buf, toc + 0x04, 4) * 0x40; + }; + // first_child + child_count wraps to a small value. + REQUIRE(StatusAfterPatch(nup, [&](std::string& buf, u64 toc) { + SetLe(buf, first_dir(buf, toc) + 0x08, 0xFFFFFFFF, 4); + SetLe(buf, first_dir(buf, toc) + 0x0C, 2, 4); + }) == Loader::ResultStatus::ErrorNUPBadFormat); +} + +TEST_CASE("NUP rejects a directory that contains its own ancestor", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success); + + // Give the base RomFS directory the same children as the package root, which lists it. + REQUIRE(StatusAfterPatch(ReadAll(archive), [](std::string& buf, u64 toc) { + const u64 dirs = toc + 0x30 + Le(buf, toc + 0x04, 4) * 0x40; + const u64 root_dir = Le(buf, toc + 0x30 + 0x08, 4); + const u64 romfs_dir = Le(buf, toc + 0x30 + 0x10, 4); + const u64 root_record = dirs + root_dir * 0x18; + const u64 romfs_record = dirs + romfs_dir * 0x18; + SetLe(buf, romfs_record + 0x08, Le(buf, root_record + 0x08, 4), 4); + SetLe(buf, romfs_record + 0x0C, Le(buf, root_record + 0x0C, 4), 4); + }) == Loader::ResultStatus::ErrorNUPBadFormat); +} + TEST_CASE("NUP marks updates standalone when the dump has no base", "[nup]") { TempDir t; const auto root = t.path / "Game"; @@ -388,5 +504,59 @@ TEST_CASE("NUP output is reproducible", "[nup]") { for (const u8 b : Sha256(ReadAll(archive))) { hex += fmt::format("{:02x}", b); } - REQUIRE(hex == "df8446903ab2c39a51af0304935eb4aa5d8ead105440976ceae7ab97133f6143"); + // The mixed-case Base/RomFS directory supplies base version 1.0.0 on every platform. + REQUIRE(hex == "718231e0974ca04c7be7bd6ed4334015b777c6e5f1ed51de23f899f81ee004ab"); +} + +TEST_CASE("NUP writes metadata overrides to the base and the newest update", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.title_name.empty()); + REQUIRE(probed.icon.empty()); + // The version comes from the RomFS resource size table of the newest update that has one. + REQUIRE(probed.display_version == "1.1.0"); + REQUIRE(probed.version_source == "update 65536"); + + FileSys::Nup::PackOptions options; + options.metadata.title_name = "Custom Name"; + options.metadata.icon = {0xFF, 0xD8, 0xFF, 0xD9}; + options.metadata.display_version = "9.8.7"; + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive, options) == FileSys::Nup::PackResult::Success); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + const auto& packages = nup.GetPackages(); + REQUIRE(packages.size() == 4); + REQUIRE(packages[0].title_name == "Custom Name"); + REQUIRE(packages[0].icon == options.metadata.icon); + REQUIRE(packages[0].display_version == "9.8.7"); + REQUIRE(packages[2].version == 131072); + REQUIRE(packages[2].display_version == "9.8.7"); + REQUIRE(packages[1].display_version == "1.1.0"); +} + +TEST_CASE("NUP probes the version from the game data list tag", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "base/exefs/main", "main"); + Put(root / "base/exefs/main.npdm", Npdm(TitleId)); + Put(root / "base/romfs/GameData/GameDataList.Product.100.byml.zs", "x"); + Put(root / "base/romfs/System/Resource/ResourceSizeTable.Product.rsizetable.zs", "x"); + Put(root / "updates/458752/exefs/main", "main"); + Put(root / "updates/458752/exefs/main.npdm", Npdm(TitleId)); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.byml.zs", "x"); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.revision.txt", "1"); + + auto probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "3.2.0"); + REQUIRE(probed.version_source == "update 458752"); + + fs::remove_all(root / "updates"); + probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "1.0.0"); + REQUIRE(probed.version_source == "base"); } diff --git a/src/tests/core/priority_queue.cpp b/src/tests/core/priority_queue.cpp new file mode 100644 index 0000000000..0d7174f793 --- /dev/null +++ b/src/tests/core/priority_queue.cpp @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <algorithm> +#include <array> +#include <random> +#include <vector> +#include <catch2/catch_test_macros.hpp> +#include "core/hle/kernel/k_priority_queue.h" + +namespace { +struct Affinity { + u64 GetAffinityMask() const { + return 3; + } + void SetAffinityMask(u64) {} + bool GetAffinity(s32) const { + return true; + } + void SetAffinity(s32, bool) {} + void SetAll() {} +}; + +struct Member { + struct QueueEntry { + Member* prev{}; + Member* next{}; + void Initialize() { + prev = next = nullptr; + } + void SetPrev(Member* value) { + prev = value; + } + void SetNext(Member* value) { + next = value; + } + Member* GetNext() const { + return next; + } + Member* GetPrev() const { + return prev; + } + }; + std::array<QueueEntry, 2> entries{}; + s32 priority{}; + QueueEntry& GetPriorityQueueEntry(s32 core) { + return entries[core]; + } + const QueueEntry& GetPriorityQueueEntry(s32 core) const { + return entries[core]; + } + Affinity GetAffinityMask() const { + return {}; + } + s32 GetActiveCore() const { + return 0; + } + s32 GetPriority() const { + return priority; + } + bool IsDummyThread() const { + return false; + } +}; + +template <size_t Priorities> +void CheckQueue() { + using Queue = Kernel::KPriorityQueue<Member, 2, Priorities - 1, 0>; + typename Queue::KPriorityQueueImpl queue; + std::array<Member, 160> members{}; + std::array<std::array<bool, 2>, 160> present{}; + std::array<std::array<std::vector<Member*>, Priorities>, 2> model{}; + std::mt19937 random{6152}; + for (size_t i = 0; i < members.size(); ++i) { + members[i].priority = static_cast<s32>(i % Priorities); + } + REQUIRE(queue.GetFront(0) == nullptr); + for (u32 operation = 0; operation < 3000; ++operation) { + const size_t index = random() % members.size(); + const s32 core = random() % 2; + auto& member = members[index]; + auto& expected = model[core][member.priority]; + if (present[index][core]) { + queue.Remove(member.priority, core, &member); + std::erase(expected, &member); + } else if ((random() & 1) != 0) { + queue.PushFront(member.priority, core, &member); + expected.insert(expected.begin(), &member); + } else { + queue.PushBack(member.priority, core, &member); + expected.push_back(&member); + } + present[index][core] = !present[index][core]; + for (s32 checked_core = 0; checked_core < 2; ++checked_core) { + Member* actual = queue.GetFront(checked_core); + for (const auto& priority : model[checked_core]) { + for (Member* wanted : priority) { + REQUIRE(actual == wanted); + actual = queue.GetNext(checked_core, actual); + } + } + REQUIRE(actual == nullptr); + } + } +} +} // namespace + +TEST_CASE("Priority queue bitmap traversal preserves thread order", "[core]") { + CheckQueue<64>(); + CheckQueue<128>(); +} diff --git a/src/tests/input_common/calibration_configuration_job.cpp b/src/tests/input_common/calibration_configuration_job.cpp index 6ded0dfc86..c45ef157a5 100644 --- a/src/tests/input_common/calibration_configuration_job.cpp +++ b/src/tests/input_common/calibration_configuration_job.cpp @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include <array> +#include <memory> #include <string> #include <thread> #include <utility> @@ -19,7 +20,7 @@ class FakeCemuhookServer { public: FakeCemuhookServer() - : socket(io_context, boost::asio::ip::udp::endpoint(boost::asio::ip::udp::v4(), 0)) {} + : socket(io_context, boost::asio::ip::udp::endpoint(boost::asio::ip::address_v4::loopback(), 0)) {} ~FakeCemuhookServer() { boost::system::error_code error_code; @@ -132,3 +133,20 @@ TEST_CASE("CalibrationConfigurationJob completed", "[input_common]") { REQUIRE(max_x == 200); REQUIRE(max_y == 200); } + +TEST_CASE("CalibrationConfigurationJob cancellation joins its worker", "[input_common]") { + FakeCemuhookServer server; + for (unsigned i = 0; i < 32; ++i) { + auto lifetime = std::make_shared<int>(0); + std::weak_ptr<int> observer = lifetime; + { + InputCommon::CemuhookUDP::CalibrationConfigurationJob job( + server.GetHost(), server.GetPort(), + [lifetime](InputCommon::CemuhookUDP::CalibrationConfigurationJob::Status) {}, + [](u16, u16, u16, u16) {}); + lifetime.reset(); + job.Stop(); + } + REQUIRE(observer.expired()); + } +} diff --git a/src/tests/video_core/descriptor_table.cpp b/src/tests/video_core/descriptor_table.cpp new file mode 100644 index 0000000000..8b20564949 --- /dev/null +++ b/src/tests/video_core/descriptor_table.cpp @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <array> +#include <chrono> +#include <cstring> +#include <iostream> +#include <limits> +#include <random> +#include <catch2/catch_test_macros.hpp> +#include "video_core/texture_cache/descriptor_table.h" + +namespace { +struct DescriptorMemory { + std::array<std::array<u64, 128>, 2> banks{}; + + void ReadBlockUnsafe(GPUVAddr address, void* output, size_t size) const { + const auto bank = (address - 0x1000) / 0x1000; + const auto offset = (address - 0x1000) % 0x1000; + std::memcpy(output, reinterpret_cast<const u8*>(banks[bank].data()) + offset, size); + } +}; +} // namespace + +TEST_CASE("Descriptor invalidation matches eager refresh across mutations", "[descriptor_table]") { + DescriptorMemory memory; + VideoCommon::DescriptorTable<u64> table; + REQUIRE(table.Synchronize(0x1000, 127)); + std::array<bool, 128> seen{}; + std::array<u64, 128> previous{}; + std::mt19937 random{0x5080}; + unsigned bank{}; + u32 limit = 127; + for (unsigned i = 0; i < 40000; ++i) { + switch (random() % 5) { + case 0: + table.Invalidate(); + seen.fill(false); + break; + case 1: + memory.banks[random() % 2][random() % 128] = random(); + break; + case 2: { + const auto next_bank = random() % 2; + const u32 next_limit = random() % 2 ? 63 : 127; + const bool changed = next_bank != bank || next_limit != limit; + REQUIRE(table.Synchronize(0x1000 + next_bank * 0x1000, next_limit) == changed); + if (changed) { + seen.fill(false); + } + bank = next_bank; + limit = next_limit; + break; + } + default: { + const u32 index = random() % (limit + 1); + const u64 expected = memory.banks[bank][index]; + const bool changed = !seen[index] || previous[index] != expected; + const auto actual = table.Read(memory, index); + REQUIRE(actual.first == expected); + REQUIRE(actual.second == changed); + seen[index] = true; + previous[index] = expected; + } + } + } +} + +TEST_CASE("Repeated image deletion cannot reuse stale descriptor view IDs", "[descriptor_table]") { + DescriptorMemory memory; + std::array<VideoCommon::DescriptorTable<u64>, 5> channels; + std::array<std::array<unsigned, 128>, 5> view_ids{}; + unsigned live_generation = 1; + const auto visit = [&](size_t channel, u32 index) { + if (channels[channel].Read(memory, index).second) { + view_ids[channel][index] = live_generation; + } + return view_ids[channel][index]; + }; + for (size_t channel = 0; channel < channels.size(); ++channel) { + REQUIRE(channels[channel].Synchronize(0x1000, 127)); + for (u32 index = 0; index < 128; ++index) { + REQUIRE(visit(channel, index) == live_generation); + } + } + for (unsigned generation = 0; generation < 32; ++generation) { + ++live_generation; + for (unsigned deletion = 0; deletion < 50; ++deletion) { + for (auto& table : channels) { + table.Invalidate(); + } + } + for (size_t channel = 0; channel < channels.size(); ++channel) { + for (u32 index = 0; index < 128; ++index) { + REQUIRE(visit(channel, index) == live_generation); + REQUIRE_FALSE(channels[channel].Read(memory, index).second); + } + } + } +} + +TEST_CASE("Descriptor limits clear exposed slots after shrink and growth", "[descriptor_table]") { + struct AddressMemory { + void ReadBlockUnsafe(GPUVAddr address, void* output, size_t size) const { + std::memcpy(output, &address, size); + } + } memory; + constexpr std::array<u32, 9> limits{0, 63, 64, 127, 128, 0x7ffff, 0x80000, 0x80001, 0x100000}; + for (const u32 limit : limits) { + VideoCommon::DescriptorTable<u64> table; + REQUIRE(table.Synchronize(0x1000, limit)); + CHECK(table.Read(memory, limit).second); + CHECK_FALSE(table.Read(memory, limit).second); + table.Refresh(0x1000, 0); + table.Invalidate(); + CHECK(table.Read(memory, 0).second); + CHECK_FALSE(table.Read(memory, 0).second); + table.Refresh(0x1000, limit); + CHECK(table.Read(memory, limit).second); + CHECK_FALSE(table.Read(memory, limit).second); + table.Invalidate(); + table.Invalidate(); + CHECK(table.Read(memory, limit).second); + CHECK_FALSE(table.Read(memory, limit).second); + } + for (const u32 limit : + {std::numeric_limits<u32>::max(), std::numeric_limits<u32>::max() - 0x1000}) { + VideoCommon::DescriptorTable<u64> table; + table.Refresh(0x1000, limit); + CHECK(table.Read(memory, 0).second); + table.Invalidate(); + CHECK(table.Read(memory, 0).second); + CHECK_FALSE(table.Read(memory, 0).second); + } +} + +TEST_CASE("Descriptor invalidation CPU benchmark", "[.][cache_bench]") { + DescriptorMemory memory; + const auto measure = [&](bool deferred) { + std::array<VideoCommon::DescriptorTable<u64>, 5> tables; + for (auto& table : tables) { + REQUIRE(table.Synchronize(0x1000, 127)); + } + void (*volatile invalidate)(VideoCommon::DescriptorTable<u64>&) = + deferred ? +[](VideoCommon::DescriptorTable<u64>& table) { table.Invalidate(); } + : +[](VideoCommon::DescriptorTable<u64>& table) { + std::ranges::fill(table.read_descriptors, 0); + }; + size_t changed{}; + const auto start = std::chrono::steady_clock::now(); + for (unsigned batch = 0; batch < 2000; ++batch) { + for (unsigned deletion = 0; deletion < 50; ++deletion) { + for (auto& table : tables) { + invalidate(table); + } + } + for (auto& table : tables) { + changed += table.Read(memory, 0).second; + } + } + const auto ms = + std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start) + .count(); + REQUIRE(changed == 10000); + return ms; + }; + const double eager = measure(false); + const double deferred = measure(true); + std::cout << "Descriptor bitmap invalidation: eager=" << eager << " ms deferred=" << deferred + << " ms (CPU only, not gameplay FPS)\n"; +} diff --git a/src/tests/video_core/memory_tracker.cpp b/src/tests/video_core/memory_tracker.cpp index c2236848b5..3ac4cee840 100644 --- a/src/tests/video_core/memory_tracker.cpp +++ b/src/tests/video_core/memory_tracker.cpp @@ -30,6 +30,10 @@ class RasterizerInterface { public: void UpdatePagesCachedCount(DAddr addr, size_t size, s32 delta) { ++update_calls; + ApplyPagesCachedCount(addr, size, delta); + } + + void ApplyPagesCachedCount(DAddr addr, size_t size, s32 delta) { calls.emplace_back(addr, size, delta); const u64 page_start{addr >> Core::DEVICE_PAGEBITS}; const u64 page_end{(addr + size + Core::DEVICE_PAGESIZE - 1) >> Core::DEVICE_PAGEBITS}; @@ -45,7 +49,13 @@ public: } void UpdatePagesCachedBatch(std::span<const std::pair<DAddr, size_t>> ranges, s32 delta) { - // TODO: for now assume fine? + if (ranges.empty()) { + return; + } + ++update_calls; + for (const auto& [addr, size] : ranges) { + ApplyPagesCachedCount(addr, size, delta); + } } [[nodiscard]] size_t UpdateCalls() const noexcept { return update_calls; } @@ -557,20 +567,32 @@ TEST_CASE("MemoryTracker: Cached write downloads") { REQUIRE(rasterizer.Count() == 0); } -TEST_CASE("MemoryTracker: FlushCachedWrites batching") { +TEST_CASE("MemoryTracker: cached writes preserve tracking counts") { RasterizerInterface rasterizer; std::optional<MemoryTracker> memory_track(rasterizer); memory_track->UnmarkRegionAsCpuModified(c, WORD * 2); + REQUIRE(rasterizer.UpdateCalls() == 1); + REQUIRE(rasterizer.UpdateCallsList().size() == 1); + REQUIRE(rasterizer.UpdateCallsList().front() == std::tuple{c, WORD * 2, 1}); + memory_track->CachedCpuWrite(c + PAGE, PAGE); memory_track->CachedCpuWrite(c + PAGE * 2, PAGE); memory_track->CachedCpuWrite(c + PAGE * 4, PAGE); - REQUIRE(rasterizer.UpdateCalls() == 0); + REQUIRE(rasterizer.Count() == 125); + REQUIRE(rasterizer.Count(c + PAGE) == 0); + REQUIRE(rasterizer.Count(c + PAGE * 2) == 0); + REQUIRE(rasterizer.Count(c + PAGE * 3) == 1); + REQUIRE(rasterizer.Count(c + PAGE * 4) == 0); + const auto update_calls = rasterizer.UpdateCalls(); memory_track->FlushCachedWrites(); - // Now we expect a single batch call (coalesced ranges) to the device memory manager - REQUIRE(rasterizer.UpdateCalls() == 1); - const auto& calls = rasterizer.UpdateCallsList(); - REQUIRE(std::get<0>(calls[0]) == c + PAGE); - REQUIRE(std::get<1>(calls[0]) == PAGE * 3); + REQUIRE(rasterizer.UpdateCalls() == update_calls); + REQUIRE(rasterizer.Count() == 125); + REQUIRE(memory_track->IsRegionCpuModified(c + PAGE, PAGE)); + REQUIRE(memory_track->IsRegionCpuModified(c + PAGE * 2, PAGE)); + REQUIRE_FALSE(memory_track->IsRegionCpuModified(c + PAGE * 3, PAGE)); + REQUIRE(memory_track->IsRegionCpuModified(c + PAGE * 4, PAGE)); + memory_track->MarkRegionAsCpuModified(c, WORD * 2); + REQUIRE(rasterizer.Count() == 0); } TEST_CASE("DeviceMemoryManager: UpdatePagesCachedBatch basic") { diff --git a/src/tests/video_core/native_macro_fifo.cpp b/src/tests/video_core/native_macro_fifo.cpp new file mode 100644 index 0000000000..e19b146192 --- /dev/null +++ b/src/tests/video_core/native_macro_fifo.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <array> +#include <vector> +#include <catch2/catch_test_macros.hpp> + +#ifdef ARCHITECTURE_x86_64 +#include "video_core/macro_turing_jit.h" + +namespace { +std::array<u32, 3> FifoInstruction(u32 method, bool end) { + std::array<u32, 3> bits{}; + const auto field = [&](u32 offset, u32 count, u32 value) { + for (u32 bit = 0; bit < count; ++bit) { + bits[(offset + bit) / 32] |= ((value >> bit) & 1) << ((offset + bit) % 32); + } + }; + field(0, 1, end); + for (u32 lane = 0; lane < 2; ++lane) { + field(15 + lane * 36, 5, 24); + field(20 + lane * 36, 5, 24); + field(25 + lane * 36, 5, 24); + } + field(30, 16, method); + field(82, 3, 5); + field(85, 4, 3); + return {bits[2], bits[1], bits[0]}; +} +} // namespace + +TEST_CASE("Owned native macro FIFOs preserve loads across DMA appends and repeated calls", + "[video_core][native_macro]") { + std::vector<u32> code; + for (const auto instruction : {FifoInstruction(0x158, false), FifoInstruction(0x200, true), + FifoInstruction(0x201, false)}) { + code.insert(code.end(), instruction.begin(), instruction.end()); + } + Tegra::TuringMacroJit jit; + jit.Prepare(code, 1); + std::array<u32, 0xc00> ram{}; + std::vector<u32> parameters; + std::vector<u32> reference; + for (u32 invocation = 0; invocation < 1000; ++invocation) { + const u32 count = 2 + invocation % 127; + parameters.push_back(count); + reference.assign(parameters.begin(), parameters.end()); + const auto run = [&](std::vector<u32>& input) { + std::vector<std::pair<u32, u32>> writes; + const auto result = jit.Execute( + 0, input, ram, [](u32) { return 0U; }, + [&](u32 method, u32 value) { + writes.emplace_back(method, value); + if (method == 0x158) { + for (u32 word = 0; word < value; ++word) { + input.push_back(invocation * 256 + word); + } + } + return true; + }); + CHECK(result.success); + CHECK(result.consumed == 3); + return writes; + }; + const auto expected = run(reference); + const auto actual = run(parameters); + REQUIRE(actual.size() == 3); + CHECK(actual == expected); + CHECK(parameters == reference); + CHECK(actual[1] == std::pair<u32, u32>{0x200, invocation * 256}); + CHECK(actual[2] == std::pair<u32, u32>{0x201, invocation * 256 + 1}); + parameters.clear(); + } +} +#endif diff --git a/src/tests/video_core/shader_invalidation.cpp b/src/tests/video_core/shader_invalidation.cpp new file mode 100644 index 0000000000..806fb5c58d --- /dev/null +++ b/src/tests/video_core/shader_invalidation.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <array> +#include <chrono> +#include <iostream> +#include <memory> +#include <random> +#include <vector> +#include <catch2/catch_test_macros.hpp> +#include "core/device_memory.h" +#include "video_core/shader_cache.h" + +namespace VideoCommon { +struct ShaderCacheTestAccess { + static std::unique_ptr<ShaderCache> Create(Tegra::MaxwellDeviceMemoryManager& memory) { + return std::unique_ptr<ShaderCache>{new ShaderCache{memory}}; + } + static void Register(ShaderCache& cache, VAddr address, size_t size, u64 hash) { + auto data = std::make_unique<ShaderInfo>(); + data->unique_hash = hash; + cache.Register(std::move(data), address, size); + } + static const ShaderInfo* Find(const ShaderCache& cache, VAddr address) { + return cache.TryGet(address); + } +}; +} // namespace VideoCommon + +using VideoCommon::ShaderCacheTestAccess; + +TEST_CASE("Shader batch invalidation matches sequential cache contents", "[shader_invalidation]") { + Core::DeviceMemory memory; + Tegra::MaxwellDeviceMemoryManager device_memory{memory}; + auto batched = ShaderCacheTestAccess::Create(device_memory); + auto sequential = ShaderCacheTestAccess::Create(device_memory); + std::mt19937 random{0x16'67}; + constexpr VAddr base = 0x100000; + for (unsigned iteration = 0; iteration < 600; ++iteration) { + for (unsigned slot = 0; slot < 128; ++slot) { + const auto address = base + slot * 4096; + if (!ShaderCacheTestAccess::Find(*batched, address)) { + const auto size = 1 + random() % 65536; + ShaderCacheTestAccess::Register(*batched, address, size, iteration + slot); + ShaderCacheTestAccess::Register(*sequential, address, size, iteration + slot); + } + } + std::vector<std::pair<DAddr, size_t>> writes; + for (unsigned i = 0, count = random() % 24; i < count; ++i) { + writes.emplace_back(base + random() % (128 * 4096), random() % 32768); + if (i % 4 == 0) { + writes.push_back(writes.back()); + } + } + for (const auto& [address, size] : writes) { + sequential->InvalidateRegion(address, size); + } + batched->InvalidateRegions(writes); + for (unsigned slot = 0; slot < 128; ++slot) { + const auto address = base + slot * 4096; + const auto actual = ShaderCacheTestAccess::Find(*batched, address); + const auto expected = ShaderCacheTestAccess::Find(*sequential, address); + REQUIRE(bool(actual) == bool(expected)); + if (actual) { + REQUIRE(actual->unique_hash == expected->unique_hash); + } + } + } +} + +TEST_CASE("Shader empty batches preserve pending invalidations", "[shader_invalidation]") { + Core::DeviceMemory memory; + Tegra::MaxwellDeviceMemoryManager device_memory{memory}; + auto cache = ShaderCacheTestAccess::Create(device_memory); + constexpr VAddr address = 0x100000; + ShaderCacheTestAccess::Register(*cache, address, 32768, 123); + cache->OnCacheInvalidation(address, 1); + cache->InvalidateRegions({}); + REQUIRE(ShaderCacheTestAccess::Find(*cache, address)); + const std::array unrelated{std::pair<DAddr, size_t>{address + 0x100000, 4096}}; + cache->InvalidateRegions(unrelated); + REQUIRE_FALSE(ShaderCacheTestAccess::Find(*cache, address)); +} + +TEST_CASE("Shader invalidation CPU benchmark", "[.][cache_bench]") { + Core::DeviceMemory memory; + Tegra::MaxwellDeviceMemoryManager device_memory{memory}; + auto batched = ShaderCacheTestAccess::Create(device_memory); + auto sequential = ShaderCacheTestAccess::Create(device_memory); + std::vector<std::pair<DAddr, size_t>> writes; + constexpr VAddr base = 0x100000; + for (unsigned slot = 0; slot < 768; ++slot) { + writes.emplace_back(base + slot * 65536, 1); + } + double sequential_ms{}, batched_ms{}; + for (unsigned repeat = 0; repeat < 20; ++repeat) { + for (unsigned slot = 0; slot < 1024; ++slot) { + const auto address = base + slot * 65536; + if (!ShaderCacheTestAccess::Find(*batched, address)) { + ShaderCacheTestAccess::Register(*batched, address, 64, slot); + ShaderCacheTestAccess::Register(*sequential, address, 64, slot); + } + } + auto start = std::chrono::steady_clock::now(); + for (const auto& [address, size] : writes) { + sequential->InvalidateRegion(address, size); + } + sequential_ms += + std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start) + .count(); + start = std::chrono::steady_clock::now(); + batched->InvalidateRegions(writes); + batched_ms += + std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start) + .count(); + REQUIRE_FALSE(ShaderCacheTestAccess::Find(*batched, base)); + REQUIRE(ShaderCacheTestAccess::Find(*batched, base + 1023 * 65536)); + } + std::cout << "Shader invalidation: sequential=" << sequential_ms << " ms batched=" << batched_ms + << " ms (CPU only, not gameplay FPS)\n"; +} diff --git a/src/tests/video_core/uniform_stream_reuse.cpp b/src/tests/video_core/uniform_stream_reuse.cpp new file mode 100644 index 0000000000..6059b70eeb --- /dev/null +++ b/src/tests/video_core/uniform_stream_reuse.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <algorithm> +#include <array> +#include <cstdio> +#include <cstdlib> +#include <cstring> +#include <span> +#include <vector> +#include <catch2/catch_test_macros.hpp> + +#include "video_core/buffer_cache/uniform_stream_reuse.h" + +namespace { +void Check(bool condition, const char* message) { + INFO(message); + REQUIRE(condition); +} + +struct Memory { + std::array<u8, 8192> bytes{}; + void ReadBlockUnsafe(u64 address, void* destination, u32 size) { + Check(address + size <= bytes.size(), "read outside guest memory"); + std::memcpy(destination, bytes.data() + address, size); + } +}; + +struct Runtime { + struct StreamUniformRef { + size_t allocation; + }; + std::vector<std::vector<u8>> allocations; + StreamUniformRef last{}, bound{}; + u64 tick{1}; + u64 CurrentTick() const { + return tick; + } + StreamUniformRef LastStreamUniformRef() const { + return last; + } + std::span<u8> BindMappedUniformBuffer(size_t, u32, u32 size) { + allocations.emplace_back(size); + last = bound = StreamUniformRef{allocations.size() - 1}; + return allocations.back(); + } + void RebindStreamUniform(StreamUniformRef ref, u32 size) { + Check(ref.allocation < allocations.size(), "reused a recycled allocation"); + Check(allocations[ref.allocation].size() == size, "reused wrong allocation size"); + bound = ref; + } + void NextTick() { + ++tick; + allocations.clear(); + } +}; + +void CheckBound(const Runtime& runtime, const Memory& memory, u64 address, u32 size) { + const auto& bytes = runtime.allocations.at(runtime.bound.allocation); + Check(bytes.size() == size && + std::memcmp(bytes.data(), memory.bytes.data() + address, size) == 0, + "bound uniform data differs from guest memory"); +} +} // namespace + +TEST_CASE("Uniform stream reuse preserves data through mutation and session resets", + "[video_core]") { + using VideoCommon::BindSmallUniformReusing; + Memory memory; + Runtime runtime; + VideoCommon::ResetUniformStreamReuse<Runtime>(); + auto bind = [&](size_t stage, u32 slot, u64 address, u32 size) { + Check(BindSmallUniformReusing(runtime, memory, stage, slot, slot, address, size), + "valid small uniform rejected"); + CheckBound(runtime, memory, address, size); + }; + bind(0, 0, 0, 64); + bind(0, 0, 0, 64); + Check(runtime.allocations.size() == 1, "identical bytes were copied twice"); + memory.bytes[7] = 23; + bind(0, 0, 0, 64); + Check(runtime.allocations.size() == 2, "changed bytes reused stale data"); + bind(0, 1, 0, 64); + bind(1, 0, 0, 64); + Check(runtime.allocations.size() == 4, "stage or slot shared the wrong allocation"); + runtime.NextTick(); + bind(0, 0, 0, 64); + Check(runtime.allocations.size() == 1, "a new command buffer reused an old allocation"); + VideoCommon::ResetUniformStreamReuse<Runtime>(); + bind(0, 0, 0, 64); + Check(runtime.allocations.size() == 2, "session reset retained an old allocation"); + bind(4, 17, 0, 4096); + const auto before = runtime.allocations.size(); + for (auto [stage, slot, size] : + {std::array<u32, 3>{5, 0, 64}, {0, 18, 64}, {0, 0, 0}, {0, 0, 4097}}) { + Check(!BindSmallUniformReusing(runtime, memory, stage, slot, slot, 0, size), + "invalid uniform range did not use the fallback"); + } + Check(runtime.allocations.size() == before, "fallback allocated stream memory"); + + u32 random = 17; + for (u32 index = 0; index < 10000; ++index) { + random = random * 1664525U + 1013904223U; + if (index % 37 == 0) + runtime.NextTick(); + if (index % 503 == 0) + VideoCommon::ResetUniformStreamReuse<Runtime>(); + const u32 size = 1 + random % 4096; + const u64 address = (random >> 12) % (memory.bytes.size() - 4096); + if (index % 3 == 0) + memory.bytes[address + random % size] ^= u8{0x5a}; + const size_t stage = (random >> 20) % 5; + const u32 slot = (random >> 24) % 18; + bind(stage, slot, address, size); + bind(stage, slot, address, size); + } +} diff --git a/src/tests/video_core/vertex_repack.cpp b/src/tests/video_core/vertex_repack.cpp index 6409be3ca4..5e21e3714c 100644 --- a/src/tests/video_core/vertex_repack.cpp +++ b/src/tests/video_core/vertex_repack.cpp @@ -76,16 +76,22 @@ TEST_CASE("Vertex repacking requires the captured tail and stride", "[vertex_rep REQUIRE(Vulkan::VertexRepackMask(regs) == 0); } -TEST_CASE("Static vertex repacking requires explicit experimental opt-in", "[vertex_repack]") { +TEST_CASE("Static vertex repacking follows the device stride alignment", "[vertex_repack]") { REQUIRE(Vulkan::VertexRepackInputSupported(true, false)); REQUIRE_FALSE(Vulkan::VertexRepackInputSupported(false, false)); REQUIRE(Vulkan::VertexRepackInputSupported(false, true)); + REQUIRE_FALSE(Vulkan::StaticVertexRepackSelected(nullptr, 1)); + REQUIRE(Vulkan::StaticVertexRepackSelected(nullptr, 4)); + REQUIRE(Vulkan::StaticVertexRepackSelected("1", 1)); + REQUIRE_FALSE(Vulkan::StaticVertexRepackSelected("0", 4)); + REQUIRE(Vulkan::StaticVertexRepackSelected("other", 4)); } 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); + const auto plan = Vulkan::MakeVertexRepackParameters(offset, 70, alignment, 1U << 20, + 65535, 1U << 20); REQUIRE(plan.has_value()); REQUIRE(plan->source_base % alignment == 0); REQUIRE(plan->source_base + plan->source_bias == offset); @@ -99,13 +105,22 @@ TEST_CASE("Vertex repack planning keeps descriptor offsets aligned", "[vertex_re } 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_FALSE(Vulkan::MakeVertexRepackParameters(0, 0, 256, 1U << 20, 65535, 1U << 20)); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 0, 1U << 20, 65535, 1U << 20)); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 256, 71, 65535, 1U << 20)); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(255, 69, 256, 323, 65535, 1U << 20)); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 256, 1U << 20, 0, 1U << 20)); + REQUIRE_FALSE( + Vulkan::MakeVertexRepackParameters(0, 0xffffffffU, 256, ~u64{0}, 0xffffffffU, ~u64{0})); + const auto one = Vulkan::MakeVertexRepackParameters(0, 69, 256, 72, 1, 72); REQUIRE(one.has_value()); REQUIRE(one->source_range == 72); } + +TEST_CASE("Vertex repack planning keeps the source range inside the buffer", "[vertex_repack]") { + const auto clamped = Vulkan::MakeVertexRepackParameters(0, 69, 256, 1U << 20, 65535, 69); + REQUIRE(clamped.has_value()); + REQUIRE(clamped->source_range == 69); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(0, 69, 256, 1U << 20, 65535, 68)); + REQUIRE_FALSE(Vulkan::MakeVertexRepackParameters(300, 69, 256, 1U << 20, 65535, 256)); +} diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 53bbae7610..40369b16ef 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -10,11 +10,13 @@ #include <memory> #include <numeric> #include <stdexcept> +#include <utility> #include <vector> #include <fmt/ranges.h> #include "common/range_sets.inc" #include "video_core/buffer_cache/buffer_cache_base.h" +#include "video_core/buffer_cache/uniform_stream_reuse.h" #include "video_core/guest_memory.h" #include "video_core/host1x/gpu_device_memory_manager.h" #include "video_core/nx2.h" @@ -28,6 +30,9 @@ using Core::DEVICE_PAGESIZE; template <class P> BufferCache<P>::BufferCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, Runtime& runtime_) : runtime{runtime_}, device_memory{device_memory_}, memory_tracker{device_memory} { + // A new runtime has a new scheduler (its ticks restart) and a new stream buffer: whatever the + // stream uniform reuse remembers refers to the old ones. Before the early return below. + VideoCommon::ResetUniformStreamReuse<Runtime>(); // Ensure the first slot is used for the null buffer void(slot_buffers.insert(runtime, NullBufferParams{})); gpu_modified_ranges.Clear(); @@ -174,11 +179,13 @@ void BufferCache<P>::UnmapGPUMemory(size_t as_id, GPUVAddr gpu_addr, size_t size template <class P> void BufferCache<P>::WriteMemory(DAddr device_addr, u64 size) { + Nx2NoteListHostWrite(device_addr, size); if (IsRegionGpuModified(device_addr, size)) { ClearDownload(device_addr, size); gpu_modified_ranges.Subtract(device_addr, size); } memory_tracker.MarkRegionAsCpuModified(device_addr, size); + MarkVertexBuffersStale(device_addr, size); } template <class P> @@ -194,6 +201,7 @@ void BufferCache<P>::CachedWriteMemory(DAddr device_addr, u64 size) { return; } + Nx2NoteListHostWrite(device_addr, size); tmp_buffer.resize_destructive(size); device_memory.ReadBlockUnsafe(device_addr, tmp_buffer.data(), size); @@ -228,8 +236,17 @@ std::optional<VideoCore::RasterizerDownloadArea> BufferCache<P>::GetFlushArea(DA }; area->preemtive = !IsRegionGpuModified(device_addr_start_aligned, device_addr_end_aligned - device_addr_start_aligned); - memory_tracker.MarkRegionAsPreflushable(device_addr_start_aligned, - device_addr_end_aligned - device_addr_start_aligned); + // Mark only data that the GPU wrote. A mark stays for the session and sends later GPU writes + // in the range to a download at each fence. + if (!area->preemtive) { + memory_tracker.MarkRegionAsPreflushable(device_addr_start_aligned, + device_addr_end_aligned - device_addr_start_aligned); + } else { + // The core keeps this area and has no mark. The next GPU write here must arm the range + // again, which ends the area and sets the trap. + read_trapped_ranges.Subtract(device_addr_start_aligned, + device_addr_end_aligned - device_addr_start_aligned); + } return area; } @@ -263,6 +280,7 @@ bool BufferCache<P>::DMACopy(GPUVAddr src_address, GPUVAddr dest_address, u64 am } ClearDownload(*cpu_dest_address, amount); + MarkVertexBuffersStale(*cpu_dest_address, amount); BufferId buffer_a; BufferId buffer_b; @@ -301,6 +319,7 @@ bool BufferCache<P>::DMACopy(GPUVAddr src_address, GPUVAddr dest_address, u64 am gpu_modified_ranges.Add(pair.first, pair.second); if (Tegra::g_nx2_ampere.load(std::memory_order_relaxed)) { Tegra::Nx2MarkGpuWritten(pair.first, pair.second); + Nx2NoteListWrite(buffer_b, pair.first, pair.second); } } const auto& copy = copies[0]; @@ -405,7 +424,8 @@ void BufferCache<P>::BindGraphicsUniformBuffer(size_t stage, u32 index, GPUVAddr template <class P> void BufferCache<P>::DisableGraphicsUniformBuffer(size_t stage, u32 index) { auto& current = channel_state->uniform_buffers[stage][index]; - if (current.device_addr == 0 && current.size == 0 && !current.buffer_id) [[likely]] { + if (current.device_addr == 0 && current.size == 0 && current.buffer_id == NULL_BUFFER_ID) + [[likely]] { return; } current = NULL_BINDING; @@ -890,7 +910,39 @@ void BufferCache<P>::UpdateVertexBufferSlot(u32 index, const Binding& binding) { } template <class P> +void BufferCache<P>::MarkVertexBuffersStale(DAddr device_addr, u64 size) { + if constexpr (requires { runtime.IsVertexRepacked(u32{}); }) { + u32 mask = enabled_vertex_buffers_mask; + while (mask != 0) { + const u32 index = std::countr_zero(mask); + mask &= mask - 1; + const Binding& binding = v_buffer[index]; + if (device_addr < binding.device_addr + binding.size && + binding.device_addr < device_addr + size) { + stale_vertex_buffers_mask |= 1U << index; + } + } + } +} + +template <class P> +void BufferCache<P>::RefreshRepackedVertexBuffers() { + if constexpr (requires { runtime.IsVertexRepacked(u32{}); }) { + u32 mask = std::exchange(stale_vertex_buffers_mask, 0) & enabled_vertex_buffers_mask; + auto& flags = maxwell3d->dirty.flags; + while (mask != 0) { + const u32 index = std::countr_zero(mask); + mask &= mask - 1; + if (runtime.IsVertexRepacked(index)) { + flags[Dirty::VertexBuffer0 + index] = true; + } + } + } +} + +template <class P> void BufferCache<P>::BindHostVertexBuffers() { + RefreshRepackedVertexBuffers(); #ifdef __ANDROID__ const bool use_optimized_vertex_buffers = Settings::values.use_optimized_vertex_buffers.GetValue(); @@ -1075,8 +1127,20 @@ void BufferCache<P>::BindHostGraphicsUniformBuffer(size_t stage, u32 index, u32 channel_state->fast_bound_uniform_buffers[stage] |= 1u << binding_index; channel_state->uniform_buffer_binding_sizes[stage][binding_index] = size; // Stream buffer path to avoid stalling on non-Nvidia drivers or Vulkan - const std::span<u8> span = runtime.BindMappedUniformBuffer(stage, binding_index, size); - device_memory.ReadBlockUnsafe(device_addr, span.data(), size); + bool streamed = false; + if constexpr (!IS_OPENGL && VideoCommon::StreamUniformReusable<Runtime>) { + // The same bytes as this slot's previous bind, in the same command buffer: bind that + // stream allocation again instead of allocating and copying (uniform_stream_reuse.h). + // EDEN_NX2_UBOREUSE=0 turns it off. + if (VideoCommon::UniformStreamReuseEnabled()) { + streamed = VideoCommon::BindSmallUniformReusing( + runtime, device_memory, stage, index, binding_index, device_addr, size); + } + } + if (!streamed) { + const std::span<u8> span = runtime.BindMappedUniformBuffer(stage, binding_index, size); + device_memory.ReadBlockUnsafe(device_addr, span.data(), size); + } return; } // Classic cached path @@ -1652,6 +1716,7 @@ void BufferCache<P>::MarkWrittenBuffer(BufferId buffer_id, DAddr device_addr, u3 } } memory_tracker.MarkRegionAsGpuModified(device_addr, size); + MarkVertexBuffersStale(device_addr, size); gpu_modified_ranges.Add(device_addr, size); if (DownloadsAtFence(device_addr, size)) { uncommitted_gpu_modified_ranges.Add(device_addr, size); @@ -1660,6 +1725,194 @@ void BufferCache<P>::MarkWrittenBuffer(BufferId buffer_id, DAddr device_addr, u3 } if (Tegra::g_nx2_ampere.load(std::memory_order_relaxed)) { Tegra::Nx2MarkGpuWritten(device_addr, size); + Nx2NoteListWrite(buffer_id, device_addr, size); + } +} + +template <class P> +void BufferCache<P>::Nx2NoteListWrite(BufferId buffer_id, DAddr device_addr, u64 size) { + if constexpr (!IS_OPENGL) { + if (size == 0 || !Tegra::g_nx2_has_command_list_pages.load(std::memory_order_relaxed)) { + return; + } + constexpr u64 page_bits = Tegra::Nx2WrittenPageBits; + const u64 last = (std::min<u64>)((device_addr + size - 1) >> page_bits, + Tegra::g_nx2_command_list_pages.size() * 64 - 1); + for (u64 page = device_addr >> page_bits; page <= last;) { + // Storage buffers can be large: skip 64 pages at a time where none is a list page. + const u64 word = + Tegra::g_nx2_command_list_pages[page / 64].load(std::memory_order_relaxed) >> + (page % 64); + if (word == 0) { + page = (page / 64 + 1) * 64; + continue; + } + if (word & 1) { + const DAddr page_addr = page << page_bits; + { + std::scoped_lock lock{nx2_list_mutex}; + Nx2ListPage& entry = nx2_list_pages[page_addr]; + ++entry.writes; + if (!nx2_collect_list_copies && !entry.draw_written) { + entry.draw_written = true; + LOG_INFO(HW_GPU, "Command list page {:#x} is written by draws too: its " + "lists drain the split", + page_addr); + } + } + if (nx2_collect_list_copies) { + nx2_list_copy_requests.emplace_back(buffer_id, page_addr); + } + } + ++page; + } + } +} + +template <class P> +bool BufferCache<P>::Nx2CopyListPages(u64 tick) { + if constexpr (IS_OPENGL) { + return false; + } else { + if (nx2_list_copy_requests.empty()) { + return false; + } + std::ranges::sort(nx2_list_copy_requests, {}, &std::pair<BufferId, DAddr>::second); + constexpr DAddr page_size = DAddr{1} << Tegra::Nx2WrittenPageBits; + bool recorded = false; + for (size_t i = 0; i < nx2_list_copy_requests.size(); ++i) { + const auto [buffer_id, page] = nx2_list_copy_requests[i]; + if (i + 1 < nx2_list_copy_requests.size() && + nx2_list_copy_requests[i + 1].second == page) { + continue; + } + Buffer& buffer = slot_buffers[buffer_id]; + const DAddr begin = (std::max<DAddr>)(page, buffer.CpuAddr()); + const DAddr end = + (std::min<DAddr>)(page + page_size, buffer.CpuAddr() + buffer.SizeBytes()); + if (begin >= end) { + continue; + } + Nx2Staging staging = runtime.DownloadStagingBuffer(page_size, true); + const BufferCopy copy{ + .src_offset = buffer.Offset(begin), + .dst_offset = staging.offset + (begin - page), + .size = end - begin, + }; + buffer.MarkUsage(copy.src_offset, copy.size); + runtime.CopyBuffer(staging.buffer, buffer, std::span{©, 1}, true); + recorded = true; + std::scoped_lock lock{nx2_list_mutex}; + Nx2ListPage& entry = nx2_list_pages[page]; + if (entry.copy) { + nx2_retired_list_copies.emplace_back(entry.copy_tick, std::move(*entry.copy)); + } + entry.copy = staging; + entry.copy_writes = entry.writes; + entry.copy_tick = tick; + entry.copy_begin = static_cast<u32>(begin - page); + entry.copy_end = static_cast<u32>(end - page); + } + nx2_list_copy_requests.clear(); + std::erase_if(nx2_retired_list_copies, [this](auto& retired) { + if (!runtime.IsFree(retired.first)) { + return false; + } + runtime.FreeDeferredStagingBuffer(retired.second); + return true; + }); + return recorded; + } +} + +template <class P> +auto BufferCache<P>::Nx2ReadListCopy(DAddr device_addr, std::span<u8> out) -> Nx2ListRead { + if constexpr (IS_OPENGL) { + return Nx2ListRead::Unknown; + } else { + constexpr DAddr page_size = DAddr{1} << Tegra::Nx2WrittenPageBits; + const DAddr page = device_addr & ~(page_size - 1); + const u64 offset = device_addr - page; + if (offset + out.size() > page_size) { + return Nx2ListRead::Unknown; + } + const auto current = [&](const Nx2ListPage& entry) { + return entry.copy && !entry.draw_written && entry.copy_writes == entry.writes && + offset >= entry.copy_begin && offset + out.size() <= entry.copy_end; + }; + u64 tick{}; + u64 writes{}; + { + std::scoped_lock lock{nx2_list_mutex}; + const auto it = nx2_list_pages.find(page); + if (it == nx2_list_pages.end()) { + return Nx2ListRead::Unknown; + } + const Nx2ListPage& entry = it->second; + // Pages the CPU writes the lists of (GPU-written once, long before) stay synced. + if (!entry.draw_written && entry.synced_writes == entry.writes) { + return Nx2ListRead::Synced; + } + if (!current(entry)) { + return Nx2ListRead::Unknown; + } + // The lists after the first of a batch find the copy done: read it at once. + if (runtime.IsFree(entry.copy_tick)) { + std::memcpy(out.data(), entry.copy->mapped_span.data() + offset, out.size()); + return Nx2ListRead::Copied; + } + tick = entry.copy_tick; + writes = entry.copy_writes; + } + // Submitted with the work that wrote the page (Nx2CopyListPages's caller flushes). + runtime.WaitSubmittedTick(tick); + std::scoped_lock lock{nx2_list_mutex}; + const auto it = nx2_list_pages.find(page); + if (it == nx2_list_pages.end() || !current(it->second) || it->second.copy_tick != tick || + it->second.copy_writes != writes) { + return Nx2ListRead::Unknown; + } + std::memcpy(out.data(), it->second.copy->mapped_span.data() + offset, out.size()); + return Nx2ListRead::Copied; + } +} + +template <class P> +void BufferCache<P>::Nx2NoteListSynced(DAddr page) { + if constexpr (!IS_OPENGL) { + std::scoped_lock lock{nx2_list_mutex}; + Nx2ListPage& entry = nx2_list_pages[page]; + entry.synced_writes = entry.writes; + } +} + +template <class P> +void BufferCache<P>::Nx2NoteListHostWrite(DAddr device_addr, u64 size) { + if constexpr (!IS_OPENGL) { + if (size == 0 || !Tegra::g_nx2_has_command_list_pages.load(std::memory_order_relaxed)) { + return; + } + constexpr u64 page_bits = Tegra::Nx2WrittenPageBits; + const u64 last = (std::min<u64>)((device_addr + size - 1) >> page_bits, + Tegra::g_nx2_command_list_pages.size() * 64 - 1); + for (u64 page = device_addr >> page_bits; page <= last; ++page) { + if (!((Tegra::g_nx2_command_list_pages[page / 64].load(std::memory_order_relaxed) >> + (page % 64)) & + 1)) { + continue; + } + std::scoped_lock lock{nx2_list_mutex}; + const auto it = nx2_list_pages.find(page << page_bits); + if (it == nx2_list_pages.end()) { + continue; + } + Nx2ListPage& entry = it->second; + const bool synced = entry.synced_writes == entry.writes; + ++entry.writes; + if (synced) { + entry.synced_writes = entry.writes; + } + } } } @@ -2014,6 +2267,7 @@ void BufferCache<P>::InlineMemoryImplementation(DAddr dest_address, size_t copy_ std::span<const u8> inlined_buffer) { ClearDownload(dest_address, copy_size); gpu_modified_ranges.Subtract(dest_address, copy_size); + MarkVertexBuffersStale(dest_address, copy_size); BufferId buffer_id = FindBuffer(dest_address, static_cast<u32>(copy_size), false); auto& buffer = slot_buffers[buffer_id]; diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 47f342932d..86c05c8f22 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -14,7 +14,9 @@ #include <memory> #include <mutex> #include <numeric> +#include <optional> #include <span> +#include <unordered_map> #include <vector> #include "common/container/unordered_map.h" @@ -57,7 +59,8 @@ constexpr u32 NUM_VERTEX_BUFFERS = 32; constexpr u32 NUM_TRANSFORM_FEEDBACK_BUFFERS = 4; constexpr u32 NUM_GRAPHICS_UNIFORM_BUFFERS = 18; constexpr u32 NUM_COMPUTE_UNIFORM_BUFFERS = 8; -constexpr u32 NUM_STORAGE_BUFFERS = 16; +// Native compute shaders bind up to 18 global memory ranges; the enable masks are 32 bits wide. +constexpr u32 NUM_STORAGE_BUFFERS = 32; constexpr u32 NUM_TEXTURE_BUFFERS = 32; constexpr u32 NUM_STAGES = 5; @@ -321,6 +324,28 @@ public: bool DMAClear(GPUVAddr src_address, u64 amount, u32 value); + /// Switch 2 GPU-written command lists (nx2.h, g_nx2_command_list_pages). While collecting (a + /// compute dispatch or a DMA copy being recorded), writes to command list pages are noted; + /// Nx2CopyListPages then records copies of those pages into host memory after the work. + void Nx2CollectListCopies(bool collect) { + nx2_collect_list_copies = collect; + if (!collect) { + nx2_list_copy_requests.clear(); + } + } + /// True when it recorded copies, which the GPU completes at `tick` (the caller submits). + bool Nx2CopyListPages(u64 tick); + enum class Nx2ListRead { Unknown, Copied, Synced }; + /// Front thread: Synced when guest memory holds the page `device_addr` lies in (flushed and + /// written by no GPU work since), else Copied with `out` filled from the copy of its last + /// write once the GPU is done with it. Unknown otherwise: the caller drains and flushes. + Nx2ListRead Nx2ReadListCopy(DAddr device_addr, std::span<u8> out); + /// Front thread, the split drained: the command list page was flushed to guest memory. + void Nx2NoteListSynced(DAddr page); + /// CPU and inline writes: they reach guest memory, so a synced page stays synced, but its copy + /// no longer holds the page. + void Nx2NoteListHostWrite(DAddr device_addr, u64 size); + /// Return true when a CPU region is modified from the GPU [[nodiscard]] bool IsRegionGpuModified(DAddr addr, size_t size); @@ -440,6 +465,10 @@ private: void MarkWrittenBuffer(BufferId buffer_id, DAddr device_addr, u32 size, bool needs_sync = false); + /// Counts a GPU write to the command list pages in the range (invalidating their copies) and, + /// while collecting, asks for new copies. + void Nx2NoteListWrite(BufferId buffer_id, DAddr device_addr, u64 size); + [[nodiscard]] BufferId FindBuffer(DAddr device_addr, u32 size, bool sparse_compatible); void SynchronizeBufferWrites(Buffer& buffer); @@ -497,6 +526,13 @@ private: void UpdateVertexBufferSlot(u32 index, const Binding& binding); + /// Flags the vertex buffers overlapping a written range so host copies derived from them are + /// rebuilt. + void MarkVertexBuffersStale(DAddr device_addr, u64 size); + + /// Re-dirties repacked vertex buffers whose source data was written since they were bound. + void RefreshRepackedVertexBuffers(); + void ClearDownload(DAddr base_addr, u64 size); void InlineMemoryImplementation(DAddr dest_address, size_t copy_size, @@ -517,6 +553,7 @@ private: u32 last_index_count = 0; u32 enabled_vertex_buffers_mask = 0; + u32 stale_vertex_buffers_mask = 0; u64 vertex_buffers_serial = 0; std::array<Binding, 32> v_buffer{}; @@ -604,6 +641,29 @@ private: // Covers the device address space (MaxwellDeviceTraits::device_virtual_bits). std::array<BufferId, ((1ULL << 36) >> CACHING_PAGEBITS)> page_table; Common::ScratchBuffer<u8> tmp_buffer; + + /// A command list page (Nx2CopyListPages): the GPU writes noted to it and the last copy. + using Nx2Staging = decltype(std::declval<Runtime&>().DownloadStagingBuffer(size_t{}, true)); + struct Nx2ListPage { + u64 writes{}; ///< GPU writes noted to the page + u64 copy_writes{}; ///< `writes` when the copy was made: the copy is current while equal + u64 copy_tick{}; ///< GPU tick that completes the copy + /// `writes` when the front last flushed the page: guest memory holds it while equal + u64 synced_writes{~u64{0}}; + u32 copy_begin{}; ///< Bytes of the page the copy holds (a buffer can end inside it) + u32 copy_end{}; + /// Written outside a compute dispatch or a DMA copy (a draw's storage writes) at least + /// once: the front can't tell from what it sent whether such a write is still queued, so + /// the page's lists always drain the split instead of reading a copy. + bool draw_written{}; + std::optional<Nx2Staging> copy; + }; + std::mutex nx2_list_mutex; ///< nx2_list_pages: the split's front reads the copies + std::unordered_map<DAddr, Nx2ListPage> nx2_list_pages; + /// Copies replaced while the GPU may still write them: freed once it is past their tick. + std::vector<std::pair<u64, Nx2Staging>> nx2_retired_list_copies; + boost::container::small_vector<std::pair<BufferId, DAddr>, 8> nx2_list_copy_requests; + bool nx2_collect_list_copies{}; }; } // namespace VideoCommon diff --git a/src/video_core/buffer_cache/uniform_stream_reuse.h b/src/video_core/buffer_cache/uniform_stream_reuse.h new file mode 100644 index 0000000000..7ba31c4717 --- /dev/null +++ b/src/video_core/buffer_cache/uniform_stream_reuse.h @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <array> +#include <concepts> +#include <cstddef> +#include <cstdlib> +#include <cstring> +#include <span> +#include <type_traits> + +#include "common/common_types.h" + +namespace VideoCommon { + +/// What a buffer cache runtime offers for stream uniform reuse. +/// - StreamUniformRef: a small trivially copyable description of one stream allocation (the +/// host buffer, its offset and whatever else the runtime needs to add it to a descriptor +/// payload again, e.g. the device address of the descriptor buffer path). +/// - LastStreamUniformRef(): the allocation made by the latest BindMappedUniformBuffer call. +/// - RebindStreamUniform(ref, size): adds `ref` to the descriptor payload exactly as +/// BindMappedUniformBuffer added the allocation the first time. +template <class R> +concept StreamUniformReusable = requires(R& runtime, u32 size) { + typename R::StreamUniformRef; + requires std::is_trivially_copyable_v<typename R::StreamUniformRef>; + { runtime.LastStreamUniformRef() } -> std::same_as<typename R::StreamUniformRef>; + runtime.RebindStreamUniform(runtime.LastStreamUniformRef(), size); + { runtime.CurrentTick() } -> std::convertible_to<u64>; +}; + +/// EDEN_NX2_UBOREUSE=0 turns the reuse off (every small uniform buffer is copied, as before). +[[nodiscard]] inline bool UniformStreamReuseEnabled() noexcept { + static const bool enabled = [] { + const char* const value = std::getenv("EDEN_NX2_UBOREUSE"); + return value == nullptr || value[0] != '0'; + }(); + return enabled; +} + +template <class Ref> +class UniformStreamReuse { +public: + static constexpr u32 MaxBytes = 4096; // BufferCache's DEFAULT_SKIP_CACHE_SIZE + static constexpr size_t NumStages = 5; + static constexpr size_t NumSlots = 18; // Maxwell constant buffers per stage + + struct Slot { + Ref ref{}; + u64 tick{}; + u32 size{}; // 0: nothing streamed yet + std::array<u8, MaxBytes> bytes{}; + }; + + void Reset() noexcept { + for (auto& stage : slots) { + for (Slot& slot : stage) { + slot.size = 0; + slot.tick = 0; + } + } + } + + std::array<std::array<Slot, NumSlots>, NumStages> slots{}; + std::array<u8, MaxBytes> scratch{}; +}; + +template <class Ref> +[[nodiscard]] inline UniformStreamReuse<Ref>& UniformStreamReuseState() noexcept { + static UniformStreamReuse<Ref> state; + return state; +} + +/// Forgets every slot. Call it when a buffer cache (and so its runtime, scheduler and stream +/// buffer) is created: ticks restart with a new scheduler and the old references are dead. +template <class Runtime> +inline void ResetUniformStreamReuse() noexcept { + if constexpr (StreamUniformReusable<Runtime>) { + UniformStreamReuseState<typename Runtime::StreamUniformRef>().Reset(); + } +} + +/// Binds the small uniform buffer at `device_addr` for (stage, cbuf `index`) through the stream +/// buffer, reusing the slot's previous allocation when the bytes and the command buffer are the +/// same. Returns false (nothing bound) when the buffer does not qualify, so the caller binds it +/// the classic way. +template <class Runtime, class DeviceMemory> +[[nodiscard]] bool BindSmallUniformReusing(Runtime& runtime, DeviceMemory& device_memory, + size_t stage, u32 index, u32 binding_index, + u64 device_addr, u32 size) { + using Reuse = UniformStreamReuse<typename Runtime::StreamUniformRef>; + if (size == 0 || size > Reuse::MaxBytes || stage >= Reuse::NumStages || + index >= Reuse::NumSlots) { + return false; + } + Reuse& reuse = UniformStreamReuseState<typename Runtime::StreamUniformRef>(); + typename Reuse::Slot& slot = reuse.slots[stage][index]; + u8* const scratch = reuse.scratch.data(); + device_memory.ReadBlockUnsafe(device_addr, scratch, size); + + const bool same_bytes = + slot.size == size && std::memcmp(slot.bytes.data(), scratch, size) == 0; + if (same_bytes && slot.tick == runtime.CurrentTick()) { + runtime.RebindStreamUniform(slot.ref, size); + return true; + } + const std::span<u8> span = runtime.BindMappedUniformBuffer(stage, binding_index, size); + std::memcpy(span.data(), scratch, size); + slot.ref = runtime.LastStreamUniformRef(); + // After the allocation: the tick the stream region is tagged with is the one current now. + slot.tick = runtime.CurrentTick(); + if (!same_bytes) { + slot.size = size; + std::memcpy(slot.bytes.data(), scratch, size); + } + return true; +} + +} // namespace VideoCommon diff --git a/src/video_core/control/scheduler.cpp b/src/video_core/control/scheduler.cpp index dc42906752..97d2dcd482 100644 --- a/src/video_core/control/scheduler.cpp +++ b/src/video_core/control/scheduler.cpp @@ -48,6 +48,7 @@ void Scheduler::Push(GPU& gpu, s32 channel, CommandList&& entries) { std::find(pending_channels.begin(), pending_channels.end(), channel_state) == pending_channels.end()) { pending_channels.push_back(channel_state); + any_pending.store(true, std::memory_order_release); } if (!pending_channels.empty()) { ResumePending(gpu); @@ -77,6 +78,7 @@ void Scheduler::ResumePending(GPU& gpu) { std::erase_if(pending_channels, [](const auto& state) { return !state->payload->dma_pusher.HasPendingAcquire(); }); + any_pending.store(!pending_channels.empty(), std::memory_order_release); } void Scheduler::DeclareChannel(std::shared_ptr<ChannelState> new_channel) { diff --git a/src/video_core/control/scheduler.h b/src/video_core/control/scheduler.h index 9a0f1b919f..b1aaf46b60 100644 --- a/src/video_core/control/scheduler.h +++ b/src/video_core/control/scheduler.h @@ -6,6 +6,7 @@ #pragma once +#include <atomic> #include <memory> #include <mutex> #include <vector> @@ -30,6 +31,10 @@ public: [[nodiscard]] bool HasPendingChannels() const { return !pending_channels.empty(); } + /// HasPendingChannels for threads other than the GPU thread. + [[nodiscard]] bool HasPendingChannelsAnyThread() const { + return any_pending.load(std::memory_order_acquire); + } void DeclareChannel(std::shared_ptr<ChannelState> new_channel); @@ -39,6 +44,7 @@ private: ::Common::unordered_map<s32, std::shared_ptr<ChannelState>> channels; std::mutex scheduling_guard; std::vector<std::shared_ptr<ChannelState>> pending_channels; ///< Suspended on an acquire + std::atomic<bool> any_pending{}; }; } // namespace Control diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 1144488c91..6a79e0570e 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -9,6 +9,7 @@ #include <cstdlib> #include <cstdio> +#include "common/scope_exit.h" #include "common/settings.h" #include "core/core.h" #include "core/memory.h" @@ -161,6 +162,9 @@ bool DmaPusher::Step() { // read: their words at submit time are an older frame's. const bool submitted = command_resume_offset == 0 && dma_pushbuffer_subindex < command_list.submitted_offsets.size() && + (command_list.snapshot_suspensions & 1) == 0 && + command_list.snapshot_suspensions == + suspensions.load(std::memory_order_acquire) && !IsGpuWrittenCommands(dma_state.dma_get, size_t{header.size} * sizeof(u32)) && !(use_safe && memory_manager.IsMemoryDirty(dma_state.dma_get, @@ -178,6 +182,11 @@ bool DmaPusher::Step() { RecordParsedEntry(header, commands); command_resume_offset += ProcessCommands(commands.subspan(command_resume_offset), command_resume_offset); + } else if (ReadGpuWrittenCommands(dma_state.dma_get, static_cast<u32>(header.size))) { + const std::span<const CommandHeader> commands{command_headers.data(), header.size}; + RecordParsedEntry(header, commands); + command_resume_offset += + ProcessCommands(commands.subspan(command_resume_offset), command_resume_offset); } else { FlushGpuWrittenCommands(dma_state.dma_get, size_t{header.size} * sizeof(u32)); Tegra::Memory::GpuGuestMemory<Tegra::CommandHeader, Tegra::Memory::GuestMemoryFlags::UnsafeRead>headers(memory_manager, dma_state.dma_get, header.size, &command_headers); @@ -382,6 +391,15 @@ void DmaPusher::CallMethod(u32 argument) { ReportUnboundSubchannel(); return; } + // Compute dispatches and DMA copies write the command lists ReadGpuWrittenCommands reads. + const auto engine_type = subchannel_type[dma_state.subchannel]; + const bool writer = engine_type == Engines::EngineTypes::KeplerCompute || + engine_type == Engines::EngineTypes::MaxwellDMA; + SCOPE_EXIT { + if (writer) { + split.NoteWriter(); + } + }; if (split.Active()) { const GPUVAddr segment = dma_state.dma_get + dma_state.dma_word_offset; if (split.FrontMacros()) { @@ -432,6 +450,15 @@ void DmaPusher::CallMultiMethod(const u32* base_start, u32 num_methods) { ReportUnboundSubchannel(); return; } + // Compute dispatches and DMA copies write the command lists ReadGpuWrittenCommands reads. + const auto engine_type = subchannel_type[dma_state.subchannel]; + const bool writer = engine_type == Engines::EngineTypes::KeplerCompute || + engine_type == Engines::EngineTypes::MaxwellDMA; + SCOPE_EXIT { + if (writer) { + split.NoteWriter(); + } + }; if (split.Active()) { const GPUVAddr segment = dma_state.dma_get + dma_state.dma_word_offset; if (split.FrontMacros()) { @@ -476,18 +503,86 @@ bool DmaPusher::IsGpuWrittenCommands(GPUVAddr address, size_t size) { return false; } +bool DmaPusher::ReadGpuWrittenCommands(GPUVAddr address, u32 count) { + // The pages are marked as command list pages: from the next write on, the buffer cache copies + // them to host memory right after the dispatch or copy that writes them and submits. The + // front then waits only for the back to run that work and for the GPU to finish the copy, not + // for everything queued and a cache flush (FlushGpuWrittenCommands: ~400 GPU round trips a + // second in a Mario Kart World race, during which the back had nothing to do). Pages + // FlushGpuWrittenCommands flushed whole and no GPU work wrote since are read from guest + // memory. + if (!Tegra::g_nx2_ampere.load(std::memory_order_relaxed) || count == 0 || + rasterizer == nullptr) { + return false; + } + const GPUVAddr end = address + u64{count} * sizeof(u32); + bool written = false; + for (GPUVAddr page = address & ~GPUVAddr{0xfff}; page < end; page += 0x1000) { + if (const auto device = memory_manager.GpuToCpuAddress(page); + device && Tegra::Nx2IsGpuWritten(*device)) { + Tegra::Nx2MarkCommandListPage(*device); + written = true; + } + } + if (!written) { + return false; + } + if (auto& split = VideoCommon::GpuSplit::Get(); split.Active()) { + split.WaitForWriters(); + } + using GpuWrittenList = VideoCore::RasterizerInterface::GpuWrittenList; + command_headers.resize_destructive(count); + u8* const out = reinterpret_cast<u8*>(command_headers.data()); + for (GPUVAddr chunk = address; chunk < end;) { + const GPUVAddr chunk_end = (std::min)(end, (chunk & ~GPUVAddr{0xfff}) + 0x1000); + const std::span<u8> part{out + (chunk - address), static_cast<size_t>(chunk_end - chunk)}; + const auto device = memory_manager.GpuToCpuAddress(chunk); + if (!device) { + return false; + } + GpuWrittenList where = GpuWrittenList::Synced; + if (Tegra::Nx2IsGpuWritten(*device)) { + where = rasterizer->ReadGpuWrittenCommandList(*device, part); + } + if (where == GpuWrittenList::Unknown) { + return false; + } + if (where == GpuWrittenList::Synced) { + memory_manager.ReadBlockUnsafe(chunk, part.data(), part.size()); + } + chunk = chunk_end; + } + return true; +} + void DmaPusher::FlushGpuWrittenCommands(GPUVAddr address, size_t size) { // Title 1 runs small command lists its culling dispatches fill in each frame. Accurate GPU // mode reads every command list through the caches (safe reads); here only the ones on pages // the GPU has written are, or their draws run with an older frame's words (trees vanish). + // These are the lists ReadGpuWrittenCommands can't take from a copy or guest memory (a page's + // first list, or pages draws write too). if (!IsGpuWrittenCommands(address, size)) { return; } // The caches belong to the split's back: it first runs what the front queued (the dispatch - // that writes these words among it). + // that writes these words among it). The whole pages are flushed: their next lists read + // guest memory until GPU work writes them again. VideoCommon::GpuSplit::Get().Drain(); - if (memory_manager.IsMemoryDirty(address, size, VideoCommon::CacheType::BufferCache)) { - memory_manager.FlushRegion(address, size, VideoCommon::CacheType::BufferCache); + const GPUVAddr first_page = address & ~GPUVAddr{0xfff}; + const GPUVAddr end_page = (address + size + 0xfff) & ~GPUVAddr{0xfff}; + if (memory_manager.IsMemoryDirty(first_page, end_page - first_page, + VideoCommon::CacheType::BufferCache)) { + memory_manager.FlushRegion(first_page, end_page - first_page, + VideoCommon::CacheType::BufferCache); + } + if (rasterizer == nullptr) { + return; + } + for (GPUVAddr page = first_page; page < end_page; page += 0x1000) { + if (const auto device = memory_manager.GpuToCpuAddress(page); + device && Tegra::Nx2IsCommandListPage(*device)) { + rasterizer->NoteGpuWrittenCommandListSynced(*device); + } } } diff --git a/src/video_core/dma_pusher.h b/src/video_core/dma_pusher.h index e9dd5e2b6b..feab652bb5 100644 --- a/src/video_core/dma_pusher.h +++ b/src/video_core/dma_pusher.h @@ -7,6 +7,7 @@ #pragma once #include <array> +#include <atomic> #include <condition_variable> #include <optional> #include <span> @@ -121,6 +122,10 @@ struct CommandList final { /// executed instead of the memory, which the game may already be refilling for a later frame. std::vector<CommandHeader> submitted_words; std::vector<u32> submitted_offsets; + /// DmaPusher::suspensions when the words were copied. A list copied during a suspension, or + /// that waited behind one, reads memory instead: games write the commands it runs before they + /// release the acquire. + u64 snapshot_suspensions = 0; }; /// FNV-1a over pushbuffer words (EDEN_NX2_PB_CHECK). @@ -139,6 +144,10 @@ inline u64 HashPushbufferWords(std::span<const u32> words) { /// details on this implementation. class DmaPusher final { public: + /// Counts suspensions on a semaphore acquire and the resumes after them, so it is odd while + /// the channel is suspended (see CommandList::snapshot_suspensions). + std::atomic<u64> suspensions{0}; + explicit DmaPusher(Core::System& system_, MemoryManager& memory_manager_, Control::ChannelState& channel_state_); ~DmaPusher(); @@ -188,6 +197,11 @@ public: void RecordParsedEntry(const CommandListHeader& header, std::span<const CommandHeader> commands); /// Whether the entry lies on device pages the GPU has written (NX2). bool IsGpuWrittenCommands(GPUVAddr address, size_t size); + /// Reads a command list on pages the GPU wrote into command_headers, from the copies the + /// buffer cache made after the work that wrote them or from guest memory for pages flushed + /// since. False when not all are current (or the list isn't GPU-written): the caller drains, + /// flushes and reads guest memory. + bool ReadGpuWrittenCommands(GPUVAddr address, u32 count); /// Brings command list words the GPU wrote (still only in host buffers) to guest memory. void FlushGpuWrittenCommands(GPUVAddr address, size_t size); diff --git a/src/video_core/engines/draw_manager.cpp b/src/video_core/engines/draw_manager.cpp index 038e5a2e69..564220b71c 100644 --- a/src/video_core/engines/draw_manager.cpp +++ b/src/video_core/engines/draw_manager.cpp @@ -72,11 +72,19 @@ void Maxwell3D::DrawManager::Clear(Maxwell3D& maxwell3d, u32 layer_count) { } } -void Maxwell3D::DrawManager::DrawDeferred(Maxwell3D& maxwell3d) { +void Maxwell3D::DrawManager::DrawDeferred(Maxwell3D& maxwell3d, bool loop_continues) { if (draw_state.draw_mode != DrawMode::Instance || draw_state.instance_count == 0) { + if (!loop_continues) { + draw_state.drawn_instances = 0; + } return; } - DrawEnd(maxwell3d, draw_state.instance_count + 1, true); + // The first draw of a batch also covers the loop's first instance; a batch that continues a + // loop split by a register write draws only the instances after the ones already drawn. + const bool continued = draw_state.drawn_instances != 0; + DrawEnd(maxwell3d, draw_state.instance_count + (continued ? 0 : 1), true); + draw_state.drawn_instances = + loop_continues ? draw_state.drawn_instances + draw_state.instance_count : 0; draw_state.instance_count = 0; } @@ -121,6 +129,7 @@ void Maxwell3D::DrawManager::DrawArrayInstanced(Maxwell3D& maxwell3d, Maxwell3D: draw_state.vertex_buffer.count = vertex_count; if (!subsequent) { draw_state.instance_count = 1; + draw_state.drawn_instances = 0; } draw_state.base_instance = draw_state.instance_count - 1; draw_state.draw_mode = DrawMode::Instance; @@ -220,6 +229,9 @@ void Maxwell3D::DrawManager::DrawEnd(Maxwell3D& maxwell3d, u32 instance_count, b [[fallthrough]]; case DrawMode::General: draw_state.base_instance = maxwell3d.regs.global_base_instance_index; + if (draw_state.draw_mode == DrawMode::Instance && draw_state.drawn_instances != 0) { + draw_state.base_instance += draw_state.drawn_instances + 1; + } draw_state.base_index = maxwell3d.regs.global_base_vertex_index; if (draw_state.draw_indexed) { draw_state.index_buffer = maxwell3d.regs.index_buffer; @@ -301,6 +313,8 @@ void Maxwell3D::DrawManager::UpdateTopology(Maxwell3D& maxwell3d) { } void Maxwell3D::DrawManager::ProcessDraw(Maxwell3D& maxwell3d, bool draw_indexed, u32 instance_count) { + draw_state.draw_element_count = + draw_indexed ? draw_state.index_buffer.count : draw_state.vertex_buffer.count; LOG_TRACE(HW_GPU, "called, topology={}, count={}", draw_state.topology, draw_indexed ? draw_state.index_buffer.count : draw_state.vertex_buffer.count); UpdateTopology(maxwell3d); if (maxwell3d.ShouldExecute()) { @@ -309,6 +323,7 @@ void Maxwell3D::DrawManager::ProcessDraw(Maxwell3D& maxwell3d, bool draw_indexed } void Maxwell3D::DrawManager::ProcessDrawIndirect(Maxwell3D& maxwell3d) { + draw_state.draw_element_count = 0; LOG_TRACE(HW_GPU, "called, topology={}, is_indexed={}, includes_count={}, buffer_size={}, max_draw_count={}", draw_state.topology, indirect_state.is_indexed, indirect_state.include_count, indirect_state.buffer_size, indirect_state.max_draw_counts); UpdateTopology(maxwell3d); if (maxwell3d.ShouldExecute()) { diff --git a/src/video_core/engines/fermi_2d.h b/src/video_core/engines/fermi_2d.h index 7fddf9fdc1..04a12fd351 100644 --- a/src/video_core/engines/fermi_2d.h +++ b/src/video_core/engines/fermi_2d.h @@ -70,6 +70,9 @@ public: ROP = 4, SrcCopyPremult = 5, BlendPremult = 6, + // Not a hardware operation: a copy that decodes sRGB-encoded values to linear, for the + // nx2 upscale fallback standing in for a program that decodes its output. + SrcCopySrgbToLinear = 7, }; enum class MemoryLayout : u32 { diff --git a/src/video_core/engines/kepler_compute.cpp b/src/video_core/engines/kepler_compute.cpp index 4732167369..925b1b2ca1 100644 --- a/src/video_core/engines/kepler_compute.cpp +++ b/src/video_core/engines/kepler_compute.cpp @@ -685,8 +685,32 @@ bool KeplerCompute::UpscaleInsteadOfNativeProgram() { result.addr_lower = static_cast<u32>(tic.Address()); return result; }; + // Title 4's program ends by decoding its result from sRGB to linear (the 0.04045 threshold + // and the 2.4 exponent as half-precision immediates of HSETP2 and HMUL2). The copy of its + // sRGB input left the frame gamma-encoded twice, washed out: decode in the copy when the + // program does. + static std::unordered_map<GPUVAddr, bool> decodes_srgb; + const GPUVAddr program = regs.code_loc.Address(); + const auto [known, inserted] = decodes_srgb.try_emplace(program, false); + if (inserted) { + bool threshold = false, exponent = false; + std::array<u32, 0x400> block{}; + for (u64 offset = 0; offset < 0x20000 && !(threshold && exponent); offset += sizeof(block)) { + if (!memory_manager.IsFullyMappedRange(program + offset, sizeof(block))) break; + memory_manager.ReadBlock(program + offset, block.data(), sizeof(block)); + for (size_t word = 0; word < block.size(); word += 4) { + const u32 opcode = block[word] & 0xfff; + threshold |= opcode == 0x434 && block[word + 1] == 0x292d292d; + exponent |= opcode == 0x832 && block[word + 1] == 0x40cc40cc; + } + } + known->second = threshold && exponent; + LOG_INFO(HW_GPU, "Upscale fallback for program {:#x}: {}", program, + known->second ? "decodes sRGB like the program" : "plain copy"); + } const Fermi2D::Config config{ - .operation = Fermi2D::Operation::SrcCopy, + .operation = known->second ? Fermi2D::Operation::SrcCopySrgbToLinear + : Fermi2D::Operation::SrcCopy, .filter = Fermi2D::Filter::Bilinear, .must_accelerate = true, .dst_x0 = 0, diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 9731f54584..92313988bd 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -342,7 +342,7 @@ void Maxwell3D::ProcessDirtyRegisters(u32 method, u32 argument) { // the state they were begun with: NVN's draw macros reset the base instance (and its copy in // c[0][0x644]) right after the loop, before the macro returns. if (draw_manager.HasDeferredDraw() && !DrawManager::IsInstanceLoopMethod(method)) [[unlikely]] { - draw_manager.DrawDeferred(*this); + draw_manager.DrawDeferred(*this, true); } regs.reg_array[method] = argument; for (auto const& table : dirty.tables) @@ -518,6 +518,10 @@ void Maxwell3D::DumpRecentWrites(u32 method) { } LOG_ERROR(HW_GPU, "Write to {:#x} while macro {:#x} collects parameters ({} so far); last writes:", method, executing_macro, macro_params.size()); + if (!record_write_history) { + LOG_ERROR(HW_GPU, "Register write history disabled; EDEN_NX2_3D_HISTORY=1 enables it"); + return; + } for (size_t i = 0; i < recent_writes.size(); ++i) { const auto& w = recent_writes[(recent_write_index + i) % recent_writes.size()]; if (w.amount != 0) { @@ -537,7 +541,7 @@ void Maxwell3D::CallMultiMethod(Core::System& system, u32 method, const u32* bas } // As in ProcessDirtyRegisters: the paths below write without it. if (draw_manager.HasDeferredDraw() && !DrawManager::IsInstanceLoopMethod(method)) [[unlikely]] { - draw_manager.DrawDeferred(*this); + draw_manager.DrawDeferred(*this, true); } switch (method) { case MAXWELL3D_REG_INDEX(const_buffer.buffer): @@ -818,7 +822,6 @@ struct Maxwell3D::FrontState { Regs::ShadowRamControl control{}; u32 executing_macro{}; std::vector<u32> parameters; - std::vector<u32> fifo; u64 calls{}; /// A method the draw manager handles was sent since the back last ran DrawDeferred: only /// then can it have a deferred instanced draw to issue at the end of a macro. @@ -1095,9 +1098,9 @@ void Maxwell3D::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split engine.native_jit = std::make_shared<TuringMacroJit>(); } engine.native_jit->Prepare(engine.native_code, engine.native_code_generation); - fs.fifo.assign(fs.parameters.begin(), fs.parameters.end()); + const size_t parameter_count = fs.parameters.size(); const auto result = engine.native_jit->Execute( - start, fs.fifo, std::span<u32>{native_data_ram}, + start, fs.parameters, std::span<u32>{native_data_ram}, [&fs](u32 address) { return address < Regs::NUM_REGS ? fs.regs[address] : 0U; }, [&](u32 address, u32 value) { if (address >= MacroRegistersStart) { @@ -1113,7 +1116,7 @@ void Maxwell3D::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split if (!result.success) { engine.native_execution_failed = true; LOG_ERROR(HW_GPU, "Native MME (JIT, split front) failed: start={:#x}, consumed={}/{}, error={}", - start, result.consumed, fs.parameters.size(), result.error); + start, result.consumed, parameter_count, result.error); } } fs.parameters.clear(); @@ -1148,7 +1151,7 @@ void Maxwell3D::FrontDmaRead(VideoCommon::GpuSplit& split, FrontState& fs, u32 m for (u32 i = 0; i < words; ++i) { const u32 word = memory_manager.Read<u32>(source + u64{i} * sizeof(u32)); if (method == 0x158) { - fs.fifo.push_back(word); + fs.parameters.push_back(word); } else if (ram + i < native_data_ram.size()) { native_data_ram[ram + i] = word; } diff --git a/src/video_core/engines/maxwell_3d.h b/src/video_core/engines/maxwell_3d.h index af0e37978e..7646ddbbe3 100644 --- a/src/video_core/engines/maxwell_3d.h +++ b/src/video_core/engines/maxwell_3d.h @@ -3064,11 +3064,14 @@ public: Maxwell3D::Regs::PrimitiveTopology topology{}; DrawMode draw_mode{}; bool draw_indexed{}; + u32 draw_element_count{}; // Zero for indirect draws whose count is not known yet. u32 base_index{}; Maxwell3D::Regs::VertexBuffer vertex_buffer; Maxwell3D::Regs::IndexBuffer index_buffer; u32 base_instance{}; u32 instance_count{}; + /// Deferred instances already drawn by flushes in the middle of the current loop. + u32 drawn_instances{}; std::vector<u8> inline_index_draw_indexes; }; struct DrawTextureState { @@ -3095,7 +3098,8 @@ public: }; void ProcessMethodCall(Maxwell3D& maxwell3d, u32 method, u32 argument); void Clear(Maxwell3D& maxwell3d, u32 layer_count); - void DrawDeferred(Maxwell3D& maxwell3d); + /// Draws the waiting instances. loop_continues is set when the loop goes on afterwards. + void DrawDeferred(Maxwell3D& maxwell3d, bool loop_continues = false); /// Instances after the first wait to be drawn together (DrawDeferred). [[nodiscard]] bool HasDeferredDraw() const noexcept { return draw_state.draw_mode == DrawMode::Instance && draw_state.instance_count != 0; @@ -3349,7 +3353,17 @@ private: }; std::array<RecentWrite, 48> recent_writes{}; size_t recent_write_index = 0; + const bool record_write_history{ +#ifdef NDEBUG + std::getenv("EDEN_NX2_3D_HISTORY") != nullptr +#else + true +#endif + }; void RecordWrite(u32 method, u32 argument, u32 amount, u32 pending) { + if (!record_write_history) { + return; + } recent_writes[recent_write_index++ % recent_writes.size()] = {method, argument, amount, pending}; } void DumpRecentWrites(u32 method); diff --git a/src/video_core/engines/puller.cpp b/src/video_core/engines/puller.cpp index 0bb1809aa3..37ac3ec851 100644 --- a/src/video_core/engines/puller.cpp +++ b/src/video_core/engines/puller.cpp @@ -116,6 +116,7 @@ bool Puller::ResumeAcquire(DmaPusher& dma_pusher) { return false; } pending_acquire.reset(); + dma_pusher.suspensions.fetch_add(1, std::memory_order_release); Nx2SemaphoreWaiters::Get().Remove(this); return true; } @@ -257,6 +258,7 @@ void Puller::ProcessSemaphoreTriggerMethod(DmaPusher& dma_pusher) { dma_pusher.rasterizer->ReleaseFences(); if (!waiters.Latched(this) && !acquire.Matches(ReadSemaphoreWord(dma_pusher, acquire.address))) { pending_acquire = acquire; + dma_pusher.suspensions.fetch_add(1, std::memory_order_release); VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::AcquireWaits, 1); } else { waiters.Remove(this); diff --git a/src/video_core/gpu.cpp b/src/video_core/gpu.cpp index 53f8f063f7..6dbabcb43a 100644 --- a/src/video_core/gpu.cpp +++ b/src/video_core/gpu.cpp @@ -210,6 +210,10 @@ struct GPU::Impl { gpu_thread.Run(std::move(func), is_async); } + void MapInOrder(std::function<void()>&& func) { + gpu_thread.Run(std::move(func), is_async, true); + } + /// Notify rasterizer that any caches of the specified region should be flushed to Switch memory void FlushRegion(DAddr addr, u64 size) { gpu_thread.FlushRegion(addr, size, is_async); @@ -228,7 +232,9 @@ struct GPU::Impl { raster_area.start_address, raster_area.end_address, reads.load(std::memory_order_relaxed)); } - const u64 fence = RequestSyncOperation([this, &raster_area]() { + const u64 fence = RequestSyncOperation([this, raster_area]() { + // Apply collected guest writes before downloading GPU data over guest memory. + InvalidateGPUCache(); renderer->ReadRasterizer()->FlushRegion(raster_area.start_address, raster_area.end_address - raster_area.start_address); }); gpu_thread.TickGPU(is_async); @@ -505,6 +511,10 @@ bool GPU::IsAsync() const { return impl->is_async; } +bool GPU::HasSuspendedChannels() const { + return impl->scheduler.HasPendingChannelsAnyThread(); +} + bool GPU::UseNvdec() const { return impl->use_nvdec; } @@ -549,6 +559,10 @@ void GPU::RunInOrder(std::function<void()>&& func) { impl->RunInOrder(std::move(func)); } +void GPU::MapInOrder(std::function<void()>&& func) { + impl->MapInOrder(std::move(func)); +} + void GPU::InvalidateRegion(DAddr addr, u64 size, bool preserve_gpu_writes) { impl->InvalidateRegion(addr, size, preserve_gpu_writes); } diff --git a/src/video_core/gpu.h b/src/video_core/gpu.h index 3a6546b41b..d37dfa5990 100644 --- a/src/video_core/gpu.h +++ b/src/video_core/gpu.h @@ -217,6 +217,9 @@ public: [[nodiscard]] bool IsAsync() const; + /// Whether a channel is suspended on a semaphore acquire (callable from any thread). + [[nodiscard]] bool HasSuspendedChannels() const; + [[nodiscard]] bool UseNvdec() const; void RendererFrameEndNotify(); @@ -254,6 +257,10 @@ public: /// Runs func on the GPU thread after the command lists submitted so far. void RunInOrder(std::function<void()>&& func); + /// RunInOrder for GPU memory maps: also done before the GPU thread reads the command lists + /// submitted later (with the GPU split, on its front rather than its back). + void MapInOrder(std::function<void()>&& func); + /// Notify rasterizer that any caches of the specified region should be invalidated void InvalidateRegion(DAddr addr, u64 size, bool preserve_gpu_writes = false); diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index ac0d3f25f1..ef0cdae123 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -138,6 +138,7 @@ void GpuSplit::Stop() { // never ran, it waited on its syncpoint forever), and the engines it held were freed. ring.reset(); write_position = 0; + last_writer_position = 0; published_local = 0; consumed_cache = 0; publish_every = PublishEvery; @@ -216,6 +217,28 @@ void GpuSplit::Drain() { } } +void GpuSplit::WaitForWriters() { + const u64 target = std::min(last_writer_position, write_position); + consumed_cache = consumed.load(std::memory_order_acquire); + if (consumed_cache >= target) { + return; + } + wait_target.store(target, std::memory_order_seq_cst); + Publish(); + for (u32 spins = 0;; ++spins) { + consumed_cache = consumed.load(std::memory_order_acquire); + if (consumed_cache >= target) { + wait_target.store(~u64{0}, std::memory_order_relaxed); + return; + } + if (spins < 16384) { + Pause(); + } else { + std::this_thread::yield(); + } + } +} + void GpuSplit::SwitchEngine(Tegra::Engines::EngineInterface* engine) { front_engine = engine; u64* const entry = Reserve(2); @@ -346,8 +369,11 @@ void GpuSplit::BackLoop(std::stop_token stop_token) { const size_t index = read_position & RingMask; const size_t size = Execute(&ring[index]); read_position += size != 0 ? size : RingWords - index; - // Hand ring space back now and then while working through a long batch. - if (read_position - last_store >= 4096 && read_position != available) { + // Hand ring space back now and then while working through a long batch, and at once + // when the front waits for this position (WaitForWriters). + if ((read_position - last_store >= 4096 || + read_position >= wait_target.load(std::memory_order_relaxed)) && + read_position != available) { consumed.store(read_position, std::memory_order_release); last_store = read_position; } diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index d6f91e4141..c7c7118481 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -100,6 +100,13 @@ public: /// Waits until the back executed everything sent so far. void Drain(); + /// Compute or copy work was just sent: what can write GPU-written command lists. + void NoteWriter() { + last_writer_position = write_position; + } + /// Waits until the back executed the compute and copy work sent so far (not what came after). + void WaitForWriters(); + private: enum class Kind : u64 { Wrap, ///< The rest of the ring is unused: continue at its start @@ -156,6 +163,7 @@ private: // Front side. u64 write_position{}; + u64 last_writer_position{}; u64 published_local{}; u64 consumed_cache{}; /// Words between publishes: small right after a drain, while the back has nothing to do. @@ -169,6 +177,8 @@ private: alignas(64) std::atomic<u64> published{}; alignas(64) std::atomic<u64> consumed{}; + /// A position the front waits for (WaitForWriters): the back stores `consumed` once past it. + alignas(64) std::atomic<u64> wait_target{~u64{0}}; alignas(64) std::atomic<bool> back_waiting{}; std::atomic<u32> wake{}; std::jthread back_thread; diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp index 55200b0647..3af8cb65a8 100644 --- a/src/video_core/gpu_thread.cpp +++ b/src/video_core/gpu_thread.cpp @@ -99,7 +99,12 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten } else if (std::holds_alternative<GPUTickCommand>(next.data)) { split.Defer([gpu = &system.GPU()] { gpu->TickWork(); }); } else if (auto* run = std::get_if<RunCommand>(&next.data)) { - split.DeferFunction(std::move(run->func)); + if (run->before_later_reads) { + split.Drain(); + run->func(); + } else { + split.DeferFunction(std::move(run->func)); + } } else if (const auto* flush = std::get_if<FlushRegionCommand>(&next.data)) { split.Defer([rasterizer_ptr, addr = flush->addr, size = flush->size] { rasterizer_ptr->FlushRegion(addr, size); @@ -155,8 +160,8 @@ void ThreadManager::TickGPU(bool is_async) { PushCommand(GPUTickCommand(), false, is_async); } -void ThreadManager::Run(std::function<void()>&& func, bool is_async) { - PushCommand(RunCommand{std::move(func)}, false, is_async); +void ThreadManager::Run(std::function<void()>&& func, bool is_async, bool before_later_reads) { + PushCommand(RunCommand{std::move(func), before_later_reads}, false, is_async); } void ThreadManager::InvalidateRegion(DAddr addr, u64 size) { diff --git a/src/video_core/gpu_thread.h b/src/video_core/gpu_thread.h index 796c0d89f6..927453dab0 100644 --- a/src/video_core/gpu_thread.h +++ b/src/video_core/gpu_thread.h @@ -80,6 +80,9 @@ struct GPUTickCommand final {}; /// Command to run a function on the GPU thread after the commands queued before it struct RunCommand final { std::function<void()> func; + /// With the GPU split, run on its front (after draining the back) instead of deferring to the + /// back, so command lists read after this one see what func did (GMMU maps). + bool before_later_reads{}; }; using CommandData = @@ -132,7 +135,7 @@ public: void TickGPU(bool is_async); /// Runs func on the GPU thread once the commands queued before it have been processed. - void Run(std::function<void()>&& func, bool is_async); + void Run(std::function<void()>&& func, bool is_async, bool before_later_reads = false); void NotifyShutdown(); diff --git a/src/video_core/host_shaders/CMakeLists.txt b/src/video_core/host_shaders/CMakeLists.txt index 9ff7563ff7..3dc26ec3ba 100644 --- a/src/video_core/host_shaders/CMakeLists.txt +++ b/src/video_core/host_shaders/CMakeLists.txt @@ -18,6 +18,7 @@ set(SHADER_FILES ${CMAKE_CURRENT_SOURCE_DIR}/astc_decoder.comp ${CMAKE_CURRENT_SOURCE_DIR}/bc_encoder.comp ${CMAKE_CURRENT_SOURCE_DIR}/blit_color_float.frag + ${CMAKE_CURRENT_SOURCE_DIR}/blit_color_srgb_to_linear.frag ${CMAKE_CURRENT_SOURCE_DIR}/block_linear_unswizzle_2d.comp ${CMAKE_CURRENT_SOURCE_DIR}/blit_color_msaa.frag ${CMAKE_CURRENT_SOURCE_DIR}/blit_depth.frag diff --git a/src/video_core/host_shaders/blit_color_srgb_to_linear.frag b/src/video_core/host_shaders/blit_color_srgb_to_linear.frag new file mode 100644 index 0000000000..1a38cb52a1 --- /dev/null +++ b/src/video_core/host_shaders/blit_color_srgb_to_linear.frag @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#version 450 + +layout(binding = 0) uniform sampler2D tex; + +layout(location = 0) in vec2 texcoord; +layout(location = 0) out vec4 color; + +// Copy of an sRGB-encoded source into a linear destination: the piecewise sRGB decode on the +// color channels, alpha unchanged. +void main() { + const vec4 value = textureLod(tex, texcoord, 0); + const vec3 low = value.rgb / 12.92; + const vec3 high = pow((value.rgb + 0.055) / 1.055, vec3(2.4)); + color = vec4(mix(high, low, lessThan(value.rgb, vec3(0.04045))), value.a); +} diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index db8c92adfd..92714d52d2 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -43,11 +43,43 @@ inline void Nx2MarkGpuWritten(u64 device_address, u64 size) { } } +// GPU-written device pages the front has read command lists from (same layout as above). The +// buffer cache copies these pages into host memory right after the compute or copy work that +// writes them and submits, so the front reads the copy once the GPU is done instead of draining +// the split and flushing the caches (BufferCache::Nx2ReadListCopy). +inline std::array<std::atomic<u64>, (u64{1} << (36 - Nx2WrittenPageBits)) / 64> g_nx2_command_list_pages{}; +inline std::atomic<bool> g_nx2_has_command_list_pages{false}; + +inline void Nx2MarkCommandListPage(u64 device_address) { + const u64 page = device_address >> Nx2WrittenPageBits; + if (page >= g_nx2_command_list_pages.size() * 64) { + return; + } + auto& word = g_nx2_command_list_pages[page / 64]; + const u64 bit = u64{1} << (page % 64); + if ((word.load(std::memory_order_relaxed) & bit) == 0) { + word.fetch_or(bit, std::memory_order_relaxed); + g_nx2_has_command_list_pages.store(true, std::memory_order_relaxed); + } +} + +[[nodiscard]] inline bool Nx2IsCommandListPage(u64 device_address) { + const u64 page = device_address >> Nx2WrittenPageBits; + if (page >= g_nx2_command_list_pages.size() * 64) { + return false; + } + return (g_nx2_command_list_pages[page / 64].load(std::memory_order_relaxed) >> (page % 64)) & 1; +} + /// A new emulation session starts: no page has been written by its GPU yet. inline void Nx2ClearGpuWrittenPages() { for (auto& word : g_nx2_gpu_written_pages) { word.store(0, std::memory_order_relaxed); } + for (auto& word : g_nx2_command_list_pages) { + word.store(0, std::memory_order_relaxed); + } + g_nx2_has_command_list_pages.store(false, std::memory_order_relaxed); } [[nodiscard]] inline bool Nx2IsGpuWritten(u64 device_address) { diff --git a/src/video_core/rasterizer_interface.h b/src/video_core/rasterizer_interface.h index 8486d44ac9..e9eaa2b3c2 100644 --- a/src/video_core/rasterizer_interface.h +++ b/src/video_core/rasterizer_interface.h @@ -94,6 +94,20 @@ public: virtual void FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType which = VideoCommon::CacheType::All) = 0; + /// Switch 2, from the GPU thread's front: where a GPU-written command list's words are. + enum class GpuWrittenList { + Unknown, ///< Neither: the caller drains, flushes the page and notes it synced + Copied, ///< `out` (within one 4 KiB page) is filled from the copy made after its writer + Synced, ///< Guest memory holds the page: no GPU work wrote it since it was flushed + }; + virtual GpuWrittenList ReadGpuWrittenCommandList([[maybe_unused]] DAddr addr, + [[maybe_unused]] std::span<u8> out) { + return GpuWrittenList::Unknown; + } + + /// Switch 2: the front flushed this command list page with the split drained. + virtual void NoteGpuWrittenCommandListSynced([[maybe_unused]] DAddr page) {} + /// Check if the the specified memory area requires flushing to CPU Memory. virtual bool MustFlushRegion(DAddr addr, u64 size, VideoCommon::CacheType which = VideoCommon::CacheType::All) = 0; diff --git a/src/video_core/renderer_opengl/gl_compute_pipeline.cpp b/src/video_core/renderer_opengl/gl_compute_pipeline.cpp index eb134e86b5..5fc10cfce2 100644 --- a/src/video_core/renderer_opengl/gl_compute_pipeline.cpp +++ b/src/video_core/renderer_opengl/gl_compute_pipeline.cpp @@ -106,19 +106,18 @@ void ComputePipeline::Configure() { const auto& cbufs{qmd.const_buffer_config}; const bool via_header_index{qmd.linked_tsc != 0}; const auto read_handle{[&](const auto& desc, u32 index) { - if constexpr (std::is_same_v<decltype(desc), const Shader::TextureDescriptor&>) { - if (desc.handle_program != ~0U) { - // Read by the shader from a storage buffer: computed for this dispatch. - return TexturePair(VideoCommon::EvaluateTextureHandle( - info, desc.handle_program, *gpu_memory, - [&](u32 cbuf) -> GPUVAddr { - return cbuf < cbufs.size() && - ((qmd.const_buffer_enable_mask >> cbuf) & 1) != 0 - ? cbufs[cbuf].Address() - : GPUVAddr{}; - }), - via_header_index); - } + if (desc.handle_program != ~0U) { + // Read by the shader from a storage buffer: computed for this dispatch. + return TexturePair(VideoCommon::EvaluateTextureHandle( + info, desc.handle_program, *gpu_memory, + [&](u32 cbuf) -> GPUVAddr { + return cbuf < cbufs.size() && + ((qmd.const_buffer_enable_mask >> cbuf) & + 1) != 0 + ? cbufs[cbuf].Address() + : GPUVAddr{}; + }), + via_header_index); } ASSERT(((qmd.const_buffer_enable_mask >> desc.cbuf_index) & 1) != 0); const u32 index_offset{index << desc.size_shift}; diff --git a/src/video_core/renderer_opengl/gl_graphics_pipeline.cpp b/src/video_core/renderer_opengl/gl_graphics_pipeline.cpp index 81950279d5..4e02f0569b 100644 --- a/src/video_core/renderer_opengl/gl_graphics_pipeline.cpp +++ b/src/video_core/renderer_opengl/gl_graphics_pipeline.cpp @@ -309,18 +309,16 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) { } const auto& cbufs{maxwell3d->state.shader_stages[stage].const_buffers}; const auto read_handle{[&](const auto& desc, u32 index) { - if constexpr (std::is_same_v<decltype(desc), const Shader::TextureDescriptor&>) { - if (desc.handle_program != ~0U) { - // Read by the shader from a storage buffer: computed for this draw. - return TexturePair(VideoCommon::EvaluateTextureHandle( - info, desc.handle_program, *gpu_memory, - [&](u32 cbuf) -> GPUVAddr { - return cbuf < cbufs.size() && cbufs[cbuf].enabled - ? cbufs[cbuf].address - : GPUVAddr{}; - }), - via_header_index); - } + if (desc.handle_program != ~0U) { + // Read by the shader from a storage buffer: computed for this draw. + return TexturePair(VideoCommon::EvaluateTextureHandle( + info, desc.handle_program, *gpu_memory, + [&](u32 cbuf) -> GPUVAddr { + return cbuf < cbufs.size() && cbufs[cbuf].enabled + ? cbufs[cbuf].address + : GPUVAddr{}; + }), + via_header_index); } ASSERT(cbufs[desc.cbuf_index].enabled); const u32 index_offset{index << desc.size_shift}; diff --git a/src/video_core/renderer_opengl/gl_shader_cache.cpp b/src/video_core/renderer_opengl/gl_shader_cache.cpp index ac750ab747..fd27fb1805 100644 --- a/src/video_core/renderer_opengl/gl_shader_cache.cpp +++ b/src/video_core/renderer_opengl/gl_shader_cache.cpp @@ -54,7 +54,7 @@ using VideoCommon::LoadPipelines; using VideoCommon::SerializePipeline; using Context = ShaderContext::Context; -constexpr u32 CACHE_VERSION = 15; +constexpr u32 CACHE_VERSION = 16; template <typename Container> auto MakeSpan(Container& container) { diff --git a/src/video_core/renderer_vulkan/blit_image.cpp b/src/video_core/renderer_vulkan/blit_image.cpp index edd151b563..506daef11e 100644 --- a/src/video_core/renderer_vulkan/blit_image.cpp +++ b/src/video_core/renderer_vulkan/blit_image.cpp @@ -11,6 +11,7 @@ #include "common/div_ceil.h" #include "common/settings.h" #include "video_core/host_shaders/blit_color_float_frag_spv.h" +#include "video_core/host_shaders/blit_color_srgb_to_linear_frag_spv.h" #include "video_core/host_shaders/blit_color_msaa_frag_spv.h" #include "video_core/host_shaders/blit_depth_frag_spv.h" #include "video_core/host_shaders/blit_depth_msaa_frag_spv.h" @@ -670,6 +671,7 @@ BlitImageHelper::BlitImageHelper(const Device& device_, Scheduler& scheduler_, full_screen_vert(BuildShader(device, FULL_SCREEN_TRIANGLE_VERT_SPV)), blit_color_to_color_frag(BuildShader(device, BLIT_COLOR_FLOAT_FRAG_SPV)), temporal_upscale_frag(BuildShader(device, TEMPORAL_UPSCALE_FRAG_SPV)), + blit_color_srgb_to_linear_frag(BuildShader(device, BLIT_COLOR_SRGB_TO_LINEAR_FRAG_SPV)), blit_color_msaa_frag(BuildShader(device, BLIT_COLOR_MSAA_FRAG_SPV)), blit_depth_stencil_frag(device.IsExtShaderStencilExportSupported() ? BuildShader(device, VULKAN_BLIT_DEPTH_STENCIL_FRAG_SPV) @@ -1331,7 +1333,10 @@ VkPipeline BlitImageHelper::FindOrEmplaceColorPipeline(const BlitImagePipelineKe } blit_color_keys.push_back(key); - const std::array stages = MakeStages(*full_screen_vert, *blit_color_to_color_frag); + const bool srgb_to_linear = + key.operation == Tegra::Engines::Fermi2D::Operation::SrcCopySrgbToLinear; + const std::array stages = MakeStages( + *full_screen_vert, srgb_to_linear ? *blit_color_srgb_to_linear_frag : *blit_color_to_color_frag); const VkPipelineColorBlendAttachmentState blend_attachment{ .blendEnable = VK_FALSE, .srcColorBlendFactor = VK_BLEND_FACTOR_ZERO, diff --git a/src/video_core/renderer_vulkan/blit_image.h b/src/video_core/renderer_vulkan/blit_image.h index 303beeea5c..4741f9fe45 100644 --- a/src/video_core/renderer_vulkan/blit_image.h +++ b/src/video_core/renderer_vulkan/blit_image.h @@ -229,6 +229,7 @@ private: vk::ShaderModule full_screen_vert; vk::ShaderModule blit_color_to_color_frag; vk::ShaderModule temporal_upscale_frag; + vk::ShaderModule blit_color_srgb_to_linear_frag; vk::ShaderModule blit_color_msaa_frag; vk::ShaderModule blit_depth_stencil_frag; vk::ShaderModule blit_depth_frag; diff --git a/src/video_core/renderer_vulkan/fixed_pipeline_state.cpp b/src/video_core/renderer_vulkan/fixed_pipeline_state.cpp index d1f78c0034..76a564aae9 100644 --- a/src/video_core/renderer_vulkan/fixed_pipeline_state.cpp +++ b/src/video_core/renderer_vulkan/fixed_pipeline_state.cpp @@ -269,7 +269,7 @@ void FixedPipelineState::Refresh(Tegra::Engines::Maxwell3D& maxwell3d, DynamicFe if (xfb_enabled) { RefreshXfbState(xfb_state, regs); } - if (!dynamic_vertex_input && ExperimentalStaticVertexRepackEnabled()) { + if (!dynamic_vertex_input && features.has_static_vertex_repack) { const u32 repack_mask = VertexRepackMask(regs); // Refresh from guest values even when only the stride changed: offsets must not accumulate. for (size_t index = 0; index < attributes.size(); ++index) { diff --git a/src/video_core/renderer_vulkan/fixed_pipeline_state.h b/src/video_core/renderer_vulkan/fixed_pipeline_state.h index 36a4b4b7a2..1fe5cd75f3 100644 --- a/src/video_core/renderer_vulkan/fixed_pipeline_state.h +++ b/src/video_core/renderer_vulkan/fixed_pipeline_state.h @@ -33,6 +33,7 @@ struct DynamicFeatures { bool has_dynamic_state3_logic_op_enable; bool has_dynamic_state3_line_stipple_enable; bool has_dynamic_vertex_input; + bool has_static_vertex_repack; bool has_color_write_enable; bool has_provoking_vertex; bool has_provoking_vertex_first_mode; diff --git a/src/video_core/renderer_vulkan/vertex_repack.h b/src/video_core/renderer_vulkan/vertex_repack.h index b0849c25c7..493eb41bd1 100644 --- a/src/video_core/renderer_vulkan/vertex_repack.h +++ b/src/video_core/renderer_vulkan/vertex_repack.h @@ -2,6 +2,7 @@ #pragma once +#include <algorithm> #include <cstdlib> #include <cstring> #include <limits> @@ -14,12 +15,21 @@ constexpr bool VertexRepackInputSupported(bool dynamic_input, bool experimental_ return dynamic_input || experimental_static; } -inline bool ExperimentalStaticVertexRepackEnabled() { - static const bool enabled = [] { - const char* value = std::getenv("EGLANTINE_EXPERIMENTAL_STATIC_VERTEX_REPACK"); - return value && std::strcmp(value, "1") == 0; - }(); - return enabled; +// Pipelines without dynamic vertex input repack when the device cannot fetch the 69-byte stride. +// EGLANTINE_EXPERIMENTAL_STATIC_VERTEX_REPACK=1 forces the repack on, =0 forces it off. +inline bool StaticVertexRepackSelected(const char* override_value, u32 stride_alignment) { + if (override_value && std::strcmp(override_value, "1") == 0) { + return true; + } + if (override_value && std::strcmp(override_value, "0") == 0) { + return false; + } + return stride_alignment > 1 && 69 % stride_alignment != 0; +} + +inline bool StaticVertexRepackEnabled(u32 stride_alignment) { + return StaticVertexRepackSelected(std::getenv("EGLANTINE_EXPERIMENTAL_STATIC_VERTEX_REPACK"), + stride_alignment); } struct VertexRepackParameters { @@ -31,8 +41,11 @@ struct VertexRepackParameters { u32 dispatch_groups; }; -inline std::optional<VertexRepackParameters> MakeVertexRepackParameters( - u32 offset, u32 size, u64 alignment, u64 max_storage_range, u32 max_dispatch_groups) { +inline std::optional<VertexRepackParameters> MakeVertexRepackParameters(u32 offset, u32 size, + u64 alignment, + u64 max_storage_range, + u32 max_dispatch_groups, + u64 buffer_size) { if (size == 0 || alignment == 0) { return std::nullopt; } @@ -40,9 +53,11 @@ inline std::optional<VertexRepackParameters> MakeVertexRepackParameters( const u64 output_size = vertices * 72; const u64 base = u64{offset} / alignment * alignment; const u64 bias = offset - base; - const u64 range = (bias + size + 3) / 4 * 4; + // The word-rounded range must not run past the end of the source buffer + const u64 available = buffer_size > base ? buffer_size - base : 0; + const u64 range = (std::min)((bias + size + 3) / 4 * 4, available); const u64 groups = (vertices * 18 + 127) / 128; - if (output_size > std::numeric_limits<u32>::max() || + if (range < bias + size || output_size > std::numeric_limits<u32>::max() || range > std::numeric_limits<u32>::max() || output_size > max_storage_range || range > max_storage_range || groups > max_dispatch_groups) { return std::nullopt; diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp index fe0ab3a28e..43cb7d9414 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp @@ -365,6 +365,10 @@ BufferCacheRuntime::BufferCacheRuntime(const Device& device_, MemoryAllocator& m LOG_INFO(Render_Vulkan, "69-byte vertex layout repacking {} ({})", enable_vertex_repack ? "enabled" : "disabled", "portable Vulkan layout fallback"); + static_vertex_repack = + StaticVertexRepackEnabled(device.GetMinVertexInputBindingStrideAlignment()); + LOG_INFO(Render_Vulkan, "Vertex repacking without dynamic vertex input {}", + static_vertex_repack ? "enabled" : "disabled"); if (enable_vertex_repack) { vertex_repack_pass = std::make_unique<VertexRepackPass>( device, scheduler, descriptor_pool, staging_pool, compute_pass_descriptor_queue); @@ -387,14 +391,13 @@ BufferCacheRuntime::BufferCacheRuntime(const Device& device_, MemoryAllocator& m u32 BufferCacheRuntime::SetVertexRepackLayout(const Tegra::Engines::Maxwell3D::Regs& regs, bool dynamic_input) { const u32 old_mask = vertex_repack_mask; - const bool input_supported = VertexRepackInputSupported( - dynamic_input, ExperimentalStaticVertexRepackEnabled()); + const bool input_supported = VertexRepackInputSupported(dynamic_input, static_vertex_repack); vertex_repack_mask = vertex_repack_pass && input_supported ? VertexRepackMask(regs) : 0; if (vertex_repack_mask != old_mask) { LOG_DEBUG(Render_Vulkan, "Vertex repack selection: mask={:#x}, dynamic_input={}", vertex_repack_mask, dynamic_input); } - return old_mask | vertex_repack_mask; + return old_mask ^ vertex_repack_mask; } StagingBufferRef BufferCacheRuntime::UploadStagingBuffer(size_t size) { @@ -453,6 +456,10 @@ void BufferCacheRuntime::Wait(u64 tick) { scheduler.Wait(tick); } +void BufferCacheRuntime::WaitSubmittedTick(u64 tick) { + scheduler.GetMasterSemaphore().Wait(tick); +} + void BufferCacheRuntime::Finish() { scheduler.Finish(); } @@ -653,7 +660,8 @@ void BufferCacheRuntime::BindVertexBuffer(u32 index, VkBuffer buffer, u32 offset return; } if (buffer != VK_NULL_HANDLE && size != 0 && IsVertexRepacked(index)) { - const auto repacked = vertex_repack_pass->Assemble(buffer, offset, size); + const auto repacked = + vertex_repack_pass->Assemble(buffer, offset, size, u64{offset} + size); if (!repacked) { return; } @@ -698,7 +706,8 @@ void BufferCacheRuntime::BindVertexBuffers(VideoCommon::HostBindings<Buffer>& bi if (handle != VK_NULL_HANDLE && IsVertexRepacked(binding) && bindings.strides[i] == 69 && bindings.sizes[i] > 0) { const auto repacked = vertex_repack_pass->Assemble( - handle, static_cast<u32>(bindings.offsets[i]), static_cast<u32>(bindings.sizes[i])); + handle, static_cast<u32>(bindings.offsets[i]), static_cast<u32>(bindings.sizes[i]), + bindings.buffers[i]->SizeBytes()); if (!repacked) { continue; } @@ -708,7 +717,7 @@ void BufferCacheRuntime::BindVertexBuffers(VideoCommon::HostBindings<Buffer>& bi bindings.strides[i] = 72; static bool reported = false; if (!reported) { - LOG_WARNING(Render_Vulkan, "Experimental GPU vertex repacking active: 69 -> 72 bytes"); + LOG_INFO(Render_Vulkan, "GPU vertex repacking active: 69 -> 72 bytes"); reported = true; } } diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.h b/src/video_core/renderer_vulkan/vk_buffer_cache.h index ff854445b5..b9a0225094 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.h +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.h @@ -110,6 +110,7 @@ public: void TickFrame(Common::SlotVector<Buffer>& slot_buffers) noexcept; + /// Returns the mask of bindings whose repacked state changed. u32 SetVertexRepackLayout(const Tegra::Engines::Maxwell3D::Regs& regs, bool dynamic_input); bool IsVertexRepacked(u32 binding) const { @@ -122,6 +123,9 @@ public: void Wait(u64 tick); + /// Waits for a submitted tick without flushing: safe on threads other than the GPU thread. + void WaitSubmittedTick(u64 tick); + void Finish(); u64 GetDeviceLocalMemory() const; @@ -165,15 +169,40 @@ public: void BindTransformFeedbackBuffers(VideoCommon::HostBindings<Buffer>& bindings); + /// This and the two members below are the hooks of VideoCommon::StreamUniformReusable + /// (buffer_cache/uniform_stream_reuse.h). + struct StreamUniformRef { + VkBuffer buffer; + VkDeviceAddress device_address; // base of `buffer`, 0 without buffer device address + u32 offset; + }; + std::span<u8> BindMappedUniformBuffer([[maybe_unused]] size_t stage, [[maybe_unused]] u32 binding_index, u32 size) { const StagingBufferRef ref = staging_pool.Request(size, MemoryUsage::Upload); - guest_descriptor_queue.AddBuffer(ref.buffer, ref.device_address, - static_cast<u32>(ref.offset), size); + last_stream_uniform = StreamUniformRef{ + .buffer = ref.buffer, + .device_address = ref.device_address, + .offset = static_cast<u32>(ref.offset), + }; + RebindStreamUniform(last_stream_uniform, size); return ref.mapped_span; } + /// The allocation of the latest BindMappedUniformBuffer call. + [[nodiscard]] StreamUniformRef LastStreamUniformRef() const noexcept { + return last_stream_uniform; + } + + /// Adds `ref` to the descriptor payload exactly as BindMappedUniformBuffer did when it made + /// the allocation (it is the very same call). The caller guarantees the allocation is still + /// live: made on the scheduler tick that is current now, so the staging pool has not recycled + /// it and the GPU has not even started on it. + void RebindStreamUniform(const StreamUniformRef& ref, u32 size) { + guest_descriptor_queue.AddBuffer(ref.buffer, ref.device_address, ref.offset, size); + } + [[nodiscard]] VkDeviceSize SparseAlignmentFor(bool sparse_compatible) const noexcept { if (!sparse_compatible || !multi_range_buffers.use_sparse) { return 0; @@ -267,12 +296,15 @@ private: std::unique_ptr<Uint8Pass> uint8_pass; std::unique_ptr<VertexRepackPass> vertex_repack_pass; u32 vertex_repack_mask{}; + bool static_vertex_repack{}; QuadIndexedPass quad_index_pass; MultiRangeBufferCache multi_range_buffers; boost::container::small_vector<MultiRangeSource, 16> multi_range_sources; VkDeviceSize multi_range_total{}; + StreamUniformRef last_stream_uniform{}; + bool limit_dynamic_storage_buffers = false; u32 max_dynamic_storage_buffers = (std::numeric_limits<u32>::max)(); }; diff --git a/src/video_core/renderer_vulkan/vk_compute_pass.cpp b/src/video_core/renderer_vulkan/vk_compute_pass.cpp index cb8dcde42f..5902b7d8f5 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pass.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pass.cpp @@ -335,10 +335,11 @@ VertexRepackPass::VertexRepackPass(const Device& device_, Scheduler& scheduler_, scheduler{scheduler_}, staging_pool{staging_pool_}, descriptor_queue{descriptor_queue_} {} std::optional<std::pair<VkBuffer, VkDeviceSize>> VertexRepackPass::Assemble(VkBuffer source, - u32 offset, u32 size) { + u32 offset, u32 size, + u64 buffer_size) { const auto parameters = MakeVertexRepackParameters( offset, size, device.GetStorageBufferAlignment(), device.GetMaxStorageBufferRange(), - device.GetMaxComputeWorkGroupCount()[0]); + device.GetMaxComputeWorkGroupCount()[0], buffer_size); if (!parameters) { LOG_ERROR(Render_Vulkan, "Vertex repack exceeds device limits: offset={}, size={}", offset, size); return std::nullopt; diff --git a/src/video_core/renderer_vulkan/vk_compute_pass.h b/src/video_core/renderer_vulkan/vk_compute_pass.h index 27fffbbbb7..9687bfff91 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pass.h +++ b/src/video_core/renderer_vulkan/vk_compute_pass.h @@ -60,9 +60,10 @@ class VertexRepackPass final : public ComputePass { public: VertexRepackPass(const Device& device, Scheduler& scheduler, DescriptorPool& descriptor_pool, StagingBufferPool& staging_pool, ComputePassDescriptorQueue& descriptor_queue); - /// Returns nothing when the range exceeds the device limits; the binding is then left as is. - std::optional<std::pair<VkBuffer, VkDeviceSize>> Assemble(VkBuffer source, u32 offset, - u32 size); + /// Returns nothing when the range exceeds the device limits or the source buffer; the binding + /// is then left as is. + std::optional<std::pair<VkBuffer, VkDeviceSize>> Assemble(VkBuffer source, u32 offset, u32 size, + u64 buffer_size); private: Scheduler& scheduler; diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 1cfe391ba6..3a3861930c 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -176,19 +176,18 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, const auto& cbufs{qmd.const_buffer_config}; const bool via_header_index{qmd.linked_tsc != 0}; const auto read_handle{[&](const auto& desc, u32 index) { - if constexpr (std::is_same_v<decltype(desc), const Shader::TextureDescriptor&>) { - if (desc.handle_program != ~0U) { - // Read by the shader from a storage buffer: computed for this dispatch. - return TexturePair(VideoCommon::EvaluateTextureHandle( - info, desc.handle_program, gpu_memory, - [&](u32 cbuf) -> GPUVAddr { - return cbuf < cbufs.size() && - ((qmd.const_buffer_enable_mask >> cbuf) & 1) != 0 - ? cbufs[cbuf].Address() - : GPUVAddr{}; - }), - via_header_index); - } + if (desc.handle_program != ~0U) { + // Read by the shader from a storage buffer: computed for this dispatch. + return TexturePair(VideoCommon::EvaluateTextureHandle( + info, desc.handle_program, gpu_memory, + [&](u32 cbuf) -> GPUVAddr { + return cbuf < cbufs.size() && + ((qmd.const_buffer_enable_mask >> cbuf) & + 1) != 0 + ? cbufs[cbuf].Address() + : GPUVAddr{}; + }), + via_header_index); } ASSERT(((qmd.const_buffer_enable_mask >> desc.cbuf_index) & 1) != 0); const u32 index_offset{index << desc.size_shift}; diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index fa273f6ef9..4bd2e2735e 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -400,18 +400,16 @@ bool GraphicsPipeline::ConfigureImpl(bool is_indexed) { } const auto& cbufs{maxwell3d->state.shader_stages[stage].const_buffers}; const auto read_handle{[&](const auto& desc, u32 index) { - if constexpr (std::is_same_v<decltype(desc), const Shader::TextureDescriptor&>) { - if (desc.handle_program != ~0U) { - // Read by the shader from a storage buffer: computed for this draw. - return TexturePair(VideoCommon::EvaluateTextureHandle( - info, desc.handle_program, *gpu_memory, - [&](u32 cbuf) -> GPUVAddr { - return cbuf < cbufs.size() && cbufs[cbuf].enabled - ? cbufs[cbuf].address - : GPUVAddr{}; - }), - via_header_index); - } + if (desc.handle_program != ~0U) { + // Read by the shader from a storage buffer: computed for this draw. + return TexturePair(VideoCommon::EvaluateTextureHandle( + info, desc.handle_program, *gpu_memory, + [&](u32 cbuf) -> GPUVAddr { + return cbuf < cbufs.size() && cbufs[cbuf].enabled + ? cbufs[cbuf].address + : GPUVAddr{}; + }), + via_header_index); } ASSERT(cbufs[desc.cbuf_index].enabled); const u32 index_offset{index << desc.size_shift}; diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index ff8e84b019..b651bfc9ba 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -34,6 +34,7 @@ #include "video_core/renderer_vulkan/maxwell_to_vk.h" #include "video_core/renderer_vulkan/pipeline_helper.h" #include "video_core/renderer_vulkan/pipeline_statistics.h" +#include "video_core/renderer_vulkan/vertex_repack.h" #include "video_core/renderer_vulkan/vk_compute_pipeline.h" #include "video_core/renderer_vulkan/vk_descriptor_pool.h" #include "video_core/renderer_vulkan/vk_pipeline_cache.h" @@ -65,7 +66,7 @@ using VideoCommon::FileEnvironment; using VideoCommon::GenericEnvironment; using VideoCommon::GraphicsEnvironment; -constexpr u32 CACHE_VERSION = 18; +constexpr u32 CACHE_VERSION = 19; constexpr size_t VULKAN_CACHE_FLUSH_PIPELINES = 128; constexpr size_t VULKAN_CACHE_FLUSH_MIN_SECONDS = 30; constexpr std::array<char, 8> VULKAN_CACHE_MAGIC_NUMBER{'y', 'u', 'z', 'u', 'v', 'k', 'c', 'h'}; @@ -583,6 +584,8 @@ PipelineCache::PipelineCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, dynamic_features.has_dynamic_vertex_input = device.IsExtVertexInputDynamicStateSupported() && Settings::values.vertex_input_dynamic_state.GetValue(); + dynamic_features.has_static_vertex_repack = + StaticVertexRepackEnabled(device.GetMinVertexInputBindingStrideAlignment()); dynamic_features.has_provoking_vertex = device.IsExtProvokingVertexSupported(); dynamic_features.has_provoking_vertex_first_mode = @@ -600,9 +603,17 @@ constexpr u32 UsedPipelinesMagic = 0x31505545; // "EUP1" // Pipelines the last two sessions used are built at boot. Each holds ~0.45 MB of driver memory: // title 1 built 4,800 from eight sessions, 1.5 GB more than building them on first use (but that // stalled frames 4x as often), while one session of its title screen uses ~1,500. +// MoltenVK keeps the previous eight sessions: a pipeline built while playing goes through +// SPIR-V -> MSL -> Metal, far more than the 2-3 ms the shorter window assumes, so each one +// built at boot is a stutter avoided. constexpr u8 UsedPipelinesMaxAge = 2; +constexpr u8 UsedPipelinesMaxAgeMoltenVK = 8; -std::unordered_map<u64, u8> LoadUsedPipelines(const std::filesystem::path& path) { +u8 MaxUsedPipelineAge(const Device& device) { + return device.IsMoltenVK() ? UsedPipelinesMaxAgeMoltenVK : UsedPipelinesMaxAge; +} + +std::unordered_map<u64, u8> LoadUsedPipelines(const std::filesystem::path& path, u8 max_age) { std::unordered_map<u64, u8> used; std::ifstream file{path, std::ios::binary}; u32 magic{}, count{}; @@ -618,7 +629,7 @@ std::unordered_map<u64, u8> LoadUsedPipelines(const std::filesystem::path& path) !file.read(reinterpret_cast<char*>(&age), sizeof(age))) { break; } - if (age < UsedPipelinesMaxAge) { // lists saved with a longer window + if (age < max_age) { // lists saved with a longer window used.emplace(hash, age); } } @@ -669,7 +680,7 @@ void PipelineCache::SaveUsedPipelines() { next.emplace(hash, u8{0}); } for (const auto& [hash, age] : used_pipelines) { - if (age + 1 < UsedPipelinesMaxAge) { + if (age + 1 < MaxUsedPipelineAge(device)) { next.try_emplace(hash, static_cast<u8>(age + 1)); } } @@ -744,7 +755,7 @@ void PipelineCache::LoadDiskResources(u64 title_id, std::stop_token stop_loading } pipeline_cache_filename = base_dir / "vulkan.bin"; used_pipelines_filename = base_dir / "vulkan_used.bin"; - used_pipelines = LoadUsedPipelines(used_pipelines_filename); + used_pipelines = LoadUsedPipelines(used_pipelines_filename, MaxUsedPipelineAge(device)); used_pipelines_saved = std::chrono::steady_clock::now(); size_t skipped_pipelines = 0; size_t queued_graphics = 0; @@ -1004,6 +1015,11 @@ void PipelineCache::TickFrame() { } void PipelineCache::EvictUnusedPipelines() { + // Building a pipeline again on MoltenVK means a Metal compile mid-game, far dearer than the + // 2-3 ms below, so none are evicted there. + if (device.IsMoltenVK()) { + return; + } // Each built pipeline holds ~0.45 MB of driver memory and none was ever destroyed, so it grew // with every place a session went (title 1: 1,500 on its title screen alone). One unused for // this long is destroyed; it takes 2-3 ms to build again from the driver's pipeline cache. @@ -1056,7 +1072,7 @@ GraphicsPipeline* PipelineCache::BuiltPipeline(GraphicsPipeline* pipeline) const // Usually these shaders are only used once for building textures so we can assume they // can't be built async const auto& draw_state = maxwell3d->draw_manager.draw_state; - if (draw_state.index_buffer.count <= 6 || draw_state.vertex_buffer.count <= 6) { + if (draw_state.draw_element_count <= 6) { return pipeline; } return nullptr; diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index d3dbba6163..3898c571f5 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -922,6 +922,11 @@ void RasterizerVulkan::DispatchCompute() { return; } std::scoped_lock lock{texture_cache.mutex, buffer_cache.mutex}; + // Command list pages the dispatch writes are copied for the split's front right after it. + buffer_cache.Nx2CollectListCopies(true); + SCOPE_EXIT { + buffer_cache.Nx2CollectListCopies(false); + }; if (!pipeline->Configure(*kepler_compute, *gpu_memory, scheduler, buffer_cache, texture_cache)) { if (kepler_compute->IsNativeMode()) throw Shader::RuntimeError("Native compute resources unavailable"); @@ -944,6 +949,7 @@ void RasterizerVulkan::DispatchCompute() { } cmdbuf.DispatchIndirect(indirect_buffer, indirect_offset); }); + SubmitNx2ListCopies(); return; } const std::array<u32, 3> dim{qmd.grid_dim_x, qmd.grid_dim_y, qmd.grid_dim_z}; @@ -966,6 +972,7 @@ void RasterizerVulkan::DispatchCompute() { } cmdbuf.Dispatch(dim[0], dim[1], dim[2]); }); + SubmitNx2ListCopies(); // Log compute dispatch if (GPU::Logging::IsActive() && @@ -1007,6 +1014,29 @@ void Vulkan::RasterizerVulkan::DisableGraphicsUniformBuffer(size_t stage, u32 in void RasterizerVulkan::FlushAll() {} +void RasterizerVulkan::SubmitNx2ListCopies() { + if (buffer_cache.Nx2CopyListPages(scheduler.CurrentTick())) { + scheduler.Flush(); + } +} + +VideoCore::RasterizerInterface::GpuWrittenList RasterizerVulkan::ReadGpuWrittenCommandList( + DAddr addr, std::span<u8> out) { + // No cache lock: the front only reads the copies, under their own lock. + switch (buffer_cache.Nx2ReadListCopy(addr, out)) { + case BufferCache::Nx2ListRead::Copied: + return GpuWrittenList::Copied; + case BufferCache::Nx2ListRead::Synced: + return GpuWrittenList::Synced; + default: + return GpuWrittenList::Unknown; + } +} + +void RasterizerVulkan::NoteGpuWrittenCommandListSynced(DAddr page) { + buffer_cache.Nx2NoteListSynced(page); +} + void RasterizerVulkan::FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType which) { if (addr == 0 || size == 0) { return; @@ -1097,12 +1127,10 @@ void RasterizerVulkan::InnerInvalidation(std::span<const std::pair<DAddr, std::s buffer_cache.WriteMemory(addr, size); } } - { - for (const auto& [addr, size] : sequences) { - query_cache.InvalidateRegion(addr, size); - pipeline_cache.InvalidateRegion(addr, size); - } + for (const auto& [addr, size] : sequences) { + query_cache.InvalidateRegion(addr, size); } + pipeline_cache.InvalidateRegions(sequences); } bool RasterizerVulkan::OnCPUWrite(DAddr addr, u64 size) { @@ -1319,6 +1347,8 @@ void RasterizerVulkan::AccelerateInlineToMemory(GPUVAddr address, size_t copy_si bool uploaded; { std::scoped_lock lock{buffer_cache.mutex}; + // Guest memory has the words now: a command list copy of the page no longer does. + buffer_cache.Nx2NoteListHostWrite(*cpu_addr, copy_size); uploaded = buffer_cache.InlineMemory(*cpu_addr, copy_size, memory); if (!uploaded && !defer) { buffer_cache.WriteMemory(*cpu_addr, copy_size); @@ -1392,10 +1422,27 @@ void RasterizerVulkan::FlushWork() { #endif // __ANDROID__ static_assert(DRAWS_TO_DISPATCH % (CHECK_MASK + 1) == 0); - if ((++draw_counter & CHECK_MASK) != CHECK_MASK) { + if ((++draw_counter & CHECK_MASK) != 0) { return; } - if (draw_counter < DRAWS_TO_DISPATCH) { + // Keep the host GPU working while native commands are still being recorded. + static const u32 native_flush_draws = [] { + constexpr u32 fallback = 128; + const char* value = std::getenv("EDEN_NX2_FLUSH_DRAWS"); + if (value == nullptr || value[0] < '0' || value[0] > '9') { + return fallback; + } + char* end = nullptr; + const unsigned long parsed = std::strtoul(value, &end, 10); + if (*end != '\0' || parsed == 0) { + return fallback; + } + return static_cast<u32>(std::clamp(parsed, 32UL, 1'048'576UL)); + }(); + const u32 draws_to_dispatch = Tegra::g_nx2_ampere.load(std::memory_order_relaxed) + ? native_flush_draws + : DRAWS_TO_DISPATCH; + if (draw_counter < draws_to_dispatch) { scheduler.DispatchWork(); return; } @@ -1414,7 +1461,13 @@ bool AccelerateDMA::BufferClear(GPUVAddr src_address, u64 amount, u32 value) { bool AccelerateDMA::BufferCopy(GPUVAddr src_address, GPUVAddr dest_address, u64 amount) { std::scoped_lock lock{buffer_cache.mutex}; - return buffer_cache.DMACopy(src_address, dest_address, amount); + buffer_cache.Nx2CollectListCopies(true); + const bool copied = buffer_cache.DMACopy(src_address, dest_address, amount); + if (buffer_cache.Nx2CopyListPages(scheduler.CurrentTick())) { + scheduler.Flush(); + } + buffer_cache.Nx2CollectListCopies(false); + return copied; } template <bool IS_IMAGE_UPLOAD> diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.h b/src/video_core/renderer_vulkan/vk_rasterizer.h index fc282fbe8d..dbb6ebdca8 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.h +++ b/src/video_core/renderer_vulkan/vk_rasterizer.h @@ -99,6 +99,8 @@ public: void FlushAll() override; void FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType which = VideoCommon::CacheType::All) override; + GpuWrittenList ReadGpuWrittenCommandList(DAddr addr, std::span<u8> out) override; + void NoteGpuWrittenCommandListSynced(DAddr page) override; bool MustFlushRegion(DAddr addr, u64 size, VideoCommon::CacheType which = VideoCommon::CacheType::All) override; VideoCore::RasterizerDownloadArea GetFlushArea(DAddr addr, u64 size) override; @@ -165,6 +167,10 @@ private: void FlushWork(); + /// Records the copies of the command list pages the work just recorded wrote and submits, so + /// the GPU makes them now (BufferCache::Nx2CopyListPages). + void SubmitNx2ListCopies(); + void UpdateDynamicStates(); void HandleTransformFeedback(); diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 30981fe413..928146027d 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -2350,23 +2350,31 @@ void TextureCacheRuntime::TickFrame() { }); } +namespace { + +// The host image of a texture; unbacked images are created without memory (see MakeImage). +[[nodiscard]] vk::Image MakeHostImage(TextureCacheRuntime& runtime, const ImageInfo& info, + u32 host_level_offset, GPUVAddr gpu_addr, bool backed) { + const Device& device = runtime.device; + const bool sparse_tiles = !backed && UsesSparseTiles(device, info, gpu_addr); + return MakeImage( + device, runtime.memory_allocator, TrimmedHostInfo(device, info, host_level_offset), + IsHugeAstcArray(device, info) ? std::span<const VkFormat>{HugeAstcViewFormats} + : IsOpaqueBc1Astc(device, info) ? std::span<const VkFormat>{OpaqueAstcViewFormats} + : runtime.ViewFormats(info.format), + AstcHostFormat(device, info), + !backed && (UsesSparseLayers(device, info, gpu_addr) || sparse_tiles || + IsUnwrittenSparse(device, info, gpu_addr)), + sparse_tiles && SparseTilesModeOf(device) == SparseTilesMode::Aliased); +} + +} // Anonymous namespace + Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu_addr_, VAddr cpu_addr_) : VideoCommon::ImageBase(info_, gpu_addr_, cpu_addr_), scheduler{&runtime_.scheduler}, runtime{&runtime_}, - original_image(MakeImage(runtime_.device, runtime_.memory_allocator, - TrimmedHostInfo(runtime_.device, info, host_level_offset), - IsHugeAstcArray(runtime_.device, info) - ? std::span<const VkFormat>{HugeAstcViewFormats} - : IsOpaqueBc1Astc(runtime_.device, info) - ? std::span<const VkFormat>{OpaqueAstcViewFormats} - : runtime->ViewFormats(info.format), - AstcHostFormat(runtime_.device, info), - UsesSparseLayers(runtime_.device, info, gpu_addr_) || - UsesSparseTiles(runtime_.device, info, gpu_addr_) || - IsUnwrittenSparse(runtime_.device, info, gpu_addr_), - UsesSparseTiles(runtime_.device, info, gpu_addr_) && - SparseTilesModeOf(runtime_.device) == SparseTilesMode::Aliased)), + original_image(MakeHostImage(runtime_, info, host_level_offset, gpu_addr_, false)), aspect_mask(ImageAspectMask(info.format)) { if (original_image.IsUnbacked()) { flags |= VideoCommon::ImageFlagBits::Unbacked; @@ -2377,6 +2385,17 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu flags |= VideoCommon::ImageFlagBits::SparseLayers; } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && InitSparseTiles()) { flags |= VideoCommon::ImageFlagBits::SparseTiles; + } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && + SparseTilesModeOf(runtime_.device) == SparseTilesMode::Aliased) { + // The driver can't sample unbound memory (that is why the blocks were to share one), + // so an image whose tiles could not be set up is backed whole like any other. + LOG_WARNING(Render_Vulkan, "Sparse tiled image {:#x}: backing it whole", gpu_addr_); + original_image = MakeHostImage(runtime_, info, host_level_offset, gpu_addr_, true); + if (!original_image.IsUnbacked()) { + flags &= ~VideoCommon::ImageFlagBits::Unbacked; + } else if (texels <= (u64{1} << 28)) { + flags |= VideoCommon::ImageFlagBits::UnwrittenSparse; + } } else if (texels <= (u64{1} << 28)) { // Not too big to back (see MemoryAllocator::CreateImage): unbacked because nothing // was written there yet, so the first write gets it replaced. diff --git a/src/video_core/shader_cache.cpp b/src/video_core/shader_cache.cpp index 5faed0dba9..5d41dc9697 100644 --- a/src/video_core/shader_cache.cpp +++ b/src/video_core/shader_cache.cpp @@ -6,6 +6,7 @@ #include <algorithm> #include <cstring> +#include <functional> #include <string> #include <unordered_set> #include <fmt/ranges.h> @@ -36,6 +37,17 @@ void ShaderCache::InvalidateRegion(VAddr addr, size_t size) { RemovePendingShaders(); } +void ShaderCache::InvalidateRegions(std::span<const std::pair<DAddr, size_t>> sequences) { + if (sequences.empty()) { + return; + } + std::scoped_lock lock{invalidation_mutex}; + for (const auto& [addr, size] : sequences) { + InvalidatePagesInRegion(addr, size); + } + RemovePendingShaders(); +} + void ShaderCache::OnCacheInvalidation(VAddr addr, size_t size) { std::scoped_lock lock{invalidation_mutex}; InvalidatePagesInRegion(addr, size); @@ -304,10 +316,17 @@ void ShaderCache::RemovePendingShaders() { marked_for_removal.clear(); if (!removed_shaders.empty()) { - // Remove the given shaders from the cache - std::erase_if(storage, [&removed_shaders](const std::unique_ptr<ShaderInfo>& shader) { - return std::ranges::find(removed_shaders, shader.get()) != removed_shaders.end(); - }); + if (removed_shaders.size() > 16) { + std::ranges::sort(removed_shaders, std::less<ShaderInfo*>{}); + std::erase_if(storage, [&removed_shaders](const std::unique_ptr<ShaderInfo>& shader) { + return std::binary_search(removed_shaders.begin(), removed_shaders.end(), + shader.get(), std::less<ShaderInfo*>{}); + }); + } else { + std::erase_if(storage, [&removed_shaders](const std::unique_ptr<ShaderInfo>& shader) { + return std::ranges::find(removed_shaders, shader.get()) != removed_shaders.end(); + }); + } } } diff --git a/src/video_core/shader_cache.h b/src/video_core/shader_cache.h index 281a90cb6f..7c370e217c 100644 --- a/src/video_core/shader_cache.h +++ b/src/video_core/shader_cache.h @@ -67,6 +67,8 @@ public: /// @param size Number of bytes of the invalidation void InvalidateRegion(VAddr addr, size_t size); + void InvalidateRegions(std::span<const std::pair<DAddr, size_t>> sequences); + /// @brief Unmarks a memory region as cached and marks it for removal /// @param addr Start address of the CPU write operation /// @param size Number of bytes of the CPU write operation @@ -104,6 +106,8 @@ protected: bool last_shaders_valid = false; private: + friend struct ShaderCacheTestAccess; + /// @brief Tries to obtain a cached shader starting in a given address /// @note Doesn't check for ranges, the given address has to be the start of the shader /// @param addr Start address of the shader, this doesn't cache for region diff --git a/src/video_core/shader_environment.cpp b/src/video_core/shader_environment.cpp index 22e661c849..0c7d8a0d42 100644 --- a/src/video_core/shader_environment.cpp +++ b/src/video_core/shader_environment.cpp @@ -316,7 +316,8 @@ std::optional<u64> GenericEnvironment::TryFindSize() { Tegra::Texture::TICEntry GenericEnvironment::ReadTextureInfo(GPUVAddr tic_addr, u32 tic_limit, bool via_header_index, u32 raw) { const auto handle{Tegra::Texture::TexturePair(raw, via_header_index)}; - const u32 index{Tegra::Texture::TextureHeaderIndex(handle.first, tic_limit)}; + const u32 index{Tegra::Texture::TextureHeaderIndex(handle.first, tic_limit, + gpu_memory->UsesNativeTiling())}; ASSERT(index <= tic_limit); const GPUVAddr descriptor_addr{tic_addr + index * sizeof(Tegra::Texture::TICEntry)}; Tegra::Texture::TICEntry entry; @@ -327,9 +328,9 @@ Tegra::Texture::TICEntry GenericEnvironment::ReadTextureInfo(GPUVAddr tic_addr, namespace { // Reads a native program a 4 KiB page at a time instead of one 16-byte instruction at a time. -// The reads do not flush the GPU caches first (ReadBlockUnsafe): the previous per-instruction -// ReadBlock did, once per instruction. An instruction that is not inside a fully mapped page is -// left to the caller. +// Each page is read with ReadBlock, so GPU-written code is flushed first, once per page instead +// of once per instruction. An instruction that is not inside a fully mapped page is left to the +// caller. class NativeInstructionReader { public: using Instruction = Shader::Ampere::Instruction; @@ -364,7 +365,7 @@ private: page_begin = address; page_end = address; if (length >= sizeof(Instruction) && gpu_memory.IsFullyMappedRange(address, length)) { - gpu_memory.ReadBlockUnsafe(address, page.data(), length); + gpu_memory.ReadBlock(address, page.data(), length); page_end = next_page; } } @@ -414,7 +415,7 @@ std::shared_ptr<const LoweredNativeCode> GenericEnvironment::LowerNativeCode(u32 if (!native.empty()) break; throw Shader::RuntimeError("Unmapped native shader instruction at {:#x}", address); } - gpu_memory->ReadBlockUnsafe(address, &instruction, sizeof(instruction)); + gpu_memory->ReadBlock(address, &instruction, sizeof(instruction)); } native.push_back(instruction); const auto status = capture.Observe(instruction, index); diff --git a/src/video_core/texture_cache/descriptor_table.h b/src/video_core/texture_cache/descriptor_table.h index 75383809c5..a441e572de 100644 --- a/src/video_core/texture_cache/descriptor_table.h +++ b/src/video_core/texture_cache/descriptor_table.h @@ -35,10 +35,11 @@ public: invalidated = true; } - [[nodiscard]] std::pair<T, bool> Read(Tegra::MemoryManager const& gpu_memory, u32 index) noexcept { + template <class MemoryManager> + [[nodiscard]] std::pair<T, bool> Read(const MemoryManager& gpu_memory, u32 index) noexcept { DEBUG_ASSERT(index <= current_limit); - if (invalidated) [[unlikely]] { - std::ranges::fill(read_descriptors, 0); + if (invalidated) { + ClearReadDescriptors(); invalidated = false; } const GPUVAddr gpu_addr = current_gpu_addr + index * sizeof(T); @@ -62,10 +63,8 @@ public: // Mario Brothership reallocates a lot of times, so use aggressive pre-alloc sizes // std::vector<T> by default uses quadratic growth, but that isn't even enough to satisfy brothership const size_t num_descriptors = ((limit + 0x80000) & (~0x7ffff)) + 1; - size_t old_size = read_descriptors.size(); read_descriptors.resize(Common::DivCeil(num_descriptors, 64U)); - old_size = (std::min)(old_size, read_descriptors.size()); - std::fill(read_descriptors.begin(), read_descriptors.begin() + old_size, 0); + ClearReadDescriptors(); invalidated = false; // descriptors.resize(num_descriptors); @@ -76,6 +75,13 @@ public: GPUVAddr current_gpu_addr{}; u32 current_limit{}; bool invalidated{}; ///< read_descriptors is to be cleared before the next read + +private: + void ClearReadDescriptors() noexcept { + // Reserved capacity is not addressable. A later limit increase clears its new range. + const size_t words = (std::min)(read_descriptors.size(), size_t{current_limit} / 64 + 1); + std::fill_n(read_descriptors.begin(), words, u64{0}); + } }; } // namespace VideoCommon diff --git a/src/video_core/texture_cache/format_lookup_table.cpp b/src/video_core/texture_cache/format_lookup_table.cpp index 20bb96aae3..18be2dcbf3 100644 --- a/src/video_core/texture_cache/format_lookup_table.cpp +++ b/src/video_core/texture_cache/format_lookup_table.cpp @@ -150,6 +150,7 @@ PixelFormat PixelFormatFromTextureInfo(TextureFormat format, ComponentType red, case Hash(TextureFormat::X8Z24, UNORM): return PixelFormat::X8_D24_UNORM; case Hash(TextureFormat::X8Z24, UNORM, UINT, UINT, UINT, LINEAR): + case Hash(TextureFormat::X8Z24, UNORM, UINT, UNORM, UNORM, LINEAR): return PixelFormat::X8_D24_UNORM; case Hash(TextureFormat::Z24S8, UINT, UNORM, UNORM, UNORM, LINEAR): return PixelFormat::S8_UINT_D24_UNORM; diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 3f3ae9cafd..4929c2df44 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -809,7 +809,8 @@ typename P::Framebuffer* TextureCache<P>::GetFramebuffer() { template <class P> ImageViewId TextureCache<P>::VisitImageView(u32 index, bool compute) { auto& table = compute ? channel_state->compute_image_table : channel_state->graphics_image_table; - index = Tegra::Texture::TextureHeaderIndex(index, table.current_limit); + index = Tegra::Texture::TextureHeaderIndex(index, table.current_limit, + gpu_memory->UsesNativeTiling()); if (index > table.current_limit) { LOG_DEBUG(HW_GPU, "Invalid image view index={}", index); return NULL_IMAGE_VIEW_ID; diff --git a/src/video_core/textures/texture.h b/src/video_core/textures/texture.h index 79396d8a68..35a4c8eecf 100644 --- a/src/video_core/textures/texture.h +++ b/src/video_core/textures/texture.h @@ -167,10 +167,10 @@ static_assert(sizeof(TextureHandle) == 4, "TextureHandle has wrong size"); /// Some Switch 2 bindless handles (Switch Sports' scenery, read from a uniform register) hold /// the texture header's byte offset in the pool, index * 32, where the handle normally has the -/// index. Read it as an offset only when the plain index falls past the pool and the offset -/// lands inside it, so handles that already resolve are left alone. -[[nodiscard]] constexpr u32 TextureHeaderIndex(u32 index, u32 limit) { - if (index > limit && index % 32 == 0 && index / 32 <= limit) { +/// index. Read it as an offset only on Switch 2 (native tiling), when the plain index falls past +/// the pool and the offset lands inside it, so handles that already resolve are left alone. +[[nodiscard]] constexpr u32 TextureHeaderIndex(u32 index, u32 limit, bool switch2) { + if (switch2 && index > limit && index % 32 == 0 && index / 32 <= limit) { return index / 32; } return index; diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h index 622d792210..5f75bcc18e 100644 --- a/src/video_core/vulkan_common/vulkan_device.h +++ b/src/video_core/vulkan_common/vulkan_device.h @@ -1161,6 +1161,12 @@ FN_MAX_LIMIT_LIST return properties.driver.driverID == VK_DRIVER_ID_MOLTENVK; } + /// The multiple vertex input binding strides must be. Metal needs 4 bytes, which MoltenVK + /// reports as minVertexInputBindingStrideAlignment through VK_KHR_portability_subset. + u32 GetMinVertexInputBindingStrideAlignment() const noexcept { + return IsMoltenVK() ? 4 : 1; + } + NvidiaArchitecture GetNvidiaArch() const noexcept { return nvidia_arch; } diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 40ff1d45f9..f5895edd10 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -219,6 +219,7 @@ add_executable(yuzu yuzu.qrc yuzu.rc migration_dialog.h migration_dialog.cpp + nup_dialogs.h nup_dialogs.cpp migration_worker.h migration_worker.cpp libqt_common.h libqt_common.cpp diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp index 8e3eb7bc52..d2613697bf 100644 --- a/src/yuzu/game/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -160,6 +160,10 @@ void GameList::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { item_model->PopulateAsync(game_dirs); } +void GameList::SetScanEnabled(bool enabled) { + item_model->SetScanEnabled(enabled); +} + void GameList::SaveInterfaceLayout() { tree_view->SaveInterfaceLayout(); } diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h index 67713691fd..b33dfdd9c8 100644 --- a/src/yuzu/game/game_list.h +++ b/src/yuzu/game/game_list.h @@ -76,6 +76,7 @@ public: bool IsEmpty() const; void PopulateAsync(QVector<UISettings::GameDir>& game_dirs); + void SetScanEnabled(bool enabled); void SaveInterfaceLayout(); void LoadInterfaceLayout(); diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 2b600946bc..dbe5ea2334 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -10,6 +10,7 @@ #include <boost/algorithm/string/split.hpp> #include "common/fs/path_util.h" +#include "common/game_presets.h" #include "common/settings.h" #include "common/settings_enums.h" #include "frontend_common/settings_generator.h" @@ -42,6 +43,7 @@ #include "set_play_time_dialog.h" #include "util/util.h" #include "yuzu/game/game_list.h" +#include "yuzu/nup_dialogs.h" #include "applets/qt_amiibo_settings.h" #include "applets/qt_controller.h" @@ -73,8 +75,11 @@ #include <QClipboard> #include <QDesktopServices> #include <QDir> +#include <QElapsedTimer> +#include <QEventLoop> #include <QFileDialog> #include <QFileInfo> +#include <QFutureWatcher> #include <QGuiApplication> #include <QInputDialog> #include <QMimeData> @@ -316,6 +321,7 @@ __declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; constexpr int default_mouse_hide_timeout = 2500; constexpr int default_input_update_timeout = 1; +constexpr int EmuThreadStopTimeoutMs = 10'000; constexpr size_t CopyBufferSize = 1_MiB; @@ -1293,7 +1299,6 @@ void MainWindow::InitializeHotkeys() { hotkey_registry.LoadHotkeys(); LinkActionShortcut(ui->action_Load_File, QStringLiteral("Load File")); - LinkActionShortcut(ui->action_Load_Amiibo, QStringLiteral("Load/Remove Amiibo")); LinkActionShortcut(ui->action_Exit, QStringLiteral("Exit Eglantine")); LinkActionShortcut(ui->action_Restart, QStringLiteral("Restart Emulation")); LinkActionShortcut(ui->action_Pause, QStringLiteral("Continue/Pause Emulation")); @@ -1833,24 +1838,6 @@ bool MainWindow::LoadROM(const QString& filename, Service::AM::FrontendAppletPar const Core::SystemResultStatus result{ QtCommon::system->Load(*render_window, filename.toStdString(), params)}; - const auto drd_callout = (UISettings::values.callout_flags.GetValue() & - static_cast<u32>(CalloutFlag::DRDDeprecation)) == 0; - - if (result == Core::SystemResultStatus::Success && - QtCommon::system->GetAppLoader().GetFileType() == - Loader::FileType::DeconstructedRomDirectory && - drd_callout) { - UISettings::values.callout_flags = UISettings::values.callout_flags.GetValue() | - static_cast<u32>(CalloutFlag::DRDDeprecation); - QMessageBox::warning( - this, tr("Warning: Outdated Game Format"), - tr("You are using the deconstructed ROM directory format for this game, which is an " - "outdated format that has been superseded by others such as NCA, NAX, XCI, or " - "NSP. Deconstructed ROM directories lack icons, metadata, and update " - "support.<br>For an explanation of the various Switch formats Eglantine supports, " - "out our user handbook. This message will not be shown again.")); - } - if (result != Core::SystemResultStatus::Success) { switch (result) { case Core::SystemResultStatus::ErrorGetLoader: @@ -1920,6 +1907,31 @@ void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletPa "converted.")); return; } + if (boot_in_progress || close_pending) { + return; + } + boot_in_progress = true; + game_list->SetScanEnabled(false); + QElapsedTimer boot_timer; + boot_timer.start(); + SCOPE_EXIT { + boot_in_progress = false; + LOG_INFO(Frontend, "Launch finished: elapsed_ms={} running={}", boot_timer.elapsed(), + emulation_running); + if (!emulation_running && !close_pending) { + game_list->SetScanEnabled(true); + game_list->PopulateAsync(UISettings::values.game_dirs); + } + if (close_pending) { + QTimer::singleShot(0, this, &QWidget::close); + } + }; + if (emulation_running) { + ShutdownGame(); + } + if (close_pending) { + return; + } LOG_INFO(Frontend, "Eglantine starting..."); if (params.program_id == 0 || @@ -1954,6 +1966,26 @@ void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletPa QtCommon::system->ApplySettings(); } + if (title_id == Settings::MarioKartWorldProgramId && !asked_recommended_mkw_settings) { + asked_recommended_mkw_settings = true; + use_recommended_mkw_settings = + QMessageBox::question( + this, tr("Recommended Mario Kart World settings"), + tr("Use the recommended settings for Mario Kart World?\n\n" + "Vulkan, 1x resolution, GPU Fast, asynchronous shaders, BC3, conservative VRAM, " + "reactive flushing off, FIFO Relaxed VSync, handheld mode, ScaleForce, and " + "FXAA.\n\n" + "They apply to Mario Kart World until Eglantine is closed. Your saved settings " + "are not changed."), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; + LOG_INFO(Config, "MKW recommended settings accepted: {}", use_recommended_mkw_settings); + } + if (Settings::ApplyMarioKartWorldPreset(Settings::values, title_id, + use_recommended_mkw_settings)) { + QtCommon::system->ApplySettings(); + Settings::UpdateGPUAccuracy(); + LOG_INFO(Config, "Applied recommended MKW settings after per-game configuration"); + } Settings::LogSettings(); if (UISettings::values.select_user_on_boot && !user_flag_cmd_line) { @@ -1973,9 +2005,43 @@ void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletPa // behavior of asking. user_flag_cmd_line = false; + FileSys::VirtualFile prepared_romfs; + if (loader && loader->GetFileType() == Loader::FileType::DeconstructedRomDirectory) { + QProgressDialog progress(tr("Preparing game files..."), QString{}, 0, 0, this); + progress.setWindowModality(Qt::ApplicationModal); + progress.setWindowFlag(Qt::WindowCloseButtonHint, false); + progress.setCancelButton(nullptr); + progress.setMinimumDuration(0); + QFutureWatcher<FileSys::VirtualFile> watcher; + QEventLoop loop; + connect(&watcher, &QFutureWatcher<FileSys::VirtualFile>::finished, &loop, &QEventLoop::quit); + watcher.setFuture(QtConcurrent::run([&loader] { + FileSys::VirtualFile image; + loader->ReadRomFS(image); + return image; + })); + progress.show(); + loop.exec(); + try { + prepared_romfs = watcher.result(); + } catch (const std::exception& error) { + LOG_ERROR(Frontend, "Loose RomFS preparation failed: {}", error.what()); + QMessageBox::critical(this, tr("Unable to prepare game files"), + tr("Game preparation failed. See the log for details.")); + return; + } + if (close_pending) { + return; + } + } + + QElapsedTimer load_timer; + load_timer.start(); if (!LoadROM(filename, params)) { + LOG_ERROR(Frontend, "Core load failed: elapsed_ms={}", load_timer.elapsed()); return; } + LOG_INFO(Frontend, "Core load completed: elapsed_ms={}", load_timer.elapsed()); QtCommon::system->SetShuttingDown(false); game_list->setDisabled(true); @@ -2110,6 +2176,9 @@ bool MainWindow::OnShutdownBegin() { Settings::values.current_speed_mode = Settings::SpeedMode::Standard; if (QtCommon::system->IsShuttingDown()) { + status_bar_update_timer.stop(); + connect(QtCommon::emu_thread.get(), &QThread::finished, this, + &MainWindow::OnEmulationStopped, Qt::UniqueConnection); return false; } @@ -2120,6 +2189,8 @@ bool MainWindow::OnShutdownBegin() { } QtCommon::system->SetShuttingDown(true); + status_bar_update_timer.stop(); + LOG_INFO(Frontend, "Shutdown requested; waiting for emulation thread"); discord_rpc->Pause(); RequestGameExit(); @@ -2139,7 +2210,8 @@ bool MainWindow::OnShutdownBegin() { shutdown_timer.setSingleShot(true); shutdown_timer.start(shutdown_time); - connect(&shutdown_timer, &QTimer::timeout, this, &MainWindow::OnEmulationStopTimeExpired); + connect(&shutdown_timer, &QTimer::timeout, this, &MainWindow::OnEmulationStopTimeExpired, + Qt::UniqueConnection); // Disable everything to prevent anything from being triggered here ui->action_Pause->setEnabled(false); @@ -2159,18 +2231,38 @@ void MainWindow::OnShutdownBeginDialog() { void MainWindow::OnEmulationStopTimeExpired() { if (QtCommon::emu_thread) { QtCommon::emu_thread->ForceStop(); + emu_terminate_timer.setSingleShot(true); + emu_terminate_timer.start(EmuThreadStopTimeoutMs); + connect(&emu_terminate_timer, &QTimer::timeout, this, &MainWindow::OnEmulationStopTimeout, + Qt::UniqueConnection); + } +} + +void MainWindow::OnEmulationStopTimeout() { + TerminateEmulationThread(); + OnEmulationStopped(); +} + +void MainWindow::TerminateEmulationThread() { + if (QtCommon::emu_thread && QtCommon::emu_thread->isRunning()) { + LOG_ERROR(Frontend, "Emulation thread failed to stop, terminating it"); + QtCommon::emu_thread->terminate(); + QtCommon::emu_thread->wait(); } } void MainWindow::OnEmulationStopped() { + if (!emulation_running) { + return; + } + if (QtCommon::emu_thread && QtCommon::emu_thread->isRunning()) { + return; + } shutdown_timer.stop(); + emu_terminate_timer.stop(); if (QtCommon::emu_thread) { QtCommon::emu_thread->disconnect(); - if (!QtCommon::emu_thread->wait(QDeadlineTimer(10'000))) { - LOG_ERROR(Frontend, "Emulation thread failed to stop, terminating it"); - QtCommon::emu_thread->terminate(); - QtCommon::emu_thread->wait(); - } + QtCommon::emu_thread->wait(); QtCommon::emu_thread.reset(); } @@ -2239,6 +2331,13 @@ void MainWindow::OnEmulationStopped() { Settings::RestoreGlobalState(QtCommon::system->IsPoweredOn()); QtCommon::system->HIDCore().ReloadInputDevices(); UpdateStatusButtons(); + LOG_INFO(Frontend, "Emulation cleanup completed"); + if (close_pending) { + QTimer::singleShot(0, this, &QWidget::close); + } else if (!boot_in_progress) { + game_list->SetScanEnabled(true); + game_list->PopulateAsync(UISettings::values.game_dirs); + } } void MainWindow::ShutdownGame() { @@ -2250,6 +2349,17 @@ void MainWindow::ShutdownGame() { play_time_manager->Stop(); OnShutdownBegin(); OnEmulationStopTimeExpired(); + emu_terminate_timer.stop(); + if (QtCommon::emu_thread && QtCommon::emu_thread->isRunning()) { + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + connect(QtCommon::emu_thread.get(), &QThread::finished, &loop, &QEventLoop::quit); + connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + timeout.start(EmuThreadStopTimeoutMs); + loop.exec(QEventLoop::ExcludeUserInputEvents); + } + TerminateEmulationThread(); OnEmulationStopped(); } @@ -3082,8 +3192,6 @@ void MainWindow::OnPauseContinueGame() { void MainWindow::OnStopGame() { if (ConfirmShutdownGame()) { play_time_manager->Stop(); - // Update game list to show new play time - game_list->PopulateAsync(UISettings::values.game_dirs); if (OnShutdownBegin()) { OnShutdownBeginDialog(); } else { @@ -3998,21 +4106,15 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { const bool resume = FileSys::Nup::HasResumableJournal(archive); bool delete_sources = resume; + FileSys::Nup::Metadata metadata; + QString progress_title; + QPixmap progress_icon; if (resume) { if (QMessageBox::question(this, tr("Resume Conversion"), tr("Resume the interrupted conversion?"), QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) return; } else { - QMessageBox prompt(QMessageBox::Question, convert_title, - tr("Source folder:\n%1\n\nTarget archive:\n%2") - .arg(QString::fromStdString(Common::FS::PathToUTF8String(*root)), - QString::fromStdString(Common::FS::PathToUTF8String(archive))), - QMessageBox::Yes | QMessageBox::No, this); - // QMessageBox takes ownership of its check box. - auto* low_storage = new QCheckBox(tr("Low storage: delete the original files as they " - "are packed (cannot be undone)")); - prompt.setCheckBox(low_storage); u64 needed{}; { std::error_code ec; @@ -4024,38 +4126,31 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { } std::error_code space_error; const auto space = std::filesystem::space(archive.parent_path(), space_error); - const auto to_mb = [](u64 bytes) { return bytes / (1024 * 1024); }; - const QString space_info = tr("\n\nApproximate space needed: %1 MB\nFree space on target " - "volume: %2 MB") - .arg(to_mb(needed)) - .arg(space_error ? 0 : to_mb(space.available)); - prompt.setInformativeText(space_info); - if (!space_error && space.available < needed) { - prompt.setText(prompt.text() + tr("\n\nWarning: free space is less than the " - "estimated space needed.")); - } - if (prompt.exec() != QMessageBox::Yes) + NupMetadataDialog dialog(*root, archive, needed, space_error ? 0 : space.available, this); + if (dialog.exec() != QDialog::Accepted) return; - delete_sources = low_storage->isChecked(); + delete_sources = dialog.DeleteSources(); + metadata = dialog.Metadata(); + progress_title = dialog.DisplayName(); + progress_icon = dialog.CurrentIcon(); } nup_running = true; nup_source_path = game_path; game_list->SetNupConversionRunning(true); nup_cancel = std::make_shared<std::atomic_bool>(false); - nup_progress = - new QProgressDialog(tr("Preparing conversion..."), tr("Cancel"), 0, 1000, this); - nup_progress->setWindowTitle(tr("Converting to NUP")); - nup_progress->setMinimumDuration(0); - nup_progress->setAutoClose(false); - nup_progress->setAutoReset(false); - connect(nup_progress, &QProgressDialog::canceled, this, - [cancel = nup_cancel] { cancel->store(true); }); + nup_progress = new NupProgressDialog(progress_title, progress_icon, this); + connect(nup_progress, &NupProgressDialog::CancelRequested, this, + [this, cancel = nup_cancel] { + cancel->store(true); + if (nup_progress) + nup_progress->SetCancelling(); + }); nup_progress->show(); const auto cancel = nup_cancel; auto last_update = std::make_shared<std::chrono::steady_clock::time_point>(); - nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, + nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, metadata, cancel, last_update] { std::string error; const auto on_progress = [this, last_update]( @@ -4069,22 +4164,13 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { [this, progress] { if (!nup_progress) return; - const auto percent = progress.bytes_total == 0 - ? 0 - : static_cast<int>(progress.bytes_done * 1000 / - progress.bytes_total); - nup_progress->setValue(percent); - nup_progress->setLabelText( - tr("%1 / %2 MB\n%3 / %4 files") - .arg(progress.bytes_done / (1024 * 1024)) - .arg(progress.bytes_total / (1024 * 1024)) - .arg(progress.files_done) - .arg(progress.files_total)); + nup_progress->SetProgress(progress); }, Qt::QueuedConnection); }; const auto result = FileSys::Nup::PackDump( - root, archive, {.delete_sources = delete_sources}, on_progress, cancel.get(), &error); + root, archive, {.delete_sources = delete_sources, .metadata = metadata}, on_progress, + cancel.get(), &error); QMetaObject::invokeMethod( this, [this, result, delete_sources, error] { @@ -4092,7 +4178,7 @@ void MainWindow::OnGameListConvertToNup(const std::string& game_path) { nup_source_path.clear(); game_list->SetNupConversionRunning(false); if (nup_progress) { - nup_progress->close(); + nup_progress->Finish(); nup_progress->deleteLater(); nup_progress = nullptr; } @@ -4739,10 +4825,28 @@ bool MainWindow::ConfirmClose() { } void MainWindow::closeEvent(QCloseEvent* event) { - if (!ConfirmClose()) { + if (!close_pending && !ConfirmClose()) { + event->ignore(); + return; + } + close_pending = true; + game_list->SetScanEnabled(false); + if (boot_in_progress) { + event->ignore(); + return; + } + if (QtCommon::emu_thread && emulation_running) { event->ignore(); + if (!QtCommon::emu_thread->isRunning()) { + OnEmulationStopped(); + return; + } + if (OnShutdownBegin()) { + OnShutdownBeginDialog(); + } return; } + LOG_INFO(Frontend, "Closing frontend after emulation cleanup"); UpdateUISettings(); game_list->SaveInterfaceLayout(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 766e4d7345..1bd04a4d8b 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -10,6 +10,7 @@ #include <optional> #include <filesystem> +#include <QElapsedTimer> #include <QMainWindow> #include <QMessageBox> #include <QFutureWatcher> @@ -54,6 +55,7 @@ class QLabel; class MultiplayerState; class QPushButton; class QProgressDialog; +class NupProgressDialog; class QSlider; class QHBoxLayout; class WaitTreeWidget; @@ -277,6 +279,7 @@ private: StartGameType with_config = StartGameType::Normal); void BootGameFromList(const QString& filename, StartGameType with_config); void ShutdownGame(); + void TerminateEmulationThread(); void SetDiscordEnabled(bool state); void LoadAmiibo(const QString& filename); @@ -435,6 +438,7 @@ private slots: void OnShutdownBeginDialog(); void OnEmulationStopped(); void OnEmulationStopTimeExpired(); + void OnEmulationStopTimeout(); #ifdef ENABLE_UPDATE_CHECKER void OnEmulatorUpdateAvailable(); @@ -499,11 +503,14 @@ private: #endif MultiplayerState* multiplayer_state = nullptr; + bool use_recommended_mkw_settings = false; + bool asked_recommended_mkw_settings = false; GRenderWindow* render_window = nullptr; GameList* game_list = nullptr; LoadingScreen* loading_screen = nullptr; QTimer shutdown_timer; + QTimer emu_terminate_timer; OverlayDialog* shutdown_dialog{}; PerformanceOverlay* perf_overlay = nullptr; #ifdef HAS_RESHADE @@ -543,6 +550,8 @@ private: // Whether emulation is currently running in yuzu. bool emulation_running = false; + bool boot_in_progress = false; + bool close_pending = false; // The path to the game currently running QString current_game_path; // Whether a user was set on the command line (skips UserSelector if it's forced to show up) @@ -574,7 +583,7 @@ private: // Install progress dialog QProgressDialog* install_progress = nullptr; - QProgressDialog* nup_progress = nullptr; + NupProgressDialog* nup_progress = nullptr; QFutureWatcher<FileSys::Nup::PackResult> nup_watcher; std::shared_ptr<std::atomic_bool> nup_cancel; bool nup_running{}; diff --git a/src/yuzu/nup_dialogs.cpp b/src/yuzu/nup_dialogs.cpp new file mode 100644 index 0000000000..cd7fcba496 --- /dev/null +++ b/src/yuzu/nup_dialogs.cpp @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "yuzu/nup_dialogs.h" + +#include <QBuffer> +#include <QCheckBox> +#include <QCloseEvent> +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QMimeData> +#include <QMouseEvent> +#include <QPaintEvent> +#include <QDialogButtonBox> +#include <QFile> +#include <QFileDialog> +#include <QFontMetrics> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QImage> +#include <QImageReader> +#include <QLabel> +#include <QLineEdit> +#include <QPainter> +#include <QPainterPath> +#include <QProgressBar> +#include <QPushButton> +#include <QRadioButton> +#include <QUrl> +#include <QVBoxLayout> + +#include "common/fs/path_util.h" + +namespace { + +constexpr int IconSize = 256; +constexpr int PreviewSize = 128; + +QString ToQString(const std::filesystem::path& path) { + return QString::fromStdString(Common::FS::PathToUTF8String(path)); +} + +QString Megabytes(u64 bytes) { + return QString::number(bytes / (1024 * 1024)); +} + +QPixmap PixmapFromBytes(const std::vector<u8>& bytes) { + QPixmap pixmap; + pixmap.loadFromData(bytes.data(), static_cast<uint>(bytes.size())); + return pixmap; +} + +QPixmap RoundedPreview(const QPixmap& source, int size) { + const qreal ratio = 2.0; + QPixmap out(QSize(size, size) * ratio); + out.setDevicePixelRatio(ratio); + out.fill(Qt::transparent); + QPainter painter(&out); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath clip; + clip.addRoundedRect(QRectF(0, 0, size, size), size / 8.0, size / 8.0); + painter.setClipPath(clip); + painter.drawPixmap(QRectF(0, 0, size, size), source, source.rect()); + return out; +} + +// The console distributes 256x256 JPEG icons. A picture that already is one is kept as is; +// anything else is cropped to a square, scaled and re-encoded. +QByteArray EncodeIcon(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray raw = file.readAll(); + QImage image; + if (!image.loadFromData(raw) || image.isNull()) { + return {}; + } + if (image.size() == QSize(IconSize, IconSize) && raw.startsWith("\xFF\xD8")) { + return raw; + } + const int side = std::min(image.width(), image.height()); + image = image.copy((image.width() - side) / 2, (image.height() - side) / 2, side, side) + .scaled(IconSize, IconSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flat(IconSize, IconSize, QImage::Format_RGB32); + flat.fill(Qt::black); + QPainter painter(&flat); + painter.drawImage(0, 0, image); + painter.end(); + QByteArray out; + QBuffer buffer(&out); + buffer.open(QIODevice::WriteOnly); + flat.save(&buffer, "JPEG", 92); + return out; +} + +// The first dropped local file that Qt can read as an image. +QString DroppedImage(const QMimeData* mime) { + if (mime == nullptr) { + return {}; + } + for (const auto& url : mime->urls()) { + if (url.isLocalFile() && !QImageReader(url.toLocalFile()).format().isEmpty()) { + return url.toLocalFile(); + } + } + return {}; +} + +} // namespace + +IconDropZone::IconDropZone(int size, QWidget* parent) : QWidget(parent) { + setFixedSize(size, size); + setAcceptDrops(true); + setCursor(Qt::PointingHandCursor); + setToolTip(tr("Drop an image here or click to browse")); +} + +void IconDropZone::SetIcon(const QPixmap& new_icon) { + icon = new_icon; + update(); +} + +void IconDropZone::paintEvent(QPaintEvent*) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF area = QRectF(rect()).adjusted(1, 1, -1, -1); + const bool active = dragging || hovered; + if (!icon.isNull()) { + painter.drawPixmap(0, 0, RoundedPreview(icon, width())); + if (active) { + painter.setBrush(QColor(0, 0, 0, 120)); + painter.setPen(Qt::NoPen); + painter.drawRoundedRect(area, width() / 8.0, width() / 8.0); + painter.setPen(Qt::white); + painter.drawText(area, Qt::AlignCenter | Qt::TextWordWrap, tr("Drop to replace")); + } + return; + } + const auto accent = palette().color(QPalette::Highlight); + QPen pen(active ? accent : palette().color(QPalette::Mid), 2, Qt::DashLine); + painter.setPen(pen); + QColor fill = active ? accent : palette().color(QPalette::Mid); + fill.setAlpha(active ? 40 : 20); + painter.setBrush(fill); + painter.drawRoundedRect(area, width() / 8.0, width() / 8.0); + painter.setPen(palette().color(QPalette::PlaceholderText)); + painter.drawText(area.adjusted(8, 8, -8, -8), Qt::AlignCenter | Qt::TextWordWrap, + dragging ? tr("Release to use this image") + : tr("Drop an icon here\nor click to browse")); +} + +void IconDropZone::dragEnterEvent(QDragEnterEvent* event) { + if (!DroppedImage(event->mimeData()).isEmpty()) { + dragging = true; + event->acceptProposedAction(); + update(); + } +} + +void IconDropZone::dragLeaveEvent(QDragLeaveEvent*) { + dragging = false; + update(); +} + +void IconDropZone::dropEvent(QDropEvent* event) { + dragging = false; + update(); + if (const auto path = DroppedImage(event->mimeData()); !path.isEmpty()) { + event->acceptProposedAction(); + emit FileDropped(path); + } +} + +void IconDropZone::mouseReleaseEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton && rect().contains(event->position().toPoint())) { + emit Clicked(); + } +} + +void IconDropZone::enterEvent(QEnterEvent*) { + hovered = true; + update(); +} + +void IconDropZone::leaveEvent(QEvent*) { + hovered = false; + update(); +} + +NupMetadataDialog::NupMetadataDialog(const std::filesystem::path& source, + const std::filesystem::path& archive, u64 needed_bytes, + u64 available_bytes, QWidget* parent) + : QDialog(parent), detected(FileSys::Nup::ProbeMetadata(source)) { + setWindowTitle(tr("Convert to NUP")); + setMinimumWidth(520); + + icon_preview = new IconDropZone(PreviewSize); + connect(icon_preview, &IconDropZone::Clicked, this, &NupMetadataDialog::ChooseIcon); + connect(icon_preview, &IconDropZone::FileDropped, this, &NupMetadataDialog::SetIconFromFile); + icon_hint = new QLabel; + icon_hint->setWordWrap(true); + icon_hint->setEnabled(false); + auto* choose_icon = new QPushButton(tr("Choose Icon...")); + reset_icon = new QPushButton(tr("Use Detected")); + connect(choose_icon, &QPushButton::clicked, this, &NupMetadataDialog::ChooseIcon); + connect(reset_icon, &QPushButton::clicked, this, &NupMetadataDialog::ResetIcon); + + auto* icon_buttons = new QVBoxLayout; + icon_buttons->addWidget(choose_icon); + if (!detected.icon.empty()) { + icon_buttons->addWidget(reset_icon); + } else { + reset_icon->hide(); + } + icon_buttons->addWidget(icon_hint); + icon_buttons->addStretch(); + auto* icon_row = new QHBoxLayout; + icon_row->setSpacing(16); + icon_row->addWidget(icon_preview, 0, Qt::AlignTop); + icon_row->addLayout(icon_buttons, 1); + + name_edit = new QLineEdit(QString::fromStdString(detected.title_name)); + name_edit->setPlaceholderText(tr("Name shown in the game list")); + name_edit->setClearButtonEnabled(true); + + version_auto = new QRadioButton; + if (detected.display_version.empty()) { + version_auto->setText(tr("Auto-detect (nothing found in the dump)")); + } else { + version_auto->setText(tr("Auto-detect: %1 (from %2)") + .arg(QString::fromStdString(detected.display_version), + QString::fromStdString(detected.version_source))); + } + version_manual = new QRadioButton(tr("Set manually:")); + version_edit = new QLineEdit; + version_edit->setPlaceholderText(tr("e.g. 1.2.0")); + version_auto->setChecked(true); + connect(version_auto, &QRadioButton::toggled, this, &NupMetadataDialog::UpdateVersionEnabled); + auto* manual_row = new QHBoxLayout; + manual_row->addWidget(version_manual); + manual_row->addWidget(version_edit, 1); + auto* version_box = new QVBoxLayout; + version_box->setSpacing(6); + version_box->addWidget(version_auto); + version_box->addLayout(manual_row); + + auto* form = new QFormLayout; + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(14); + form->addRow(tr("Icon"), icon_row); + form->addRow(tr("Name"), name_edit); + form->addRow(tr("Version"), version_box); + + auto* paths = new QLabel(tr("<b>Source</b><br>%1<br><b>Target</b><br>%2") + .arg(ToQString(source).toHtmlEscaped(), + ToQString(archive).toHtmlEscaped())); + paths->setTextFormat(Qt::RichText); + paths->setWordWrap(true); + paths->setTextInteractionFlags(Qt::TextSelectableByMouse); + + auto* space = new QLabel(tr("Approximate space needed: %1 MB. Free on the target volume: " + "%2 MB.") + .arg(Megabytes(needed_bytes), Megabytes(available_bytes))); + space->setWordWrap(true); + if (available_bytes < needed_bytes) { + space->setText(space->text() + QLatin1Char(' ') + + tr("Free space is less than the estimate.")); + } + + low_storage = new QCheckBox(tr("Low storage: delete the original files as they are packed " + "(cannot be undone)")); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Cancel); + auto* convert = buttons->addButton(tr("Convert"), QDialogButtonBox::AcceptRole); + convert->setDefault(true); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto* separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 16); + layout->setSpacing(14); + layout->addLayout(form); + layout->addWidget(separator); + layout->addWidget(paths); + layout->addWidget(space); + layout->addWidget(low_storage); + layout->addSpacing(4); + layout->addWidget(buttons); + + UpdateIconPreview(); + UpdateVersionEnabled(); +} + +NupMetadataDialog::~NupMetadataDialog() = default; + +FileSys::Nup::Metadata NupMetadataDialog::Metadata() const { + FileSys::Nup::Metadata result; + // Only what differs from the dump is an override. + if (const auto name = name_edit->text().trimmed().toStdString(); name != detected.title_name) { + result.title_name = name; + } + if (!custom_icon.isEmpty()) { + result.icon.assign(custom_icon.begin(), custom_icon.end()); + } + if (version_manual->isChecked()) { + result.display_version = version_edit->text().trimmed().toStdString(); + } + return result; +} + +bool NupMetadataDialog::DeleteSources() const { + return low_storage->isChecked(); +} + +QString NupMetadataDialog::DisplayName() const { + return name_edit->text().trimmed(); +} + +QPixmap NupMetadataDialog::CurrentIcon() const { + return current_icon; +} + +void NupMetadataDialog::ChooseIcon() { + const auto path = QFileDialog::getOpenFileName( + this, tr("Choose Icon"), {}, tr("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)")); + if (!path.isEmpty()) { + SetIconFromFile(path); + } +} + +void NupMetadataDialog::SetIconFromFile(const QString& path) { + const auto encoded = EncodeIcon(path); + if (encoded.isEmpty()) { + icon_hint->setText(tr("That file could not be read as an image.")); + return; + } + custom_icon = encoded; + UpdateIconPreview(); +} + +void NupMetadataDialog::ResetIcon() { + custom_icon.clear(); + UpdateIconPreview(); +} + +void NupMetadataDialog::UpdateIconPreview() { + QPixmap pixmap; + if (!custom_icon.isEmpty()) { + pixmap.loadFromData(custom_icon); + icon_hint->setText(tr("Custom icon, stored as %1x%1 JPEG.").arg(IconSize)); + } else if (!detected.icon.empty()) { + pixmap = PixmapFromBytes(detected.icon); + icon_hint->setText(tr("Icon found in the dump.")); + } else { + icon_hint->setText(tr("No icon found in the dump. Add one, or the NUP will have none.")); + } + current_icon = pixmap; + reset_icon->setEnabled(!custom_icon.isEmpty()); + icon_preview->SetIcon(pixmap); +} + +void NupMetadataDialog::UpdateVersionEnabled() { + version_edit->setEnabled(version_manual->isChecked()); + if (version_manual->isChecked()) { + version_edit->setFocus(); + } +} + +NupProgressDialog::NupProgressDialog(const QString& title, const QPixmap& icon, QWidget* parent) + : QDialog(parent, Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint) { + setWindowTitle(tr("Converting to NUP")); + setModal(true); + setMinimumWidth(480); + + auto* header = new QHBoxLayout; + header->setSpacing(14); + if (!icon.isNull()) { + auto* icon_label = new QLabel; + icon_label->setFixedSize(56, 56); + icon_label->setPixmap(RoundedPreview(icon, 56)); + header->addWidget(icon_label); + } + auto* title_label = new QLabel(title.isEmpty() ? tr("Converting to NUP") : title); + QFont font = title_label->font(); + font.setBold(true); + font.setPointSizeF(font.pointSizeF() * 1.2); + title_label->setFont(font); + title_label->setWordWrap(true); + status = new QLabel(tr("Preparing...")); + status->setEnabled(false); + auto* titles = new QVBoxLayout; + titles->setSpacing(2); + titles->addStretch(); + titles->addWidget(title_label); + titles->addWidget(status); + titles->addStretch(); + header->addLayout(titles, 1); + + bar = new QProgressBar; + bar->setRange(0, 1000); + bar->setTextVisible(false); + bar->setFixedHeight(14); + bar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + details = new QLabel; + details->setAlignment(Qt::AlignCenter); + details->setMinimumHeight(QFontMetrics(details->font()).height() * 2 + 4); + + cancel = new QPushButton(tr("Cancel")); + connect(cancel, &QPushButton::clicked, this, &NupProgressDialog::CancelRequested); + auto* button_row = new QHBoxLayout; + button_row->addStretch(); + button_row->addWidget(cancel); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 20); + layout->setSpacing(14); + layout->addLayout(header); + layout->addWidget(bar); + layout->addWidget(details); + layout->addLayout(button_row); + timer.start(); +} + +void NupProgressDialog::SetProgress(const FileSys::Nup::PackProgress& progress) { + if (cancelling) { + return; + } + const double fraction = + progress.bytes_total == 0 ? 0.0 : double(progress.bytes_done) / progress.bytes_total; + bar->setValue(static_cast<int>(fraction * 1000)); + status->setText(tr("%1%").arg(fraction * 100, 0, 'f', 1)); + + QString text = tr("%1 / %2 MB - %3 / %4 files") + .arg(Megabytes(progress.bytes_done), Megabytes(progress.bytes_total)) + .arg(progress.files_done) + .arg(progress.files_total); + const double seconds = timer.elapsed() / 1000.0; + if (seconds >= 3.0 && progress.bytes_done > 0 && fraction < 1.0) { + const double rate = progress.bytes_done / seconds; + const int remaining = static_cast<int>((progress.bytes_total - progress.bytes_done) / rate); + text += tr("\n%1 MB/s - about %2:%3 left") + .arg(rate / (1024 * 1024), 0, 'f', 0) + .arg(remaining / 60) + .arg(remaining % 60, 2, 10, QLatin1Char('0')); + } + details->setText(text); +} + +void NupProgressDialog::SetCancelling() { + cancelling = true; + cancel->setEnabled(false); + status->setText(tr("Cancelling...")); + bar->setRange(0, 0); +} + +void NupProgressDialog::Finish() { + finished = true; + close(); +} + +void NupProgressDialog::closeEvent(QCloseEvent* event) { + // Only the window code closes this dialog; a close request cancels the conversion. + if (!finished) { + emit CancelRequested(); + event->ignore(); + return; + } + QDialog::closeEvent(event); +} + +void NupProgressDialog::reject() { + if (!finished) { + emit CancelRequested(); + return; + } + QDialog::reject(); +} diff --git a/src/yuzu/nup_dialogs.h b/src/yuzu/nup_dialogs.h new file mode 100644 index 0000000000..b9258c216b --- /dev/null +++ b/src/yuzu/nup_dialogs.h @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <filesystem> + +#include <QByteArray> +#include <QDialog> +#include <QElapsedTimer> +#include <QPixmap> +#include <QWidget> + +#include "common/common_types.h" +#include "core/file_sys/nup_writer.h" + +class QCheckBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QRadioButton; + +// Square preview that accepts an image dropped on it or chosen with a click. +class IconDropZone : public QWidget { + Q_OBJECT +public: + explicit IconDropZone(int size, QWidget* parent = nullptr); + + void SetIcon(const QPixmap& icon); + +signals: + void FileDropped(const QString& path); + void Clicked(); + +protected: + void paintEvent(QPaintEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dragLeaveEvent(QDragLeaveEvent* event) override; + void dropEvent(QDropEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void enterEvent(QEnterEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + QPixmap icon; + bool dragging = false; + bool hovered = false; +}; + +// Asks for the metadata a NUP is written with and for the conversion options. +class NupMetadataDialog : public QDialog { + Q_OBJECT +public: + NupMetadataDialog(const std::filesystem::path& source, const std::filesystem::path& archive, + u64 needed_bytes, u64 available_bytes, QWidget* parent = nullptr); + ~NupMetadataDialog() override; + + // Empty fields keep what the dump supplies. + FileSys::Nup::Metadata Metadata() const; + bool DeleteSources() const; + // What the summary shows: the name entered and the icon that will be stored. + QString DisplayName() const; + QPixmap CurrentIcon() const; + +private: + void ChooseIcon(); + void SetIconFromFile(const QString& path); + void ResetIcon(); + void UpdateIconPreview(); + void UpdateVersionEnabled(); + + FileSys::Nup::Metadata detected; + QByteArray custom_icon; + QPixmap current_icon; + + IconDropZone* icon_preview; + QLabel* icon_hint; + QPushButton* reset_icon; + QLineEdit* name_edit; + QRadioButton* version_auto; + QRadioButton* version_manual; + QLineEdit* version_edit; + QCheckBox* low_storage; +}; + +// Shown while a conversion runs. The bar spans the dialog; the labels show what is being +// converted, how much is done and a rough time remaining. +class NupProgressDialog : public QDialog { + Q_OBJECT +public: + NupProgressDialog(const QString& title, const QPixmap& icon, QWidget* parent = nullptr); + + void SetProgress(const FileSys::Nup::PackProgress& progress); + void SetCancelling(); + // Closes the dialog without treating it as a cancel request. + void Finish(); + +signals: + void CancelRequested(); + +protected: + void closeEvent(QCloseEvent* event) override; + void reject() override; + +private: + QLabel* status; + QLabel* details; + QProgressBar* bar; + QPushButton* cancel; + QElapsedTimer timer; + bool cancelling = false; + bool finished = false; +}; |
