aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/common/game_presets.h37
-rw-r--r--src/common/settings.h8
-rw-r--r--src/common/slot_vector.h16
-rw-r--r--src/core/CMakeLists.txt5
-rw-r--r--src/core/device_memory_manager.inc5
-rw-r--r--src/core/file_sys/nup_container.cpp210
-rw-r--r--src/core/file_sys/nup_container.h54
-rw-r--r--src/core/file_sys/nup_writer.cpp99
-rw-r--r--src/core/file_sys/nup_writer.h24
-rw-r--r--src/core/hle/kernel/k_priority_queue.h20
-rw-r--r--src/core/hle/kernel/kernel.cpp4
-rw-r--r--src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp10
-rw-r--r--src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp5
-rw-r--r--src/core/hle/service/nx2_gsv.h77
-rw-r--r--src/core/loader/deconstructed_rom_directory.cpp75
-rw-r--r--src/core/memory.cpp15
-rw-r--r--src/input_common/drivers/sdl_driver.cpp26
-rw-r--r--src/input_common/drivers/udp_client.cpp4
-rw-r--r--src/input_common/drivers/udp_client.h1
-rw-r--r--src/qt_common/config/uisettings.cpp3
-rw-r--r--src/qt_common/config/uisettings.h2
-rw-r--r--src/qt_common/game_list/discovery.h113
-rw-r--r--src/qt_common/game_list/model.cpp21
-rw-r--r--src/qt_common/game_list/model.h2
-rw-r--r--src/qt_common/game_list/worker.cpp162
-rw-r--r--src/qt_common/game_list/worker.h7
-rw-r--r--src/qt_common/util/game.cpp11
-rw-r--r--src/shader_recompiler/ir_opt/texture_pass.cpp69
-rw-r--r--src/shader_recompiler/shader_info.h9
-rw-r--r--src/tests/CMakeLists.txt7
-rw-r--r--src/tests/common/game_presets.cpp111
-rw-r--r--src/tests/common/slot_vector.cpp54
-rw-r--r--src/tests/core/core_timing.cpp3
-rw-r--r--src/tests/core/nup_writer.cpp174
-rw-r--r--src/tests/core/priority_queue.cpp111
-rw-r--r--src/tests/input_common/calibration_configuration_job.cpp20
-rw-r--r--src/tests/video_core/descriptor_table.cpp170
-rw-r--r--src/tests/video_core/memory_tracker.cpp38
-rw-r--r--src/tests/video_core/native_macro_fifo.cpp74
-rw-r--r--src/tests/video_core/shader_invalidation.cpp120
-rw-r--r--src/tests/video_core/uniform_stream_reuse.cpp116
-rw-r--r--src/tests/video_core/vertex_repack.cpp33
-rw-r--r--src/video_core/buffer_cache/buffer_cache.h264
-rw-r--r--src/video_core/buffer_cache/buffer_cache_base.h62
-rw-r--r--src/video_core/buffer_cache/uniform_stream_reuse.h121
-rw-r--r--src/video_core/control/scheduler.cpp2
-rw-r--r--src/video_core/control/scheduler.h6
-rw-r--r--src/video_core/dma_pusher.cpp101
-rw-r--r--src/video_core/dma_pusher.h14
-rw-r--r--src/video_core/engines/draw_manager.cpp19
-rw-r--r--src/video_core/engines/fermi_2d.h3
-rw-r--r--src/video_core/engines/kepler_compute.cpp26
-rw-r--r--src/video_core/engines/maxwell_3d.cpp17
-rw-r--r--src/video_core/engines/maxwell_3d.h16
-rw-r--r--src/video_core/engines/puller.cpp2
-rw-r--r--src/video_core/gpu.cpp16
-rw-r--r--src/video_core/gpu.h7
-rw-r--r--src/video_core/gpu_split.cpp30
-rw-r--r--src/video_core/gpu_split.h10
-rw-r--r--src/video_core/gpu_thread.cpp11
-rw-r--r--src/video_core/gpu_thread.h5
-rw-r--r--src/video_core/host_shaders/CMakeLists.txt1
-rw-r--r--src/video_core/host_shaders/blit_color_srgb_to_linear.frag18
-rw-r--r--src/video_core/nx2.h32
-rw-r--r--src/video_core/rasterizer_interface.h14
-rw-r--r--src/video_core/renderer_opengl/gl_compute_pipeline.cpp25
-rw-r--r--src/video_core/renderer_opengl/gl_graphics_pipeline.cpp22
-rw-r--r--src/video_core/renderer_opengl/gl_shader_cache.cpp2
-rw-r--r--src/video_core/renderer_vulkan/blit_image.cpp7
-rw-r--r--src/video_core/renderer_vulkan/blit_image.h1
-rw-r--r--src/video_core/renderer_vulkan/fixed_pipeline_state.cpp2
-rw-r--r--src/video_core/renderer_vulkan/fixed_pipeline_state.h1
-rw-r--r--src/video_core/renderer_vulkan/vertex_repack.h35
-rw-r--r--src/video_core/renderer_vulkan/vk_buffer_cache.cpp21
-rw-r--r--src/video_core/renderer_vulkan/vk_buffer_cache.h36
-rw-r--r--src/video_core/renderer_vulkan/vk_compute_pass.cpp5
-rw-r--r--src/video_core/renderer_vulkan/vk_compute_pass.h7
-rw-r--r--src/video_core/renderer_vulkan/vk_compute_pipeline.cpp25
-rw-r--r--src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp22
-rw-r--r--src/video_core/renderer_vulkan/vk_pipeline_cache.cpp28
-rw-r--r--src/video_core/renderer_vulkan/vk_rasterizer.cpp69
-rw-r--r--src/video_core/renderer_vulkan/vk_rasterizer.h6
-rw-r--r--src/video_core/renderer_vulkan/vk_texture_cache.cpp45
-rw-r--r--src/video_core/shader_cache.cpp27
-rw-r--r--src/video_core/shader_cache.h4
-rw-r--r--src/video_core/shader_environment.cpp13
-rw-r--r--src/video_core/texture_cache/descriptor_table.h18
-rw-r--r--src/video_core/texture_cache/format_lookup_table.cpp1
-rw-r--r--src/video_core/texture_cache/texture_cache.h3
-rw-r--r--src/video_core/textures/texture.h8
-rw-r--r--src/video_core/vulkan_common/vulkan_device.h6
-rw-r--r--src/yuzu/CMakeLists.txt1
-rw-r--r--src/yuzu/game/game_list.cpp4
-rw-r--r--src/yuzu/game/game_list.h1
-rw-r--r--src/yuzu/main_window.cpp246
-rw-r--r--src/yuzu/main_window.h11
-rw-r--r--src/yuzu/nup_dialogs.cpp485
-rw-r--r--src/yuzu/nup_dialogs.h114
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{&copy, 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;
+};