From 28fee6a17506d4154aaaad3aa98750a93e144b9b Mon Sep 17 00:00:00 2001 From: ZnOw01 Date: Sat, 31 Oct 2026 11:02:42 -0500 Subject: [PATCH 001/154] video_core: bound macro binds and discard misparsed host blocks Maxwell3D::ProcessMacroBind wrote positions[start_address_ptr++] without checking the index. A misparsed pushbuffer produced indices like 0x3a400040 and 0x81500e36 against the 0x80-entry table, writing gigabytes past it and crashing the GPU thread. Bound the index and drop the write, and when the host class reaches the illegal method 0x1, discard the rest of that command block using its declared count instead of executing thousands of bogus methods. The skip survives entry boundaries so the next header parses aligned. Also record the failing instruction's PC in the macro JIT; it stayed zero, hiding where the JIT gave up. Assisted-by: Hermes Agent (AI-assisted analysis and patch) (cherry picked from commit eafb2d64faa5a1ee0f40916d43f8f9a91efb082c) (cherry picked from commit 3b547986fc5b4933f55dcbe25c9f88043ed2104f) --- src/video_core/dma_pusher.cpp | 30 +++++++++++++++++++++++++++ src/video_core/dma_pusher.h | 1 + src/video_core/engines/maxwell_3d.cpp | 11 ++++++++-- src/video_core/macro.cpp | 5 +++-- src/video_core/macro_turing_jit.h | 2 ++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 19319606bb..2e48366eda 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -244,6 +244,9 @@ bool DmaPusher::Step() { } } parked_methods.clear(); + // A rejected block cannot continue past its submission; do not let its skip eat the + // headers of the next one. + dma_state.skip_words = 0; dma_pushbuffer.pop_front(); dma_pushbuffer_subindex = 0; } else { @@ -274,6 +277,16 @@ static const bool g_record_parsed_entries = std::getenv("EDEN_NX2_PB_CHECK") != size_t DmaPusher::ProcessCommands(std::span commands, size_t word_offset) { for (size_t index = 0; index < commands.size();) { + // Discard the rest of a command block that was rejected (illegal method). The block's + // declared count still tells where the next header starts, so the stream stays aligned + // instead of running thousands of bogus methods. + if (dma_state.skip_words != 0) { + const u32 skip = + static_cast(std::min(dma_state.skip_words, commands.size() - index)); + index += skip; + dma_state.skip_words -= skip; + continue; + } // Data word of methods command if (dma_state.method_count && dma_state.non_incrementing) { auto const& command_header = commands[index]; //must ref (MUltiMethod re) @@ -428,6 +441,23 @@ bool DmaPusher::WantsSegment() const { } void DmaPusher::CallMethod(u32 argument) { + // Method 0x1 is illegal in the host class: a stream that reaches it has been misparsed (for + // example, a split method continued into a different command buffer). Discard the rest of + // the block using its declared count instead of running thousands of bogus methods against + // the engines; the next header then parses from an aligned position. + if (dma_state.method == static_cast(BufferMethods::Illegal)) { + const u32 remaining = dma_state.method_count > 0 ? dma_state.method_count - 1 : 0; + static std::atomic reported{}; + if (reported.fetch_add(1) < 8) { + LOG_ERROR(HW_GPU, + "Illegal host method on subchannel {}: discarding {} words of the block", + dma_state.subchannel, remaining); + } + dma_state.skip_words = remaining; + dma_state.method_count = 1; + dma_state.non_incrementing = false; + return; + } if (g_semaphore_watch != 0) [[unlikely]] { WatchArgument(dma_state.method, argument); } diff --git a/src/video_core/dma_pusher.h b/src/video_core/dma_pusher.h index a245c39819..78b26a5ee2 100644 --- a/src/video_core/dma_pusher.h +++ b/src/video_core/dma_pusher.h @@ -236,6 +236,7 @@ class DmaPusher final { u32 subchannel; ///< Current subchannel u32 method_count; ///< Current method count u32 length_pending; ///< Large NI command length pending + u32 skip_words; ///< Words left to discard from an invalid command block GPUVAddr dma_get; ///< Currently read segment u64 dma_word_offset; ///< Current word offset from address bool non_incrementing; ///< Current command's NI flag diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index bff0787bc9..c4797f8d82 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -591,9 +591,16 @@ void Maxwell3D::ProcessMacroUpload(u32 data) { void Maxwell3D::ProcessMacroBind(u32 data) { const auto positions = GetNativeMacroPositions(); const u32 index = regs.load_mme.start_address_ptr++; - if (index < positions.size()) { - positions[index] = data; + if (index >= positions.size()) { + static std::atomic rejected{}; + if (rejected.fetch_add(1) < 8) { + LOG_ERROR(HW_GPU, + "Rejecting out-of-range macro bind: index={:#x}, entries={}, data={:#x}", + index, positions.size(), data); + } + return; } + positions[index] = data; } void Maxwell3D::ProcessFirmwareCall4() { diff --git a/src/video_core/macro.cpp b/src/video_core/macro.cpp index 94297e8fea..7d0b7afad0 100644 --- a/src/video_core/macro.cpp +++ b/src/video_core/macro.cpp @@ -1487,8 +1487,9 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u emit); if (!result.success) { native_execution_failed = true; - LOG_ERROR(HW_GPU, "Native MME (JIT) failed: start={:#x}, consumed={}/{}, error={}", - method, result.consumed, parameters.size(), result.error); + LOG_ERROR(HW_GPU, + "Native MME (JIT) failed: start={:#x}, pc={:#x}, consumed={}/{}, error={}", + method, result.pc, result.consumed, parameters.size(), result.error); } return; } diff --git a/src/video_core/macro_turing_jit.h b/src/video_core/macro_turing_jit.h index d0f24080a3..b57ba8c377 100644 --- a/src/video_core/macro_turing_jit.h +++ b/src/video_core/macro_turing_jit.h @@ -299,6 +299,8 @@ class TuringMacroJit { } void Instruction(Word pc, Word count, const Decoded& d) { + // Keep the failing PC available for diagnostics; it used to stay zero. + mov(dword[st + offsetof(State, fail_pc)], pc); // FIFO loads, in lane order. for (Word i = 0; i < 2; ++i) { const Xbyak::Reg32 load = i == 0 ? r12d : r13d; From fc862e17c06a788b08004b240d98866925cbcd66 Mon Sep 17 00:00:00 2001 From: ZnOw01 Date: Sun, 1 Nov 2026 09:04:59 -0500 Subject: [PATCH 002/154] texture_cache: reject zero-extent render targets Return the null image view before image lookup or insertion for render targets with an empty dimension. Assisted-by: Hermes Agent (cherry picked from commit f39979345123e0235893ab1d7db7c9d84d80fc2f) (cherry picked from commit 5baa81604c0e344c4ba85a6cc5e2ec096af21bcd) --- src/video_core/texture_cache/texture_cache.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 25a313dd22..e4e8927bcf 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -2907,6 +2907,9 @@ ImageViewId TextureCache

::FindDepthBuffer() { template ImageViewId TextureCache

::FindRenderTargetView(const ImageInfo& info, GPUVAddr gpu_addr) { + if (info.size.width == 0 || info.size.height == 0 || info.size.depth == 0) { + return NULL_IMAGE_VIEW_ID; + } ImageId image_id{}; bool delete_state = has_deleted_images; do { From 52c2932edea788ffe1b7efe60864e91b0eb6f471 Mon Sep 17 00:00:00 2001 From: Franseven Date: Sat, 31 Oct 2026 00:57:35 +0100 Subject: [PATCH 003/154] shader: FSEL sources keep .NEG/.ABS, and native array-cube vector samples FSEL shared the bitwise SEL case and so dropped the negation and absolute bits of its sources. Ampere TXD/TLD with dimension 7, LOD 3 and up to four components is a native array-cube vector sample; lower it to the two-operand Maxwell form when the instruction fits and refuse it otherwise. On nx2: the FSEL part is already there (Duck912's form); this brings the native array-cube vector samples. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 07c017f1d3c71c8da579909dfa31cf2ac4ac207f) (cherry picked from commit 1d32521a59c5281af50393fbfa815c2ef6857347) --- .../frontend/ampere/lower_to_maxwell.h | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h index b86c0ee0e3..d9df752556 100644 --- a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h +++ b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h @@ -2213,6 +2213,28 @@ inline LoweredProgram LowerToMaxwellWith(std::span code, u32 const bool depth_compare = insn.Bits(78, 1); const u32 dimension = insn.Bits(61, 3), lod = insn.Bits(87, 3); const u32 source_a = insn.Bits(24, 8), source_b = insn.Bits(32, 8); + if (!bindless && !insn.Bits(60, 1) && dimension == 7 && lod == 3) { + const u32 components = std::popcount(mask); + if (depth_compare || !components || split != (components > 2) || + insn.Bits(76, 1) || insn.Bits(81, 3) != 7 || insn.Bits(59, 1) || + insn.Bits(40, 14) > 0x1fff || insn.Bits(54, 5) != 2 || + source_a >= 246 || source_b >= 250 || dest == 255 || + (components > 1 && ((dest & 1) || dest >= 254)) || + (components == 4 && ((second_dest & 1) || second_dest >= 254))) + return fail(index, "native array-cube vector sample mode"); + result.required_texture_buffer = 2; + // Vector coordinates already use layer,X,Y,Z order. LOD is + // supplied separately, matching the Maxwell TEX operand ABI. + emit(0xc000000000000000ULL | predicate | 250 | (u64{source_a} << 8) | + (u64{source_b} << 20) | (u64{7} << 28) | (u64{mask} << 31) | + (u64{insn.Bits(77, 1)} << 35) | (u64{insn.Bits(40, 14)} << 36) | + (u64{insn.Bits(90, 1)} << 49) | (u64{7} << 51) | (u64{lod} << 55)); + for (u32 i = 0; i < components; ++i) + emit(0x5c98000000000000ULL | predicate | + (i < 2 ? dest + i : second_dest + i - 2) | + (u64{15} << 39) | (u64{250 + i} << 20)); + break; + } if (!bindless && dimension == 5 && depth_compare && lod == 0 && !split && mask == 1) { if (!insn.Bits(60, 1) || insn.Bits(76, 2) || insn.Bits(81, 3) != 7 || insn.Bits(59, 1) || insn.Bits(40, 14) > 0x1fff || insn.Bits(54, 5) != 2 || From 792daba207ab5d81dc1d2b34ec027e2a65147d08 Mon Sep 17 00:00:00 2001 From: Franseven Date: Sat, 31 Oct 2026 09:55:22 +0100 Subject: [PATCH 004/154] core: ignore stale page runs at unmapped pages in WalkBlock WalkBlock sizes each step from continuity_tracker, the number of pages after the current one that are physically contiguous with it. That count is meaningless when the current device page is unmapped: a run left over from an earlier mapping can make the walk skip past the unmapped page and hand on_unmapped a block wider than the hole, losing the dialogue text Splatoon Raiders streams through such gaps. Treat an unmapped page as a one-page block so the walk resynchronises on the next page instead of trusting a run that does not start at it. Ported from Eden-NS2 bbbadec4fb. Assisted-by: Claude Opus 5.5 (implementation and debugging) Picked-from: PokeDinner 06c9f77a59f159524103a7c67f4438e247cd05d0 (cherry picked from commit d66307b1e4c7158d5ac9b60f43cfae12036fc594) (cherry picked from commit 5cccacefcf34677facbb47766f6a1ed355ac13bb) --- src/core/device_memory_manager.inc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/device_memory_manager.inc b/src/core/device_memory_manager.inc index 5931fd0882..221a448bb3 100644 --- a/src/core/device_memory_manager.inc +++ b/src/core/device_memory_manager.inc @@ -443,7 +443,11 @@ void DeviceMemoryManager::WalkBlock(DAddr addr, std::size_t size, auto o std::size_t page_index = addr >> Memory::YUZU_PAGEBITS; std::size_t page_offset = addr & Memory::YUZU_PAGEMASK; while (remaining_size) { - const size_t next_pages = std::size_t(tracked_entries[page_index].continuity_tracker+1); + const auto phys_addr = tracked_entries[page_index].compressed_physical_ptr; + // An unmapped page says nothing about the pages after it: its run cannot be trusted, so + // only this page is handed to on_unmapped before the walk moves on. + const size_t next_pages = + phys_addr == 0 ? 1 : std::size_t(tracked_entries[page_index].continuity_tracker + 1); const std::size_t copy_amount = (std::min)((next_pages << Memory::YUZU_PAGEBITS) - page_offset, remaining_size); const auto current_vaddr = u64((page_index << Memory::YUZU_PAGEBITS) + page_offset); SCOPE_EXIT{ @@ -453,7 +457,6 @@ void DeviceMemoryManager::WalkBlock(DAddr addr, std::size_t size, auto o remaining_size -= copy_amount; }; - auto phys_addr = tracked_entries[page_index].compressed_physical_ptr; if (phys_addr == 0) { on_unmapped(copy_amount, current_vaddr); continue; From c520ca6d85ab96ba61a00689c8ad66ef86cdc4fa Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:24:02 -0400 Subject: [PATCH 005/154] buffer_cache: never join a buffer through a stale page table entry Two GUI crashes in Fortune's Weave read a null LRU item in LeastRecentlyUsedCache::Free with id SIZE_MAX, under CreateBuffer -> JoinOverlap -> DeleteBuffer from a compute dispatch. Only a buffer that was inserted but not yet registered has that id, so a page table entry still named an erased slot, CreateBuffer reused the slot for the new buffer and then joined the new buffer with itself. ResolveOverlaps now skips and clears page table entries whose slot is not live, logging the first few. The inline index buffer resize erased its slot without Unregister, leaving its pages and LRU item behind; it now goes through DeleteBuffer, and DeleteBuffer resets inline_buffer_id so a garbage-collected inline buffer is recreated instead of reused. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 4a6b890171c0adaff111ecd2353cca6aa0f45c7d) (cherry picked from commit ef868d472e3c082ee206d574be7f2adf31199a3f) --- src/common/slot_vector.h | 6 ++++++ src/video_core/buffer_cache/buffer_cache.h | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/common/slot_vector.h b/src/common/slot_vector.h index 8e1c06e35a..b94168aa51 100644 --- a/src/common/slot_vector.h +++ b/src/common/slot_vector.h @@ -138,6 +138,12 @@ class SlotVector { ResetStorageBit(id.Value()); } + /// Returns true when id refers to a slot that currently holds an object. + [[nodiscard]] bool contains(SlotId id) const noexcept { + return id && id.Value() / 64 < stored_bitset.size() && + ((stored_bitset[id.Value() / 64] >> (id.Value() % 64)) & 1) != 0; + } + [[nodiscard]] Iterator begin() noexcept { const auto it = std::ranges::find_if(stored_bitset, [](u64 value) { return value != 0; }); if (it == stored_bitset.end()) { diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 44ce6916bd..af7e8e5db6 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -1529,10 +1529,14 @@ void BufferCache

::UpdateIndexBuffer() { auto inline_index_size = static_cast(draw_state.inline_index_draw_indexes.size()); u32 buffer_size = Common::AlignUp(inline_index_size, CACHING_PAGESIZE); if (inline_buffer_id == NULL_BUFFER_ID) [[unlikely]] { + LOG_INFO(HW_GPU, "Creating inline index buffer of {:#x} bytes", buffer_size); inline_buffer_id = CreateBuffer(0, buffer_size, false); } if (slot_buffers[inline_buffer_id].SizeBytes() < buffer_size) [[unlikely]] { - slot_buffers.erase(inline_buffer_id); + LOG_INFO(HW_GPU, "Growing inline index buffer to {:#x} bytes", buffer_size); + // Erasing the slot directly left its page table entries and LRU item behind, so a + // later CreateBuffer could reuse the slot and join the new buffer with itself. + DeleteBuffer(inline_buffer_id, true); inline_buffer_id = CreateBuffer(0, buffer_size, false); } channel_state->index_buffer = Binding{ @@ -2036,6 +2040,18 @@ typename BufferCache

::OverlapResult BufferCache

::ResolveOverlaps(DAddr dev if (!overlap_id) { continue; } + if (!slot_buffers.contains(overlap_id)) [[unlikely]] { + // A deleted buffer is still in the page table. Joining it would delete whichever + // buffer reuses the slot next, possibly the one being created here. + static u32 stale_logs = 0; + if (stale_logs < 16) { + ++stale_logs; + LOG_ERROR(HW_GPU, "Stale buffer page: device_addr={:#x} buffer_id={}", + device_addr, overlap_id.index); + } + page_table[device_addr >> CACHING_PAGEBITS] = BufferId{}; + continue; + } Buffer& overlap = slot_buffers[overlap_id]; if (overlap.IsPicked()) { continue; @@ -2447,6 +2463,10 @@ void BufferCache

::DownloadBufferMemory(Buffer& buffer, DAddr device_addr, u64 template void BufferCache

::DeleteBuffer(BufferId buffer_id, bool do_not_mark) { + if (buffer_id == inline_buffer_id) { + // The garbage collector can delete the inline index buffer; recreate it on next use. + inline_buffer_id = NULL_BUFFER_ID; + } if constexpr (requires { runtime.OnBufferDeleted(slot_buffers[buffer_id]); }) { runtime.OnBufferDeleted(slot_buffers[buffer_id]); } From d3fdaed2095d2bbcd4395a7f60188da89d5bbfc9 Mon Sep 17 00:00:00 2001 From: Franseven Date: Fri, 30 Oct 2026 13:18:38 +0100 Subject: [PATCH 006/154] nvdrv: clear the error notifier when it is installed (from Eden-NS2 de4f497) Assisted-by: Claude Opus 5.5 (implementation and debugging) Picked-from: PokeDinner f555303386b2b051b9f915391eca5e7f51404b70 (cherry picked from commit c3a8f328a95db2d0055e0b939745585f81fe004d) (cherry picked from commit 23f6509a5e46c95a120d46888c03caab58332cf5) --- src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp index 79586446d5..395a1fe273 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp @@ -181,6 +181,14 @@ NvResult nvhost_gpu::ZCullBind(IoctlZCullBind& params) { NvResult nvhost_gpu::SetErrorNotifier(IoctlSetErrorNotifier& params) { LOG_WARNING(Service_NVDRV, "(STUBBED) called, offset={:X}, size={:X}, mem={:X}", params.offset, params.size, params.mem); + // Like the host driver, clear the notifier when it is installed: NVN polls its status word and + // treats a non-zero value (whatever the buffer held before) as a channel error. + if (const auto handle = nvmap.GetHandle(params.mem); + handle && handle->address && params.size && params.size <= 0x10000 && + params.offset + params.size <= handle->size) { + const std::vector zeros(params.size); + system.ApplicationMemory().WriteBlock(handle->address + params.offset, zeros.data(), zeros.size()); + } return NvResult::Success; } From 2ce9116b2e443385356b2938350742d42a5c84ab Mon Sep 17 00:00:00 2001 From: Franseven Date: Fri, 30 Oct 2026 13:19:02 +0100 Subject: [PATCH 007/154] shader: translate F2I to 8-bit integers (from Eden-NS2 de4f497) Assisted-by: Claude Opus 5.5 (implementation and debugging) Picked-from: PokeDinner 87df5ae27e46ab79c34aee982100c025bbb4969b (cherry picked from commit d636724efa5325ca459b8e60eb20ec6ae38a2638) (cherry picked from commit 09faa6e25dbefb751191bf54d20365ab7ca761a4) --- .../impl/floating_point_conversion_integer.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/shader_recompiler/frontend/maxwell/translate/impl/floating_point_conversion_integer.cpp b/src/shader_recompiler/frontend/maxwell/translate/impl/floating_point_conversion_integer.cpp index c8c9e26248..0e07d0a239 100644 --- a/src/shader_recompiler/frontend/maxwell/translate/impl/floating_point_conversion_integer.cpp +++ b/src/shader_recompiler/frontend/maxwell/translate/impl/floating_point_conversion_integer.cpp @@ -14,7 +14,7 @@ namespace Shader::Maxwell { namespace { enum class DestFormat : u64 { - Invalid, + I8, I16, I32, I64, @@ -48,6 +48,8 @@ union F2I { size_t BitSize(DestFormat dest_format) { switch (dest_format) { + case DestFormat::I8: + return 8; case DestFormat::I16: return 16; case DestFormat::I32: @@ -62,6 +64,9 @@ size_t BitSize(DestFormat dest_format) { std::pair ClampBounds(DestFormat format, bool is_signed) { if (is_signed) { switch (format) { + case DestFormat::I8: + return {static_cast((std::numeric_limits::max)()), + static_cast((std::numeric_limits::min)())}; case DestFormat::I16: return {static_cast((std::numeric_limits::max)()), static_cast((std::numeric_limits::min)())}; @@ -76,6 +81,9 @@ std::pair ClampBounds(DestFormat format, bool is_signed) { } } else { switch (format) { + case DestFormat::I8: + return {static_cast((std::numeric_limits::max)()), + static_cast((std::numeric_limits::min)())}; case DestFormat::I16: return {static_cast((std::numeric_limits::max)()), static_cast((std::numeric_limits::min)())}; From 4a497a76c54f17e8167a41f3bb0d2a5ea517cff0 Mon Sep 17 00:00:00 2001 From: Franseven Date: Fri, 30 Oct 2026 13:19:27 +0100 Subject: [PATCH 008/154] shader: translate PRMT in its default mode (from Eden-NS2 de4f497) Assisted-by: Claude Opus 5.5 (implementation and debugging) Picked-from: PokeDinner 1064c5cb08341aa189f8136c6aaff9eb48c40b4a (cherry picked from commit 206a703a07bd4953f3c2478672c5f20909f91b37) (cherry picked from commit ee8513077406ea164bb2191764846cb7c9c0471b) --- src/shader_recompiler/CMakeLists.txt | 1 + .../translate/impl/integer_byte_permute.cpp | 56 +++++++++++++++++++ .../translate/impl/not_implemented.cpp | 16 ------ 3 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 src/shader_recompiler/frontend/maxwell/translate/impl/integer_byte_permute.cpp diff --git a/src/shader_recompiler/CMakeLists.txt b/src/shader_recompiler/CMakeLists.txt index e4df9c045d..aa03648c45 100644 --- a/src/shader_recompiler/CMakeLists.txt +++ b/src/shader_recompiler/CMakeLists.txt @@ -117,6 +117,7 @@ add_library(shader_recompiler STATIC frontend/maxwell/translate/impl/impl.h frontend/maxwell/translate/impl/integer_add.cpp frontend/maxwell/translate/impl/integer_add_three_input.cpp + frontend/maxwell/translate/impl/integer_byte_permute.cpp frontend/maxwell/translate/impl/integer_compare.cpp frontend/maxwell/translate/impl/integer_compare_and_set.cpp frontend/maxwell/translate/impl/integer_floating_point_conversion.cpp diff --git a/src/shader_recompiler/frontend/maxwell/translate/impl/integer_byte_permute.cpp b/src/shader_recompiler/frontend/maxwell/translate/impl/integer_byte_permute.cpp new file mode 100644 index 0000000000..66c87db909 --- /dev/null +++ b/src/shader_recompiler/frontend/maxwell/translate/impl/integer_byte_permute.cpp @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "common/bit_field.h" +#include "common/common_types.h" +#include "shader_recompiler/frontend/maxwell/translate/impl/impl.h" + +namespace Shader::Maxwell { +namespace { +// PRMT in its default mode: each result byte is picked by a selector nibble from the eight bytes +// {c, a} (0-3 from a, 4-7 from c); bit 3 of the nibble replicates the picked byte's sign bit. +void PRMT(TranslatorVisitor& v, u64 insn, const IR::U32& selector, const IR::U32& src_c) { + union { + u64 raw; + BitField<0, 8, IR::Reg> dest_reg; + BitField<8, 8, IR::Reg> src_a_reg; + BitField<48, 3, u64> mode; + } const prmt{insn}; + if (prmt.mode != 0) { + throw NotImplementedException("PRMT mode {}", prmt.mode.Value()); + } + const IR::U32 src_a{v.X(prmt.src_a_reg)}; + IR::U32 result{v.ir.Imm32(0)}; + for (u32 byte = 0; byte < 4; ++byte) { + const IR::U32 nibble{v.ir.BitFieldExtract(selector, v.ir.Imm32(byte * 4), v.ir.Imm32(4))}; + const IR::U32 index{v.ir.BitwiseAnd(nibble, v.ir.Imm32(7))}; + const IR::U32 source{ + IR::U32{v.ir.Select(v.ir.ILessThan(index, v.ir.Imm32(4), false), src_a, src_c)}}; + const IR::U32 offset{v.ir.ShiftLeftLogical(v.ir.BitwiseAnd(index, v.ir.Imm32(3)), v.ir.Imm32(3))}; + const IR::U32 value{v.ir.BitFieldExtract(source, offset, v.ir.Imm32(8))}; + const IR::U32 sign{v.ir.BitFieldExtract(source, v.ir.IAdd(offset, v.ir.Imm32(7)), v.ir.Imm32(1), true)}; + const IR::U1 replicate{v.ir.INotEqual(v.ir.BitwiseAnd(nibble, v.ir.Imm32(8)), v.ir.Imm32(0))}; + const IR::U32 picked{IR::U32{v.ir.Select(replicate, sign, value)}}; + result = v.ir.BitFieldInsert(result, picked, v.ir.Imm32(byte * 8), v.ir.Imm32(8)); + } + v.X(prmt.dest_reg, result); +} +} // Anonymous namespace + +void TranslatorVisitor::PRMT_reg(u64 insn) { + PRMT(*this, insn, GetReg20(insn), GetReg39(insn)); +} + +void TranslatorVisitor::PRMT_rc(u64 insn) { + PRMT(*this, insn, GetReg39(insn), GetCbuf(insn)); +} + +void TranslatorVisitor::PRMT_cr(u64 insn) { + PRMT(*this, insn, GetCbuf(insn), GetReg39(insn)); +} + +void TranslatorVisitor::PRMT_imm(u64 insn) { + PRMT(*this, insn, GetImm20(insn), GetReg39(insn)); +} + +} // namespace Shader::Maxwell diff --git a/src/shader_recompiler/frontend/maxwell/translate/impl/not_implemented.cpp b/src/shader_recompiler/frontend/maxwell/translate/impl/not_implemented.cpp index 369af8e03a..4165ca2f66 100644 --- a/src/shader_recompiler/frontend/maxwell/translate/impl/not_implemented.cpp +++ b/src/shader_recompiler/frontend/maxwell/translate/impl/not_implemented.cpp @@ -195,22 +195,6 @@ void TranslatorVisitor::PRET(u64) { ThrowNotImplemented(Opcode::PRET); } -void TranslatorVisitor::PRMT_reg(u64) { - ThrowNotImplemented(Opcode::PRMT_reg); -} - -void TranslatorVisitor::PRMT_rc(u64) { - ThrowNotImplemented(Opcode::PRMT_rc); -} - -void TranslatorVisitor::PRMT_cr(u64) { - ThrowNotImplemented(Opcode::PRMT_cr); -} - -void TranslatorVisitor::PRMT_imm(u64) { - ThrowNotImplemented(Opcode::PRMT_imm); -} - void TranslatorVisitor::R2B(u64) { ThrowNotImplemented(Opcode::R2B); } From 73b60ac02c4b3752bf52713d1c9f813057480ae5 Mon Sep 17 00:00:00 2001 From: Franseven Date: Fri, 30 Oct 2026 13:30:51 +0100 Subject: [PATCH 009/154] renderer_vulkan: rotate three resource sets for the applet capture (from Eden-NS2 e2f285a) Assisted-by: Claude Opus 5.5 (implementation and debugging) Picked-from: PokeDinner b81a277e6363c012789b189f186a3ee299b12717 (cherry picked from commit 9230f08b90c8bee195ca417410b9286f64adf06a) (cherry picked from commit 1d1abc205496ff08c20b04c94f2005c9ffe888b0) --- src/video_core/renderer_vulkan/renderer_vulkan.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index fc0eea84ab..18f59c8d96 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -363,9 +363,13 @@ void RendererVulkan::RenderAppletCaptureLayer( VideoCore::Capture::Layout, *applet_frame.image_view, CaptureFormat); } + // The capture is drawn every frame. With one set of layer resources each frame waited for + // the host GPU to finish the previous one; rotating through three lets the GPU thread stay + // ahead like the swapchain blit does. + constexpr size_t CaptureResourceSets = 3; scheduler.RequestOutsideRenderPassOperationContext(); - blit_applet.DrawToFrame(device, rasterizer, &applet_frame, capture_layers, VideoCore::Capture::Layout, 1, - CaptureFormat); + blit_applet.DrawToFrame(device, rasterizer, &applet_frame, capture_layers, VideoCore::Capture::Layout, + CaptureResourceSets, CaptureFormat); } } // namespace Vulkan From b6fef3d28f016cdfc5e1969ca930ea01678afff1 Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:23:51 +0100 Subject: [PATCH 010/154] shader_recompiler: drop unused identities at the end of the SSA rewrite Reads walk back through every block until they find a definition, and each multi-predecessor block on the way gets an operandless phi that is usually trivial. Those phis, and the replaced register reads, stay in their blocks as Identity instructions: a 2,572-block Mario Kart World fragment shader got 222,000 of them for 126,000 real instructions, and every pass until dead code elimination walked them. Those without uses are now removed when the pass ends, in the order dead code elimination would have removed them. ReadVariable also reuses one walk stack instead of growing a new vector on every read. Emitted SPIR-V is identical for the 11,883 modules of two Mario Kart World disk caches (eden-shader-bench --hashes). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 55f28f57cf661754a5a08302990ebdb628147588) (cherry picked from commit a3c4ce20a109658c570e686d812da0232ea27b7c) (cherry picked from commit 82d93cfa72fc3709c199037eceb99229c3a92c68) --- .../ir_opt/ssa_rewrite_pass.cpp | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp index b5cd233992..ebbb934b08 100644 --- a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp +++ b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp @@ -147,7 +147,6 @@ enum class Status { PushPhiArgument, }; -template struct ReadState { ReadState(IR::Block* block_) : block{block_} {} ReadState() = default; @@ -169,11 +168,12 @@ class Pass { template IR::Value ReadVariable(Type variable, IR::Block* root_block) { - // TODO: Windows commits sodoku if you use small_vector - std::vector> stack{ - ReadState(nullptr), - ReadState(root_block), - }; + // Reads walk back through every block until they find a definition, often dozens deep: + // reuse one stack instead of growing a new vector on every read. + auto& stack{read_stack}; + stack.clear(); + stack.emplace_back(nullptr); + stack.emplace_back(root_block); const auto prepare_phi_operand{[&] { if (stack.back().pred_it == stack.back().pred_end) { IR::Inst* const phi{stack.back().phi}; @@ -303,6 +303,8 @@ class Pass { // TODO: Windows dies with stack exhaustion? std::unordered_map> incomplete_phis; DefTable current_def; + // ReadVariable never nests, so its walk stack can be shared + std::vector read_stack; }; void VisitInst(Pass& pass, IR::Block* block, IR::Inst& inst) { @@ -412,6 +414,20 @@ void SsaRewritePass(IR::Program& program) { } } } + // Trivial phis and replaced reads stay in their blocks as Identity instructions, often more + // of them than real instructions. Those without uses cannot gain any now that the definition + // tables are gone: drop them here, as dead code elimination would later (same order), instead + // of letting every pass until then walk them. + for (IR::Block* const block : program.post_order_blocks) { + auto it{block->end()}; + while (it != block->begin()) { + --it; + if (it->GetOpcode() == IR::Opcode::Identity && !it->HasUses()) { + it->Invalidate(); + it = block->Instructions().erase(it); + } + } + } } } // namespace Shader::Optimization From efc72987fde75016db681af9016fee1990ad1bd7 Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:35:40 +0100 Subject: [PATCH 011/154] shader_recompiler: keep SSA definitions of predicates and flags in blocks Registers already store their SSA definitions in the blocks. Predicates, the condition flags and the indirect branch variable used one node-based std::unordered_map each, keyed by block, and goto variables a map of such maps; every read walks back through blocks, looking up (and inserting) a definition in each. Those lookups and their allocations were ~13% of a profile of the benchmark. The fixed-count variables now live in the blocks next to the registers, goto variables in one flat map keyed by (block, index). Lookups no longer insert empty entries, which only the lookups themselves could see. Emitted SPIR-V is identical for the 11,883 modules of two Mario Kart World disk caches (eden-shader-bench --hashes). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 1fcc877fe71a7e7c448aca78ed35662ff7157e75) (cherry picked from commit 981247484fe1c5e7fe513ebef4e16b3af483398f) (cherry picked from commit 984ad7761abf73bd7b56515dbcccfdd0c33a3871) --- .../frontend/ir/basic_block.h | 13 ++++ .../ir_opt/ssa_rewrite_pass.cpp | 64 ++++++++++++------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/shader_recompiler/frontend/ir/basic_block.h b/src/shader_recompiler/frontend/ir/basic_block.h index d3a66caac9..e86f05dc1f 100644 --- a/src/shader_recompiler/frontend/ir/basic_block.h +++ b/src/shader_recompiler/frontend/ir/basic_block.h @@ -89,6 +89,17 @@ class Block { return ssa_reg_values[RegIndex(reg)]; } + /// Number of other fixed-count variables the SSA pass stores in blocks + /// (predicates, the four condition flags and the indirect branch variable). + static constexpr size_t NUM_SSA_VALUES = NUM_USER_PREDS + 5; + + void SetSsaValue(size_t index, const Value& value) noexcept { + ssa_values[index] = value; + } + const Value& SsaValue(size_t index) const noexcept { + return ssa_values[index]; + } + void SsaSeal() noexcept { is_ssa_sealed = true; } @@ -182,6 +193,8 @@ class Block { /// Intrusively store the value of a register in the block. std::array ssa_reg_values; + /// Intrusively store the value of the other fixed-count SSA variables in the block. + std::array ssa_values; /// Intrusively store if the block is sealed in the SSA pass. bool is_ssa_sealed{false}; diff --git a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp index ebbb934b08..b7d5cf5408 100644 --- a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp +++ b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp @@ -54,8 +54,29 @@ struct IndirectBranchVariable { }; using Variant = std::variant; -// TODO: majority of these require stable iterators, test with XC beforehand -using ValueMap = std::unordered_map; +// Definitions are copied out as soon as they are read, so the tables need no stable references. +// Fixed-count variables live in the blocks, like registers: every read walks back through +// blocks and looks up or writes a definition in each, and node-based maps per variable made +// those lookups and their allocations a large part of the pass. +constexpr size_t SSA_ZERO_FLAG = IR::NUM_USER_PREDS; +constexpr size_t SSA_SIGN_FLAG = IR::NUM_USER_PREDS + 1; +constexpr size_t SSA_CARRY_FLAG = IR::NUM_USER_PREDS + 2; +constexpr size_t SSA_OVERFLOW_FLAG = IR::NUM_USER_PREDS + 3; +constexpr size_t SSA_INDIRECT_BRANCH = IR::NUM_USER_PREDS + 4; +static_assert(SSA_INDIRECT_BRANCH < IR::Block::NUM_SSA_VALUES); + +struct GotoKey { + bool operator==(const GotoKey&) const noexcept = default; + + IR::Block* block; + u32 index; +}; + +struct GotoKeyHash { + size_t operator()(const GotoKey& key) const noexcept { + return std::hash{}(key.block) ^ (static_cast(key.index) << 32); + } +}; struct DefTable { const IR::Value& Def(IR::Block* block, IR::Reg variable) { @@ -66,62 +87,57 @@ struct DefTable { } const IR::Value& Def(IR::Block* block, IR::Pred variable) { - return preds[IR::PredIndex(variable)][block]; + return block->SsaValue(IR::PredIndex(variable)); } void SetDef(IR::Block* block, IR::Pred variable, const IR::Value& value) { - preds[IR::PredIndex(variable)].insert_or_assign(block, value); + block->SetSsaValue(IR::PredIndex(variable), value); } const IR::Value& Def(IR::Block* block, GotoVariable variable) { - return goto_vars[variable.index][block]; + const auto it{goto_vars.find(GotoKey{block, variable.index})}; + return it != goto_vars.end() ? it->second : empty; } void SetDef(IR::Block* block, GotoVariable variable, const IR::Value& value) { - goto_vars[variable.index].insert_or_assign(block, value); + goto_vars.insert_or_assign(GotoKey{block, variable.index}, value); } const IR::Value& Def(IR::Block* block, IndirectBranchVariable) { - return indirect_branch_var[block]; + return block->SsaValue(SSA_INDIRECT_BRANCH); } void SetDef(IR::Block* block, IndirectBranchVariable, const IR::Value& value) { - indirect_branch_var.insert_or_assign(block, value); + block->SetSsaValue(SSA_INDIRECT_BRANCH, value); } const IR::Value& Def(IR::Block* block, ZeroFlagTag) { - return zero_flag[block]; + return block->SsaValue(SSA_ZERO_FLAG); } void SetDef(IR::Block* block, ZeroFlagTag, const IR::Value& value) { - zero_flag.insert_or_assign(block, value); + block->SetSsaValue(SSA_ZERO_FLAG, value); } const IR::Value& Def(IR::Block* block, SignFlagTag) { - return sign_flag[block]; + return block->SsaValue(SSA_SIGN_FLAG); } void SetDef(IR::Block* block, SignFlagTag, const IR::Value& value) { - sign_flag.insert_or_assign(block, value); + block->SetSsaValue(SSA_SIGN_FLAG, value); } const IR::Value& Def(IR::Block* block, CarryFlagTag) { - return carry_flag[block]; + return block->SsaValue(SSA_CARRY_FLAG); } void SetDef(IR::Block* block, CarryFlagTag, const IR::Value& value) { - carry_flag.insert_or_assign(block, value); + block->SetSsaValue(SSA_CARRY_FLAG, value); } const IR::Value& Def(IR::Block* block, OverflowFlagTag) { - return overflow_flag[block]; + return block->SsaValue(SSA_OVERFLOW_FLAG); } void SetDef(IR::Block* block, OverflowFlagTag, const IR::Value& value) { - overflow_flag.insert_or_assign(block, value); + block->SetSsaValue(SSA_OVERFLOW_FLAG, value); } - std::array preds; - // TODO: Requires stable iterators - std::unordered_map goto_vars; - ValueMap indirect_branch_var; - ValueMap zero_flag; - ValueMap sign_flag; - ValueMap carry_flag; - ValueMap overflow_flag; + Common::unordered_map goto_vars; + const IR::Value empty{}; }; IR::Opcode UndefOpcode(IR::Reg) noexcept { From 0634367a3ffa5b0369b8b8cf385b3fcb1a6b0cc4 Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:35:40 +0100 Subject: [PATCH 012/154] spirv: remember the ids of scalar constants Every immediate operand goes through EmitContext::Const, and Sirit's Constant deduplicates by building a std::vector of the instruction words and hashing it: ~10% of the benchmark's profile, half of it in the allocator. EmitContext now keeps the id of each u32 and f32 constant it asked Sirit for, keyed by bit pattern as Sirit does, and returns it for later uses. Sirit would have returned the same id, so the module does not change. Emitted SPIR-V is identical for the 11,883 modules of two Mario Kart World disk caches (eden-shader-bench --hashes). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 87734a7800a180f934a2acc9444b262d6b6ccb13) (cherry picked from commit 8b32fcb6298b3caf825df70dca26f4a6e947ed89) (cherry picked from commit 04877e62281fa930094e85ee19015b176d9b58ec) --- .../backend/spirv/spirv_emit_context.h | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.h b/src/shader_recompiler/backend/spirv/spirv_emit_context.h index 1f9b534aaf..442573b31a 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.h +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.h @@ -7,8 +7,10 @@ #pragma once #include +#include #include +#include "common/container/unordered_map.h" #include "common/container/unordered_set.h" #include "shader_recompiler/backend/bindings.h" @@ -174,7 +176,7 @@ class EmitContext final : public Sirit::Module { [[nodiscard]] Id BitOffset16(const IR::Value& offset); Id Const(u32 value) { - return Constant(U32[1], value); + return CachedConstant(u32_constants, U32[1], value, value); } Id Const(u32 element_1, u32 element_2) { @@ -208,7 +210,19 @@ class EmitContext final : public Sirit::Module { } Id Const(f32 value) { - return Constant(F32[1], value); + return CachedConstant(f32_constants, F32[1], std::bit_cast(value), value); + } + + /// Sirit deduplicates constants, but by hashing a freshly allocated copy of their words on + /// every call; immediates are emitted over and over, so remember the ids of scalar constants. + /// Keys are bit patterns, like Sirit's (0.0f and -0.0f stay distinct). + template + Id CachedConstant(::Common::unordered_map& cache, Id type, u32 bits, T value) { + const auto [it, is_new]{cache.try_emplace(bits)}; + if (is_new) { + it->second = Constant(type, value); + } + return it->second; } const Profile& profile; @@ -228,6 +242,9 @@ class EmitContext final : public Sirit::Module { VectorTypes F16; VectorTypes F64; + ::Common::unordered_map u32_constants; + ::Common::unordered_map f32_constants; + Id true_value{}; Id false_value{}; Id u32_zero_value{}; From ade6957593073266ff3888863e974eb9ce756c7d Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:55:34 +0100 Subject: [PATCH 013/154] shader_recompiler: find the visited block containing a label by lookup CFG::InspectVisitedBlocks scanned every block of the function for each label it analyzed, which made CFG construction quadratic in the number of blocks. The blocks are kept sorted by their first instruction and never overlap (virtual blocks are empty), so the only candidate is the last non-empty block starting at or before the label: find it from upper_bound. CFG time in the benchmark: 337 ms -> 181 ms. Emitted SPIR-V is identical for the 11,883 modules of two Mario Kart World disk caches (eden-shader-bench --hashes). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 11d26dde666f6c73d8243cd1a7dcf6f9d7a3a3f3) (cherry picked from commit 692780fab92c07e18d57af72adcddd725cdbfd59) (cherry picked from commit 6fad202af7eddbe8a66152251789ac82ba2f50af) --- .../frontend/maxwell/control_flow.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/shader_recompiler/frontend/maxwell/control_flow.cpp b/src/shader_recompiler/frontend/maxwell/control_flow.cpp index 9fb30df664..a67c02581a 100644 --- a/src/shader_recompiler/frontend/maxwell/control_flow.cpp +++ b/src/shader_recompiler/frontend/maxwell/control_flow.cpp @@ -256,12 +256,18 @@ void CFG::AnalyzeLabel(FunctionId function_id, Label& label) { bool CFG::InspectVisitedBlocks(FunctionId function_id, const Label& label) { const Location pc{label.address}; Function& function{functions[function_id]}; - const auto it{ - std::ranges::find_if(function.blocks, [pc](auto& block) { return block.Contains(pc); })}; - if (it == function.blocks.end()) { + // Blocks are sorted by their first instruction and do not overlap; empty blocks (virtual + // blocks) contain nothing. So only the last non-empty block starting at or before pc can + // contain it. A linear search over all blocks for every label was quadratic. + auto it{function.blocks.upper_bound(pc, Compare{})}; + while (it != function.blocks.begin() && std::prev(it)->begin == std::prev(it)->end) { + --it; + } + if (it == function.blocks.begin() || !std::prev(it)->Contains(pc)) { // Address has not been visited return false; } + --it; Block* const visited_block{&*it}; if (visited_block->begin == pc) { throw LogicError("Dangling block"); From a8541bc76176ac78b4b7e79b93bc82cda3d590b9 Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:57:38 +0100 Subject: [PATCH 014/154] shader_recompiler: create SSA phis of sealed blocks only when they are needed When a read reaches a sealed block with several predecessors and no definition, the pass created an operandless phi first, to break cycles, read every predecessor, then removed the phi again when it was trivial, which it almost always is (222,000 of 226,000 phis in the largest Mario Kart World fragment shader). Each one was allocated, linked in, filled, unlinked, reinserted as an Identity and later dropped. The phi now stays pending: the block is marked, the operands are kept on the side, and the phi is only created when a cycle reads it or when it merges two different values. Nothing is inserted at the front of the block in between, so it is created at the same place with the same operands; trivial pending phis leave nothing behind, as the removed phis' unused identities did not either. Emitted SPIR-V is identical for the 11,883 modules of two Mario Kart World disk caches (eden-shader-bench --hashes). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 9c0383a9c086b7832fa3510fcbea1c207b883e27) (cherry picked from commit 2b1eda0eb1e0f505db2307625c682ee16b4ef091) (cherry picked from commit 60e242cbb9e4b57e6854e6a75b82e93c1cc4f9a5) --- .../ir_opt/ssa_rewrite_pass.cpp | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp index b7d5cf5408..b8fabee46f 100644 --- a/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp +++ b/src/shader_recompiler/ir_opt/ssa_rewrite_pass.cpp @@ -169,9 +169,13 @@ struct ReadState { IR::Block* block{}; IR::Value result{}; + /// Phi of a sealed block with several predecessors, null while it is only pending IR::Inst* phi{}; IR::Block* const* pred_it{}; IR::Block* const* pred_end{}; + /// Operands of a pending phi, in Pass::pending_operands + size_t operands_begin{}; + size_t num_operands{}; Status pc{Status::Start}; }; @@ -190,11 +194,15 @@ class Pass { stack.clear(); stack.emplace_back(nullptr); stack.emplace_back(root_block); + const IR::Opcode undef_opcode{UndefOpcode(variable)}; const auto prepare_phi_operand{[&] { - if (stack.back().pred_it == stack.back().pred_end) { - IR::Inst* const phi{stack.back().phi}; - IR::Block* const block{stack.back().block}; - const IR::Value result{TryRemoveTrivialPhi(*phi, block, UndefOpcode(variable))}; + ReadState& state{stack.back()}; + if (state.pred_it == state.pred_end) { + IR::Block* const block{state.block}; + const IR::Value result{state.phi + ? TryRemoveTrivialPhi(*state.phi, block, undef_opcode) + : ResolvePendingPhi(state, undef_opcode)}; + pending_operands.resize(state.operands_begin); stack.pop_back(); stack.back().result = result; WriteVariable(variable, block, result); @@ -208,7 +216,12 @@ class Pass { IR::Block* const block{stack.back().block}; switch (stack.back().pc) { case Status::Start: { - if (const IR::Value& def = current_def.Def(block, variable); !def.IsEmpty()) { + if (IR::Value def{current_def.Def(block, variable)}; !def.IsEmpty()) { + if (def == pending_phi) { + // A cycle reached a pending phi: it needs to exist now + def = IR::Value{MaterializePhi(PendingFrame(block), undef_opcode)}; + WriteVariable(variable, block, def); + } stack.back().result = def; } else if (!block->IsSsaSealed()) { // Incomplete CFG @@ -224,13 +237,16 @@ class Pass { stack.emplace_back(imm_preds.front()); break; } else { - // Break potential cycles with operandless phi - IR::Inst* const phi{&*block->PrependNewInst(block->begin(), IR::Opcode::Phi)}; - phi->SetFlags(IR::TypeOf(UndefOpcode(variable))); - - WriteVariable(variable, block, IR::Value{phi}); - - stack.back().phi = phi; + // Break potential cycles with an operandless phi. Most of these phis turn out + // trivial, so it stays pending (pending_phi marks the block) and is only + // created when a cycle reads it or when it merges different values. Nothing + // is inserted at the front of this block until then, so it is created where + // it would have been created right away. + WriteVariable(variable, block, pending_phi); + + stack.back().phi = nullptr; + stack.back().operands_begin = pending_operands.size(); + stack.back().num_operands = 0; stack.back().pred_it = imm_preds.data(); stack.back().pred_end = imm_preds.data() + imm_preds.size(); prepare_phi_operand(); @@ -246,9 +262,14 @@ class Pass { break; } case Status::PushPhiArgument: { - IR::Inst* const phi{stack.back().phi}; - phi->AddPhiOperand(*stack.back().pred_it, stack.back().result); - ++stack.back().pred_it; + ReadState& state{stack.back()}; + if (state.phi) { + state.phi->AddPhiOperand(*state.pred_it, state.result); + } else { + pending_operands.emplace_back(*state.pred_it, state.result); + ++state.num_operands; + } + ++state.pred_it; } [[fallthrough]]; case Status::PreparePhiArgument: @@ -272,6 +293,51 @@ class Pass { } private: + ReadState& PendingFrame(IR::Block* block) { + for (auto it = read_stack.rbegin(); it != read_stack.rend(); ++it) { + if (it->block == block && it->pc == Status::PushPhiArgument && !it->phi) { + return *it; + } + } + throw LogicError("Pending phi without a frame"); + } + + /// Creates the phi of a pending frame with the operands it collected so far + IR::Inst* MaterializePhi(ReadState& state, IR::Opcode undef_opcode) { + IR::Block* const block{state.block}; + IR::Inst* const phi{&*block->PrependNewInst(block->begin(), IR::Opcode::Phi)}; + phi->SetFlags(IR::TypeOf(undef_opcode)); + for (size_t i = 0; i < state.num_operands; ++i) { + const auto& [pred, value]{pending_operands[state.operands_begin + i]}; + phi->AddPhiOperand(pred, value); + } + state.phi = phi; + return phi; + } + + /// TryRemoveTrivialPhi for a phi nothing has seen yet: no self-references, and nothing to + /// remove or reroute when it is trivial + IR::Value ResolvePendingPhi(ReadState& state, IR::Opcode undef_opcode) { + IR::Value same; + for (size_t i = 0; i < state.num_operands; ++i) { + const IR::Value& op{pending_operands[state.operands_begin + i].second}; + if (op.Resolve() == same.Resolve()) { + continue; + } + if (!same.IsEmpty()) { + return IR::Value{MaterializePhi(state, undef_opcode)}; + } + same = op; + } + if (same.IsEmpty()) { + // Unreachable, or the start block: same insertion point as in TryRemoveTrivialPhi + IR::Block* const block{state.block}; + const auto insert_point{std::ranges::find_if_not(block->Instructions(), IR::IsPhi)}; + same = IR::Value{&*block->PrependNewInst(insert_point, undef_opcode)}; + } + return same; + } + template IR::Value AddPhiOperands(Type variable, IR::Inst& phi, IR::Block* block) { for (IR::Block* const imm_pred : block->ImmPredecessors()) { @@ -321,6 +387,10 @@ class Pass { DefTable current_def; // ReadVariable never nests, so its walk stack can be shared std::vector read_stack; + std::vector> pending_operands; + // Definition of blocks whose phi is pending + IR::Inst pending_phi_inst{IR::Opcode::Void, 0}; + const IR::Value pending_phi{&pending_phi_inst}; }; void VisitInst(Pass& pass, IR::Block* block, IR::Inst& inst) { From c2e9219c4aca145c50e4932055cadcd149b1605d Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:38:49 +0100 Subject: [PATCH 015/154] shader_environment: let graphics environments detach from the 3D engine Translation reads live 3D engine state through the environment (constant buffer values, texture descriptors through the texture pool registers, the viewport transform flag, HLE replacements), which is why it had to run on the GPU thread. Detach() copies that state: the stage's enabled constant buffers whole (their values get folded into the shader and uniform memory is reused once the GPU is past the draw), the texture pool's device address (read later with unsynchronized reads, through the device memory manager, which outlives the GPU address space at shutdown), the viewport flag and the replacement table. It then drops the engine pointer. It refuses, leaving the environment as it was, when the pool is not one contiguous device range. ShaderCache::device_memory becomes protected for the pipeline cache. Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit adf315b3fc3ea34f602ff226e3d7bda8f9209b33) (cherry picked from commit 6d8f95f08e4dc214bdc686c0440776878dc6b5bb) (cherry picked from commit 855af11b1ae4496472d455d00e09f5fb7d52453c) --- src/video_core/shader_cache.h | 4 +- src/video_core/shader_environment.cpp | 114 ++++++++++++++++++++++---- src/video_core/shader_environment.h | 38 +++++++++ 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/video_core/shader_cache.h b/src/video_core/shader_cache.h index 7c370e217c..bed42421eb 100644 --- a/src/video_core/shader_cache.h +++ b/src/video_core/shader_cache.h @@ -105,6 +105,8 @@ class ShaderCache : public VideoCommon::ChannelSetupCaches shader_infos{}; bool last_shaders_valid = false; + Tegra::MaxwellDeviceMemoryManager& device_memory; + private: friend struct ShaderCacheTestAccess; @@ -160,8 +162,6 @@ class ShaderCache : public VideoCommon::ChannelSetupCachesstate.shader_stages[stage_index].const_buffers[cbuf_index]}; - ASSERT(cbuf.enabled); u32 value{}; - if (cbuf_offset < cbuf.size) { - value = gpu_memory->Read(cbuf.address + cbuf_offset); + if (snapshot) { + const auto& cbuf{snapshot->const_buffers[cbuf_index]}; + ASSERT(cbuf.enabled); + if (cbuf_offset < cbuf.size) { + std::memcpy(&value, cbuf.bytes.data() + cbuf_offset, sizeof(value)); + } + } else { + const auto& cbuf{maxwell3d->state.shader_stages[stage_index].const_buffers[cbuf_index]}; + ASSERT(cbuf.enabled); + if (cbuf_offset < cbuf.size) { + value = gpu_memory->Read(cbuf.address + cbuf_offset); + } } cbuf_values.emplace(MakeCbufKey(cbuf_index, cbuf_offset), value); return value; @@ -682,8 +690,9 @@ std::optional GraphicsEnvironment::GetReplaceConstBuffe return std::nullopt; } const u64 key = (static_cast(bank) << 32) | static_cast(offset); - auto it = maxwell3d->replace_table.find(key); - if (it == maxwell3d->replace_table.end()) { + const auto& replace_table{snapshot ? snapshot->replace_table : maxwell3d->replace_table}; + auto it = replace_table.find(key); + if (it == replace_table.end()) { return std::nullopt; } const auto converted_value = [](Tegra::Engines::Maxwell3D::HLEReplacementAttributeType name) { @@ -703,20 +712,14 @@ std::optional GraphicsEnvironment::GetReplaceConstBuffe } Shader::TextureType GraphicsEnvironment::ReadTextureType(u32 handle) { - const auto& regs{maxwell3d->regs}; - const bool via_header_index{regs.sampler_binding == Maxwell::SamplerBinding::ViaHeaderBinding}; - auto entry = - ReadTextureInfo(regs.tex_header.Address(), regs.tex_header.limit, via_header_index, handle); + const auto entry{ReadTextureDescriptor(handle)}; const Shader::TextureType result{ConvertTextureType(entry)}; texture_types.emplace(handle, result); return result; } Shader::TexturePixelFormat GraphicsEnvironment::ReadTexturePixelFormat(u32 handle) { - const auto& regs{maxwell3d->regs}; - const bool via_header_index{regs.sampler_binding == Maxwell::SamplerBinding::ViaHeaderBinding}; - auto entry = - ReadTextureInfo(regs.tex_header.Address(), regs.tex_header.limit, via_header_index, handle); + const auto entry{ReadTextureDescriptor(handle)}; const Shader::TexturePixelFormat result(ConvertTexturePixelFormat(entry)); texture_pixel_formats.emplace(handle, result); return result; @@ -728,11 +731,90 @@ bool GraphicsEnvironment::IsTexturePixelFormatInteger(u32 handle) { } u32 GraphicsEnvironment::ReadViewportTransformState() { - const auto& regs{maxwell3d->regs}; - viewport_transform_state = regs.viewport_scale_offset_enabled; + viewport_transform_state = snapshot ? snapshot->viewport_scale_offset_enabled + : maxwell3d->regs.viewport_scale_offset_enabled; return viewport_transform_state; } +Tegra::Texture::TICEntry GraphicsEnvironment::ReadTextureDescriptor(u32 handle) { + if (!snapshot) { + const auto& regs{maxwell3d->regs}; + const bool via_header_index{regs.sampler_binding == + Maxwell::SamplerBinding::ViaHeaderBinding}; + return ReadTextureInfo(regs.tex_header.Address(), regs.tex_header.limit, + via_header_index, handle); + } + const u32 index{Tegra::Texture::TexturePair(handle, snapshot->via_header_index).first}; + ASSERT(index <= snapshot->texture_pool_limit); + Tegra::Texture::TICEntry entry{}; + if (index <= snapshot->texture_pool_limit) { + // Only the pool's own range was checked to be contiguous: never read past it. + snapshot->device_memory->ReadBlockUnsafe(snapshot->texture_pool + index * sizeof(entry), + &entry, sizeof(entry)); + } + return entry; +} + +// Runs on the GPU thread, before the environment is handed to a translation worker. +// - Constant buffers are copied whole: translation folds some of their values into the shader +// (driver constants in bank 1, texture handles, indirect branch tables), and games reuse +// uniform memory once the GPU is past the draw, so a later read could see another draw's data. +// - The texture pool is not copied, only located: its entries are read on the worker through +// the device address, unsynchronized. An entry rewritten in the meantime gives the newer +// texture type, an accepted risk (pools hold long-lived descriptors). +// - The viewport transform flag and the HLE macro replacements are copied. +bool GraphicsEnvironment::Detach(Tegra::MaxwellDeviceMemoryManager& device_memory) { + // A texture handle indexes at most 2^20 descriptors; a larger limit is not a real pool and + // would make the contiguity check below walk gigabytes of page table. + constexpr u32 MaxTexturePoolLimit = (1U << 20) - 1; + const auto& regs{maxwell3d->regs}; + if (regs.tex_header.limit > MaxTexturePoolLimit) { + return false; + } + const GPUVAddr pool_address{regs.tex_header.Address()}; + const size_t pool_size{(size_t{regs.tex_header.limit} + 1) * sizeof(Tegra::Texture::TICEntry)}; + const std::optional pool_device_address{gpu_memory->GpuToCpuAddress(pool_address)}; + if (!pool_device_address || !gpu_memory->IsContinuousRange(pool_address, pool_size)) { + return false; + } + EngineSnapshot copy; + const auto& live_buffers{maxwell3d->state.shader_stages[stage_index].const_buffers}; + for (size_t index = 0; index < live_buffers.size(); ++index) { + const auto& live{live_buffers[index]}; + auto& copied{copy.const_buffers[index]}; + copied.enabled = live.enabled; + if (!live.enabled || live.size == 0) { + continue; + } + // The size register can exceed what a constant buffer holds; never copy more than that. + copied.size = (std::min)(live.size, static_cast(Maxwell::MaxConstBufferSize)); + copied.bytes.resize(copied.size + sizeof(u32)); + gpu_memory->ReadBlockUnsafe(live.address, copied.bytes.data(), copied.bytes.size()); + } + copy.device_memory = &device_memory; + copy.texture_pool = *pool_device_address; + copy.texture_pool_limit = regs.tex_header.limit; + copy.via_header_index = regs.sampler_binding == Maxwell::SamplerBinding::ViaHeaderBinding; + copy.viewport_scale_offset_enabled = regs.viewport_scale_offset_enabled; + if (has_hle_engine_state) { + copy.replace_table = maxwell3d->replace_table; + } + snapshot = std::move(copy); + maxwell3d = nullptr; + return true; +} + +size_t GraphicsEnvironment::SnapshotSizeBytes() const noexcept { + if (!snapshot) { + return 0; + } + size_t total{}; + for (const auto& cbuf : snapshot->const_buffers) { + total += cbuf.bytes.size(); + } + return total; +} + ComputeEnvironment::ComputeEnvironment(Tegra::Engines::KeplerCompute& kepler_compute_, Tegra::MemoryManager& gpu_memory_, GPUVAddr program_base_, u32 start_address_, diff --git a/src/video_core/shader_environment.h b/src/video_core/shader_environment.h index 916c3a2fc3..79d884224c 100644 --- a/src/video_core/shader_environment.h +++ b/src/video_core/shader_environment.h @@ -22,6 +22,7 @@ #include "common/unique_function.h" #include "shader_recompiler/environment.h" #include "video_core/engines/maxwell_3d.h" +#include "video_core/host1x/gpu_device_memory_manager.h" namespace Tegra { class Memorymanager; @@ -164,9 +165,46 @@ class GraphicsEnvironment final : public GenericEnvironment { std::optional GetReplaceConstBuffer(u32 bank, u32 offset) override; + /// Copies what translation reads from the 3D engine and stops using the engine, so the + /// program can be translated on a worker thread while the GPU thread moves on. Returns + /// false, leaving the environment unchanged, when the texture pool is not one contiguous + /// range of device memory. + [[nodiscard]] bool Detach(Tegra::MaxwellDeviceMemoryManager& device_memory); + + /// Bytes of constant buffer contents copied by Detach (0 if not detached). + [[nodiscard]] size_t SnapshotSizeBytes() const noexcept; + private: + /// The 3D engine state a detached environment reads instead of the engine. + struct EngineSnapshot { + struct ConstBuffer { + bool enabled{}; + u32 size{}; + /// Contents when the environment was detached, one word longer than `size` so a + /// read at any offset below `size` stays inside the copy. + std::vector bytes; + }; + std::array const_buffers; + + /// The texture pool is not copied (it can hold thousands of entries): its entries are + /// read later through its device address, which does not depend on the GPU memory + /// manager staying alive. + Tegra::MaxwellDeviceMemoryManager* device_memory{}; + DAddr texture_pool{}; + u32 texture_pool_limit{}; + bool via_header_index{}; + + u32 viewport_scale_offset_enabled{}; + ::Common::unordered_map + replace_table; + }; + + /// Texture descriptor (TIC entry) of a texture handle, from the engine or the snapshot. + Tegra::Texture::TICEntry ReadTextureDescriptor(u32 handle); + Tegra::Engines::Maxwell3D* maxwell3d{}; size_t stage_index{}; + std::optional snapshot; }; class ComputeEnvironment final : public GenericEnvironment { From 5720d5f6813079934cf151091cc9f193095b041f Mon Sep 17 00:00:00 2001 From: Franseven Date: Sat, 31 Oct 2026 00:56:51 +0100 Subject: [PATCH 016/154] nx2: copy buffers back for titles that read GPU-written data AsyncBufferDownloads is off for native tiling, which left Pokemon Pokopia's sky black or flickering: its guest side reads buffer data the CPU never touches, so the fence-time copy-back is the only path that produces it. Keep the default off and name the titles that need it. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit a5c06ea2099c8550342e1863e1e03ea99f5976af) (cherry picked from commit b267e8cefd8ab81855097d568cfbba4b4c0a1b44) --- .../service/nvdrv/devices/nvhost_ctrl_gpu.cpp | 1 + src/video_core/buffer_cache/buffer_cache_base.h | 7 +++++-- src/video_core/nx2.h | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp index 93d51cd538..5df2ce4227 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp @@ -346,6 +346,7 @@ NvResult nvhost_ctrl_gpu::GetCharacteristics3( nx2_caller = true; g_nx2_driver = true; Tegra::g_nx2_ampere = true; + Tegra::g_nx2_program_id = system.GetApplicationProcessProgramID(); ApplyNx2Characteristics(params.gc); params.gpu_characteristics_buf_size = Nx2CharacteristicsSize; LOG_WARNING(Service_NVDRV, "Switch 2 caller, reporting Ampere chip 0x17E"); diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index bfe68e4a2f..5ad051191c 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -35,6 +35,7 @@ #include "video_core/buffer_cache/buffer_base.h" #include "video_core/buffer_cache/virtual_range_cache.h" #include "video_core/control/channel_state_cache.h" +#include "video_core/nx2.h" #include "video_core/delayed_destruction_ring.h" #include "video_core/dirty_flags.h" #include "video_core/engines/maxwell_3d.h" @@ -570,7 +571,8 @@ class BufferCache : public VideoCommon::ChannelSetupCaches= 0) { return forced == 1; } - return gpu_memory == nullptr || !gpu_memory->UsesNativeTiling(); + return gpu_memory == nullptr || !gpu_memory->UsesNativeTiling() || + Tegra::Nx2NeedsBufferDownloads(); } /// Whether a GPU write to this range is copied back at the next fence. Ranges the guest CPU diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index 92714d52d2..8588101c15 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -20,6 +20,23 @@ namespace Tegra { // Ampere classes and the Turing+ macro ISA, which Eden's Maxwell engines don't implement. inline std::atomic g_nx2_ampere{false}; +// The running title, for the few places that must behave differently per game. +inline std::atomic g_nx2_program_id{0}; + +// Titles whose guest side reads GPU-written buffer data through a path the buffer cache does not +// see, so their buffers must be copied back to guest memory at every fence +// (BufferCache::AsyncBufferDownloads, EDEN_NX2_BUFFER_DOWNLOADS). 0400A3A01EE4C000 is Pokemon +// Pokopia: with the copy-back off its sky is black or flickers. Open work: find what reads it, +// then the list goes. +inline bool Nx2NeedsBufferDownloads() { + switch (g_nx2_program_id.load(std::memory_order_relaxed)) { + case 0x0400A3A01EE4C000ULL: + return true; + default: + return false; + } +} + // Device pages the GPU has written through the buffer cache (shader stores, copies), ever. Title 1 // runs command lists the GPU writes each frame (20-byte entries its culling fills in); read // without a flush they are stale and the draws behind them (trees) go missing. The bits never From ab71d701e4ea885199302bc112753306cf8aff73 Mon Sep 17 00:00:00 2001 From: xDarkWindx Date: Mon, 2 Nov 2026 20:44:55 +0000 Subject: [PATCH 017/154] vk_update_descriptor: grow the descriptor payload when a frame overflows it Each frame gets a 0x80000-entry slice of the descriptor payload, which the Vulkan worker reads until it runs the commands recorded with it. When a frame's descriptors overflow the slice, the GPU thread waits for the worker to drain (WaitWorker) and starts the slice over. Fortune's Weave writes over a million descriptors per frame (buffer- backed texture arrays of hundreds of records per draw), so it overflowed twice per frame, each time serializing the GPU thread with the worker. Right after that wait every recorded command has run and nothing points into the payload, so the slices now double there, up to four times their initial size (about 400 MB for the guest queue instead of about 100 MB; only games that overflow grow it). Overflows past the cap still wait as before. Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_011CXf4Za4HoE7Kvn1V7Y1G9 (cherry picked from commit bad5dc8207b062616cc003940b22e3828b41be45) (cherry picked from commit 7671c2cdf0957a5e8bf1e9d15083fb424f60c5d2) --- .../renderer_vulkan/vk_update_descriptor.cpp | 19 +++++++++++++++++-- .../renderer_vulkan/vk_update_descriptor.h | 5 ++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_update_descriptor.cpp b/src/video_core/renderer_vulkan/vk_update_descriptor.cpp index 29f3ee71a1..329e748f66 100644 --- a/src/video_core/renderer_vulkan/vk_update_descriptor.cpp +++ b/src/video_core/renderer_vulkan/vk_update_descriptor.cpp @@ -19,6 +19,7 @@ namespace Vulkan { UpdateDescriptorQueue::UpdateDescriptorQueue(const Device& device_, size_t frame_payload_size_, bool supports_descriptor_buffer_) : device{device_}, frame_payload_size{frame_payload_size_}, + max_frame_payload_size{frame_payload_size_ * MAX_PAYLOAD_GROWTH}, supports_descriptor_buffer{supports_descriptor_buffer_}, payload(std::make_unique_for_overwrite(frame_payload_size_ * FRAMES_IN_FLIGHT)) { @@ -45,9 +46,23 @@ void UpdateDescriptorQueue::Acquire(Scheduler& scheduler, size_t required_entrie reserve, frame_payload_size); const size_t used = static_cast(std::distance(payload_start, payload_cursor)); if (used + reserve >= frame_payload_size) { - LOG_WARNING(Render_Vulkan, "Payload overflow (used={}, reserve={}, capacity={})", - used, reserve, frame_payload_size); + // The worker reads this slice until it has run the commands recorded with it: wait for it. + // Every recorded command has run then, so nothing points into the payload and it may grow. + // Fortune's Weave writes over a million descriptors a frame (buffer-backed texture arrays of + // hundreds of records per draw) and overflowed twice per frame, each time stalling the GPU + // thread until the worker drained; a bigger slice stops the overflows. scheduler.WaitWorker(); + if (frame_payload_size < max_frame_payload_size) { + frame_payload_size *= 2; + payload = std::make_unique_for_overwrite(frame_payload_size * + FRAMES_IN_FLIGHT); + payload_start = payload.get() + frame_index * frame_payload_size; + LOG_INFO(Render_Vulkan, "Descriptor payload grown to {} entries per frame (used={}, reserve={})", + frame_payload_size, used, reserve); + } else { + LOG_WARNING(Render_Vulkan, "Payload overflow (used={}, reserve={}, capacity={})", + used, reserve, frame_payload_size); + } payload_cursor = payload_start; } upload_start = payload_cursor; diff --git a/src/video_core/renderer_vulkan/vk_update_descriptor.h b/src/video_core/renderer_vulkan/vk_update_descriptor.h index 4d393376b4..b108524879 100644 --- a/src/video_core/renderer_vulkan/vk_update_descriptor.h +++ b/src/video_core/renderer_vulkan/vk_update_descriptor.h @@ -42,6 +42,8 @@ class UpdateDescriptorQueue final { public: static constexpr size_t GUEST_FRAME_PAYLOAD_SIZE = 0x80000; + /// A frame's slice grows (doubling) up to this many times its initial size when it overflows. + static constexpr size_t MAX_PAYLOAD_GROWTH = 4; static constexpr size_t COMPUTE_FRAME_PAYLOAD_SIZE = 0x20000; explicit UpdateDescriptorQueue(const Device& device_, size_t frame_payload_size_, @@ -116,7 +118,8 @@ class UpdateDescriptorQueue final { private: const Device& device; - const size_t frame_payload_size; + size_t frame_payload_size; + const size_t max_frame_payload_size; const bool supports_descriptor_buffer; bool use_descriptor_buffer{false}; size_t frame_index{0}; From 7453242b4727876c5399813cda4dfdf5d0e1086b Mon Sep 17 00:00:00 2001 From: Wael3rd <29479851+Wael3rd@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:38:49 +0100 Subject: [PATCH 018/154] vk_pipeline_cache: translate native graphics pipelines on worker threads A new graphics pipeline was translated (Maxwell code -> IR -> SPIR-V) on the GPU thread before its draw, ~2-4 ms each, which is most of the first-lap stutter left in title 1 with asynchronous shaders on. For native programs whose draw may be skipped (same rule as for a pipeline whose driver compile is not done), the GPU thread now builds and detaches the environments, queues translation and driver compile on new translation workers (half the pipeline worker count, so compute compiles are not held up) and waits up to a budget (EDEN_NX2_ASYNC_TRANSLATION_WAIT_MS, default 3 ms, 0 = never) on the job's future. Ready in time: the draw uses it. Otherwise the draw is skipped and the pipeline adopted into graphics_cache by a later draw; the disk cache and driver cache flush are queued at adoption, on the GPU thread, as for pipelines built there. Compute pipelines and Switch 1 programs are unchanged. EDEN_NX2_ASYNC_TRANSLATION=0 turns the new path off; a log line every 10 s counts queued, ready-in-budget and adopted pipelines and their GPU-thread and worker time. On nx2: keeps upstream's driver pipeline cache check, draw element count and disk_pipelines record (also for adopted pipelines). Assisted-by: Claude Opus 5.5 (implementation and debugging) Claude-Session: https://claude.ai/code/session_01XYitykHUVF1YKAGcYZ8W8G (cherry picked from commit 99bbe1f5ecce86bbce02e4e4484f8c68174a474c) (cherry picked from commit 9a876a744e1dcd92b6d0bd884cfbf68e0633fe2c) (cherry picked from commit 11ff8fa53a29e14619187af4fb29cae6d5a28db2) --- .../renderer_vulkan/vk_pipeline_cache.cpp | 309 ++++++++++++++++-- .../renderer_vulkan/vk_pipeline_cache.h | 57 ++++ 2 files changed, 343 insertions(+), 23 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 18aa74b29e..5399ad6e17 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -337,6 +338,33 @@ size_t GetTotalPipelineWorkers() { #endif } +// EDEN_NX2_ASYNC_TRANSLATION=0: translate every graphics pipeline on the GPU thread, as before +// (for A/B tests). Otherwise, with asynchronous shaders on, native graphics pipelines are +// translated on worker threads. +bool IsAsyncTranslationAllowed() { + const char* const value = std::getenv("EDEN_NX2_ASYNC_TRANSLATION"); + return value == nullptr || value[0] != '0'; +} + +// EDEN_NX2_ASYNC_TRANSLATION_WAIT_MS=: how long the GPU thread waits for a +// pipeline it just queued before skipping the draw. Default 3; 0 never waits. +std::chrono::microseconds GetTranslationWaitBudget() { + constexpr double DefaultMilliseconds = 3.0; + const char* const value = std::getenv("EDEN_NX2_ASYNC_TRANSLATION_WAIT_MS"); + const double milliseconds = value != nullptr ? std::strtod(value, nullptr) : DefaultMilliseconds; + if (!(milliseconds > 0.0)) { + return std::chrono::microseconds{0}; + } + return std::chrono::microseconds{static_cast(milliseconds * 1000.0)}; +} + +// Translation threads also run the driver compile of what they translate. They get half of the +// pipeline workers' count, so they cannot hold up the driver compiles queued on those (compute +// pipelines wait for theirs). +size_t GetTotalTranslationWorkers() { + return std::max(1, GetTotalPipelineWorkers() / 2); +} + } // Anonymous namespace size_t ComputePipelineCacheKey::Hash() const noexcept { @@ -393,10 +421,24 @@ PipelineCache::PipelineCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, use_asynchronous_shaders{Settings::values.use_asynchronous_shaders.GetValue()}, use_vulkan_pipeline_cache{Settings::values.use_vulkan_driver_pipeline_cache.GetValue() && !DriverCachesPipelines(device_)}, + // Translation threads compile in parallel with the pipeline workers: not on drivers that + // break with parallel compiles. + use_async_translation{use_asynchronous_shaders && IsAsyncTranslationAllowed() && + !device.HasBrokenParallelShaderCompiling()}, + translation_wait_budget{GetTranslationWaitBudget()}, + num_translation_threads{use_async_translation ? GetTotalTranslationWorkers() : 0}, workers(device.HasBrokenParallelShaderCompiling() ? 1ULL : GetTotalPipelineWorkers(), "VkPipelineBuilder", {}, Common::ThreadPlacement::Background), serialization_thread(1, "VkPipelineSerialization", {}, - Common::ThreadPlacement::Background) { + Common::ThreadPlacement::Background), + translation_workers(num_translation_threads, "VkPipelineTranslator", {}, + Common::ThreadPlacement::Background) { + if (use_async_translation) { + LOG_INFO(Render_Vulkan, + "Native graphics pipelines are translated on {} worker threads, wait budget " + "{:.1f} ms", + num_translation_threads, translation_wait_budget.count() / 1000.0); + } const auto& float_control{device.FloatControlProperties()}; const VkDriverId driver_id{device.GetDriverID()}; const VkShaderStageFlags subgroup_stages{device.GetSubgroupSupportedStages()}; @@ -709,7 +751,9 @@ bool PipelineCache::IsIndirectResetShader() const { } GraphicsPipeline* PipelineCache::CurrentGraphicsPipeline() { - + if (num_translated.load(std::memory_order_acquire) != 0) { + AdoptTranslatedGraphicsPipelines(); + } if (!RefreshStages(graphics_key.unique_hashes)) { current_pipeline = nullptr; return nullptr; @@ -996,12 +1040,19 @@ void ReportNewPipelineKey(const GraphicsPipelineCacheKey& key) { } // Anonymous namespace GraphicsPipeline* PipelineCache::CurrentGraphicsPipelineSlowPath() { - const auto [pair, is_new]{graphics_cache.try_emplace(graphics_key)}; - auto& pipeline{pair->second}; - if (is_new) { + auto it{graphics_cache.find(graphics_key)}; + if (it == graphics_cache.end()) { + if (translating_graphics.contains(graphics_key)) { + // Its translation is still running on a worker thread. + return nullptr; + } ReportNewPipelineKey(graphics_key); - pipeline = CreateGraphicsPipeline(); + if (!BuildNewGraphicsPipeline()) { + return nullptr; + } + it = graphics_cache.find(graphics_key); } + auto& pipeline{it->second}; if (!pipeline) { return nullptr; } @@ -1077,19 +1128,33 @@ GraphicsPipeline* PipelineCache::BuiltPipeline(GraphicsPipeline* pipeline) const if (!use_asynchronous_shaders) { return pipeline; } + return CanSkipDraw() ? nullptr : pipeline; +} + +bool PipelineCache::CanSkipDraw() const noexcept { // If something is using depth, we can assume that games are not rendering anything which // will be used one time. if (maxwell3d->regs.zeta_enable) { - return nullptr; + return true; } // If games are using a small index count, we can assume these are full screen quads. // 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.draw_element_count <= 6) { - return pipeline; + return false; } - return nullptr; + return true; +} + +bool PipelineCache::BuildNewGraphicsPipeline() { + if (CanTranslateAsynchronously()) { + if (std::optional> done{QueueGraphicsTranslation()}) { + return WaitForGraphicsTranslation(*done); + } + } + graphics_cache.emplace(graphics_key, CreateGraphicsPipeline()); + return true; } std::unique_ptr PipelineCache::CreateGraphicsPipeline( @@ -1267,6 +1332,73 @@ void ReportRuntimePipeline(const char* kind, std::chrono::steady_clock::time_poi window = now; } } + +// Graphics pipelines translated on worker threads: what they still cost the GPU thread +// (environments and snapshots, waiting within the budget) and the workers, logged every 10 s. +// Used on the GPU thread only. +class TranslationReport { +public: + using Duration = std::chrono::steady_clock::duration; + + void Queued(Duration preparation, size_t snapshot_bytes) { + ++queued; + preparation_time += preparation; + copied_bytes += snapshot_bytes; + LogEvery10Seconds(); + } + + void NotDetached() { + ++not_detached; + LogEvery10Seconds(); + } + + void Waited(Duration wait, bool in_time) { + wait_time += wait; + if (in_time) { + ++ready_in_time; + } + LogEvery10Seconds(); + } + + void Adopted(Duration build) { + ++adopted; + build_time += build; + LogEvery10Seconds(); + } + +private: + void LogEvery10Seconds() { + const auto now = std::chrono::steady_clock::now(); + if (now - window < std::chrono::seconds{10}) { + return; + } + const auto ms = [](Duration d) { return std::chrono::duration(d).count(); }; + LOG_INFO(Render_Vulkan, + "Graphics pipelines translated on workers in the last 10 s: {} queued ({} KiB " + "of state copied), {} ready within the wait budget, {} adopted ({:.1f} ms on " + "workers), {} built on the GPU thread (texture pool not contiguous); GPU " + "thread: {:.1f} ms preparing, {:.1f} ms waiting", + queued, copied_bytes / 1024, ready_in_time, adopted, ms(build_time), + not_detached, ms(preparation_time), ms(wait_time)); + // Start a new window. + *this = TranslationReport{}; + } + + u32 queued{}; + u32 not_detached{}; + u32 ready_in_time{}; + u32 adopted{}; + size_t copied_bytes{}; + Duration preparation_time{}; + Duration wait_time{}; + Duration build_time{}; + std::chrono::steady_clock::time_point window{std::chrono::steady_clock::now()}; +}; + +TranslationReport& GetTranslationReport() { + static TranslationReport report; + return report; +} } // Anonymous namespace std::unique_ptr PipelineCache::CreateGraphicsPipeline() { @@ -1283,19 +1415,20 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline() { if (!pipeline || pipeline_cache_filename.empty()) { return pipeline; } - if (pipeline->IsMeshPipeline()) { - // Not stored: task and mesh programs are translated with the mesh registers, which the - // cache format does not carry. The few of them rebuild quickly from the driver cache. - QueueVulkanPipelineCacheFlush(); - return pipeline; - } - if (disk_pipelines.contains(graphics_key.Hash())) { - // In the disk cache already, only not built at boot. - QueueVulkanPipelineCacheFlush(); - return pipeline; + // Not stored: pipelines in the disk cache already (only not built at boot), and task and mesh + // ones, which are translated with the mesh registers the cache format does not carry (the few + // of them rebuild quickly from the driver cache). + if (!disk_pipelines.contains(graphics_key.Hash()) && !pipeline->IsMeshPipeline()) { + disk_pipelines.insert(graphics_key.Hash()); + SerializeGraphicsPipeline(graphics_key, std::move(environments.envs)); } - disk_pipelines.insert(graphics_key.Hash()); - serialization_thread.QueueWork([this, key = graphics_key, envs = std::move(environments.envs)] { + QueueVulkanPipelineCacheFlush(); + return pipeline; +} + +void PipelineCache::SerializeGraphicsPipeline(const GraphicsPipelineCacheKey& key, + GraphicsEnvironmentArray envs) { + serialization_thread.QueueWork([this, key, envs = std::move(envs)] { boost::container::static_vector env_ptrs; for (size_t index = 0; index < Maxwell::MaxShaderProgram; ++index) { @@ -1305,8 +1438,138 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline() { } SerializePipeline(key, env_ptrs, pipeline_cache_filename, CACHE_VERSION); }); - QueueVulkanPipelineCacheFlush(); - return pipeline; +} + +// Asynchronous translation of native graphics pipelines (NX2_SHADER_STUTTER.md). +// +// The GPU thread used to translate a new pipeline (Maxwell code to IR to SPIR-V) before its +// draw, ~2-4 ms each. Now it builds the environments, detaches them from the 3D engine (copies +// what translation reads, see GraphicsEnvironment::Detach) and queues the translation and the +// driver compile on translation_workers. It then waits up to translation_wait_budget for the +// result; if the pipeline is not ready by then the draw is skipped, like a draw whose driver +// compile is not done yet, and a later draw adopts the pipeline. +// +// Only native (Switch 2) programs take this path: their code was lowered when the shader cache +// met them, so translation never reads guest memory for instructions. Compute pipelines stay +// synchronous: a skipped dispatch would lose its outputs. +bool PipelineCache::CanTranslateAsynchronously() const { + if (!use_async_translation || !CanSkipDraw()) { + return false; + } + if (!maxwell3d->GetNativeMacroEngine().native_turing) { + return false; + } + for (size_t index = 0; index < Maxwell::MaxShaderProgram; ++index) { + if (graphics_key.unique_hashes[index] == 0) { + continue; + } + if (!shader_infos[index] || !shader_infos[index]->lowered_native) { + return false; + } + } + return true; +} + +std::optional> PipelineCache::QueueGraphicsTranslation() { + const auto begin = std::chrono::steady_clock::now(); + GraphicsEnvironments environments; + GetGraphicsEnvironments(environments, graphics_key.unique_hashes); + + size_t snapshot_bytes{}; + for (size_t index = 0; index < Maxwell::MaxShaderProgram; ++index) { + if (graphics_key.unique_hashes[index] == 0) { + continue; + } + if (!environments.envs[index].Detach(device_memory)) { + GetTranslationReport().NotDetached(); + return std::nullopt; + } + snapshot_bytes += environments.envs[index].SnapshotSizeBytes(); + } + + std::promise done; + std::future result{done.get_future()}; + translating_graphics.insert(graphics_key); + translation_workers.QueueWork([this, key = graphics_key, envs = std::move(environments.envs), + done = std::move(done)]() mutable { + TranslateGraphicsPipeline(key, std::move(envs), std::move(done)); + }); + GetTranslationReport().Queued(std::chrono::steady_clock::now() - begin, snapshot_bytes); + return result; +} + +bool PipelineCache::WaitForGraphicsTranslation(std::future& done) { + // Waiting only helps when the job starts right away: behind a backlog it would spend the + // whole budget without getting the pipeline. + if (translation_wait_budget.count() == 0 || + translating_graphics.size() > num_translation_threads) { + return false; + } + const auto begin = std::chrono::steady_clock::now(); + const bool in_time = done.wait_for(translation_wait_budget) == std::future_status::ready; + GetTranslationReport().Waited(std::chrono::steady_clock::now() - begin, in_time); + if (!in_time) { + return false; + } + AdoptTranslatedGraphicsPipelines(); + return graphics_cache.contains(graphics_key); +} + +void PipelineCache::TranslateGraphicsPipeline(const GraphicsPipelineCacheKey& key, + GraphicsEnvironmentArray envs, + std::promise done) { + const auto begin = std::chrono::steady_clock::now(); + ShaderPools pools; + boost::container::static_vector env_ptrs; + for (size_t index = 0; index < Maxwell::MaxShaderProgram; ++index) { + if (key.unique_hashes[index] != 0) { + env_ptrs.push_back(&envs[index]); + } + } + std::unique_ptr pipeline; + try { + // Not built in parallel: the driver compile runs here too, so the pipeline can be drawn + // with as soon as the GPU thread adopts it. + pipeline = CreateGraphicsPipeline(pools, key, MakeSpan(env_ptrs), nullptr, false); + } catch (const std::exception& exception) { + // Shader errors are handled inside; nothing else may end the worker thread. + LOG_ERROR(Render_Vulkan, "Graphics pipeline translation failed: {}", exception.what()); + } + { + std::scoped_lock lock{translated_mutex}; + translated_graphics.push_back({ + .key = key, + .pipeline = std::move(pipeline), + .envs = std::move(envs), + .build_time = std::chrono::steady_clock::now() - begin, + }); + num_translated.store(translated_graphics.size(), std::memory_order_release); + } + // Signalled after the lock is released: the GPU thread may be waiting to adopt this result. + done.set_value(); +} + +void PipelineCache::AdoptTranslatedGraphicsPipelines() { + std::vector translated; + { + std::scoped_lock lock{translated_mutex}; + translated.swap(translated_graphics); + num_translated.store(0, std::memory_order_relaxed); + } + for (TranslatedGraphicsPipeline& result : translated) { + translating_graphics.erase(result.key); + GetTranslationReport().Adopted(result.build_time); + // What CreateGraphicsPipeline() does after building on the GPU thread. + if (result.pipeline && !pipeline_cache_filename.empty()) { + if (!disk_pipelines.contains(result.key.Hash()) && + !result.pipeline->IsMeshPipeline()) { + disk_pipelines.insert(result.key.Hash()); + SerializeGraphicsPipeline(result.key, std::move(result.envs)); + } + QueueVulkanPipelineCacheFlush(); + } + graphics_cache.emplace(result.key, std::move(result.pipeline)); + } } std::unique_ptr PipelineCache::CreateComputePipeline( diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.h b/src/video_core/renderer_vulkan/vk_pipeline_cache.h index 3c0719683d..6d3e30f58f 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.h +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.h @@ -12,11 +12,15 @@ #include #include #include +#include #include #include #include +#include +#include #include #include "common/container/unordered_map.h" +#include "common/container/unordered_set.h" #include #include "common/common_types.h" @@ -91,6 +95,7 @@ class PipelineStatistics; class RenderPassCache; class Scheduler; +using VideoCommon::GraphicsEnvironment; using VideoCommon::ShaderInfo; struct ShaderPools { @@ -130,8 +135,47 @@ class PipelineCache : public VideoCommon::ShaderCache { [[nodiscard]] GraphicsPipeline* BuiltPipeline(GraphicsPipeline* pipeline) const noexcept; + /// Whether the current draw may be skipped while its pipeline is being built. + [[nodiscard]] bool CanSkipDraw() const noexcept; + + /// Builds the pipeline of graphics_key, which is not in graphics_cache. Returns false when it + /// was left translating on a worker thread (the draw is skipped), true when graphics_cache + /// now holds it (null if the build failed). + bool BuildNewGraphicsPipeline(); + std::unique_ptr CreateGraphicsPipeline(); + // Asynchronous translation of native graphics pipelines (NX2_SHADER_STUTTER.md). + using GraphicsEnvironmentArray = std::array; + + /// A graphics pipeline translated on a worker thread, waiting for the GPU thread. + struct TranslatedGraphicsPipeline { + GraphicsPipelineCacheKey key; + std::unique_ptr pipeline; ///< Null when the build failed. + GraphicsEnvironmentArray envs; ///< For the disk cache. + std::chrono::steady_clock::duration build_time{}; + }; + + [[nodiscard]] bool CanTranslateAsynchronously() const; + + /// Queues the translation of graphics_key's pipeline on a worker thread. Returns a future + /// that becomes ready when the result is waiting to be adopted, or nothing when the + /// environments could not be detached from the engine. + std::optional> QueueGraphicsTranslation(); + + /// Waits up to the wait budget for a queued translation. True if it was adopted in time. + bool WaitForGraphicsTranslation(std::future& done); + + /// Runs on a translation worker thread. + void TranslateGraphicsPipeline(const GraphicsPipelineCacheKey& key, + GraphicsEnvironmentArray envs, std::promise done); + + /// Moves the pipelines finished by the workers into graphics_cache (GPU thread). + void AdoptTranslatedGraphicsPipelines(); + + void SerializeGraphicsPipeline(const GraphicsPipelineCacheKey& key, + GraphicsEnvironmentArray envs); + std::unique_ptr CreateGraphicsPipeline( ShaderPools& pools, const GraphicsPipelineCacheKey& key, std::span envs, PipelineStatistics* statistics, @@ -165,6 +209,9 @@ class PipelineCache : public VideoCommon::ShaderCache { VideoCore::ShaderNotify& shader_notify; bool use_asynchronous_shaders{}; bool use_vulkan_pipeline_cache{}; + bool use_async_translation{}; + std::chrono::microseconds translation_wait_budget{}; + size_t num_translation_threads{}; GraphicsPipelineCacheKey graphics_key{}; GraphicsPipeline* current_pipeline{}; @@ -205,6 +252,16 @@ class PipelineCache : public VideoCommon::ShaderCache { Common::ThreadWorker workers; Common::ThreadWorker serialization_thread; DynamicFeatures dynamic_features; + + /// Keys whose pipeline is being translated on a worker (GPU thread only). + ::Common::unordered_set translating_graphics; + /// Finished translations, handed from the workers to the GPU thread. + std::mutex translated_mutex; + std::vector translated_graphics; + std::atomic num_translated{}; + /// Declared last so it is destroyed first: its threads finish the translation they are + /// running (and drop the queued ones) while everything they use is still alive. + Common::ThreadWorker translation_workers; }; } // namespace Vulkan From e41e57bc910153ca1f24987b7eda2c6deb7e9bab Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:39:58 -0400 Subject: [PATCH 019/154] build: define NOMINMAX for clang-cl The MSVC branch defines it, but clang-cl takes the other branch, so windows.h (pulled in by vulkan.h) defined min/max macros that broke std::min/std::max calls in the frame generation code. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 9c8638014e3f08c9c8d32b829d4090008fc86dfd) --- src/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c49e252ccb..b8926dcd31 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -153,6 +153,10 @@ else() $<$:-pipe> # Disable RTTI (C++ only) $<$:-fno-rtti>) + else() + # clang-cl: like the MSVC branch, or windows.h (included by vulkan.h) defines min/max + # macros that break std::min/std::max calls + add_compile_definitions(NOMINMAX) endif() if (ENABLE_WERROR) From 2dbbdb7c75d3429708a0699a31ad1f5e5236a275 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:09 -0400 Subject: [PATCH 020/154] android: boot loose ExeFS dumps, add a Load file option Switch 2 titles boot as a loose ExeFS (main, main.npdm, sdk, subsdk*, romfs/ next to it). On Android those are content URIs, which cannot be joined with '/': the VFS now finds children through the document provider's listing (one JNI call per directory, sizes included), returns a content directory's real name, and looks its parent up only when asked. The game list finds loose dumps (main + main.npdm, or in exefs/) in shallow scans too and names them after their folder. "Load file" boots a picked file; a loose main boots when a granted tree holds it, else the dump's folder is asked for. Also fixes getGame for names without an extension and the null std::string returned by fs_android before the callbacks are registered. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit e3adf313410524c2a814e30a414e7dd03b6b0a63) --- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 10 +- .../yuzu_emu/fragments/EmulationFragment.kt | 4 +- .../fragments/HomeSettingsFragment.kt | 8 ++ .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 83 ++++++++++++ .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 118 +++++++++++++++++ .../org/yuzu/yuzu_emu/utils/GameHelper.kt | 91 +++++++++++--- .../app/src/main/res/values/strings.xml | 8 ++ src/common/fs/fs_android.cpp | 45 ++++++- src/common/fs/fs_android.h | 10 ++ src/core/file_sys/vfs/vfs_real.cpp | 119 +++++++++++++++++- 10 files changed, 474 insertions(+), 22 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 77222884c6..c7956847cf 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -106,9 +106,17 @@ object NativeLibrary { if (DocumentsTree.isNativePath(path)) { YuzuApplication.documentsTree!!.getParentDirectory(path) } else { - path + FileUtil.getParentUri(path) ?: path } + /** + * Children of a content URI directory, four strings each: "d" or "f", size (-1 if unknown), + * display name and document URI. Native code finds files next to a loose ExeFS with it. + */ + @Keep + @JvmStatic + fun listChildren(path: String): Array = FileUtil.listChildren(path) + @Keep @JvmStatic fun getFilename(path: String): String = diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 9db255d70c..dd1e22850f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -239,7 +239,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { handleEmuReadyIntent(intent) return } else if (intentUri != null) { - intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri)) || + FileUtil.getFilename(intentUri) == "main" // a loose ExeFS + ) { GameHelper.getGame(requireActivity().intent.data!!, false) } else { null diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 2b4cfc5ca8..16db01eb4a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -258,6 +258,14 @@ class HomeSettingsFragment : Fragment() { } ) ) + add( + HomeSetting( + R.string.load_file, + R.string.load_file_description, + R.drawable.ic_folder_open, + { mainActivity.loadGameFile.launch(arrayOf("*/*")) } + ) + ) add( HomeSetting( R.string.verify_installed_content, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 1da275ab87..5f15548cab 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -33,6 +33,8 @@ import org.yuzu.yuzu_emu.dialogs.NetPlayDialog import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment +import org.yuzu.yuzu_emu.HomeNavigationDirections import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.GamesViewModel @@ -405,6 +407,87 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } + // "Load file": boots a picked file without adding it to the library. A loose ExeFS main also + // needs its folder (main.npdm, sdk, subsdk*, romfs/), which picking one file does not grant: + // it boots when a granted tree holds it, else the folder is asked for. + val loadGameFile = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + processLoadGameFile(result) + } + } + + val loadLooseExeFsFolder = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processLooseExeFsFolder(result) + } + } + + private fun processLoadGameFile(result: Uri) { + val filename = FileUtil.getFilename(result) + if (filename != "main" && filename != "main.npdm") { + try { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (e: SecurityException) { + // Not persistable: the grant still lasts while the app runs + } + bootGameUri { result } + return + } + + val treeUri = FileUtil.getTreeDocumentUri(result) + if (treeUri == null) { + MessageDialogFragment.newInstance( + this, + titleId = R.string.loose_exefs_folder_access, + descriptionId = R.string.loose_exefs_folder_access_description, + positiveAction = { loadLooseExeFsFolder.launch(null) } + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return + } + bootGameUri { + FileUtil.getParentUri(treeUri.toString())?.let { + GameHelper.findLooseExeFsMain(FileUtil.listFiles(it.toUri())) + } + } + } + + private fun processLooseExeFsFolder(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + bootGameUri { GameHelper.findLooseExeFsMain(FileUtil.listFiles(result)) } + } + + // Identifies the game off the main thread (a loose ExeFS indexes its whole RomFS through the + // document provider, which takes a while) and boots it. + private fun bootGameUri(findGameUri: () -> Uri?) { + ProgressDialogFragment.newInstance(this, R.string.loading_game) { _, _ -> + val gameUri = findGameUri() + ?: return@newInstance MessageDialogFragment.newInstance( + titleId = R.string.load_file_failed, + descriptionId = R.string.loose_exefs_not_found + ) + val game = GameHelper.getGame(gameUri, false) + ?: return@newInstance MessageDialogFragment.newInstance( + titleId = R.string.load_file_failed, + descriptionId = R.string.load_file_invalid + ) + withContext(Dispatchers.Main) { + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.navigate( + HomeNavigationDirections.actionGlobalEmulationActivity(game, true) + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + fun processGamesDir(result: Uri, calledFromGameFragment: Boolean = false) { contentResolver.takePersistableUriPermission( result, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index 1deba1aade..f42e74bbb0 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -8,6 +8,7 @@ package org.yuzu.yuzu_emu.utils import android.database.Cursor import android.net.Uri +import android.os.Build import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile import java.io.BufferedInputStream @@ -146,6 +147,123 @@ object FileUtil { return results.toTypedArray() } + /** + * Children of a directory in a granted tree, flattened for native code: four strings per + * child, "d" or "f", size in bytes (-1 if the provider does not report it), display name and + * document URI. + * @param path Native content uri path of a tree or a directory inside one + */ + fun listChildren(path: String): Array { + val uri = path.toUri() + if (!DocumentsContract.isTreeUri(uri)) { + return emptyArray() + } + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE + ) + var c: Cursor? = null + val results = mutableListOf() + try { + val docId = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = context.contentResolver.query(childrenUri, columns, null, null, null) + while (c!!.moveToNext()) { + val isDirectory = c.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR + results.add(if (isDirectory) "d" else "f") + results.add(if (c.isNull(3)) "-1" else c.getLong(3).toString()) + results.add(c.getString(1)) + results.add( + DocumentsContract.buildDocumentUriUsingTree(uri, c.getString(0)).toString() + ) + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list children of $path: " + e.message) + } finally { + closeQuietly(c) + } + return results.toTypedArray() + } + + /** + * Parent directory of a document inside a granted tree, or null at the tree's root and for + * documents outside a tree. + * @param path Native content uri path + */ + fun getParentUri(path: String): String? { + val uri = path.toUri() + if (!DocumentsContract.isTreeUri(uri) || isRootTreeUri(uri)) { + return null + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + // The tree root's ID first, the document's own ID last. + val documentIds = + DocumentsContract.findDocumentPath(context.contentResolver, uri)?.path + if (documentIds != null && documentIds.size >= 2) { + return DocumentsContract.buildDocumentUriUsingTree( + uri, + documentIds[documentIds.size - 2] + ).toString() + } + } catch (e: Exception) { + Log.warning("[FileUtil]: findDocumentPath failed for $path: " + e.message) + } + } + + // Providers that do not implement findDocumentPath: path-like IDs (external storage) + val treeId = DocumentsContract.getTreeDocumentId(uri) + val docId = DocumentsContract.getDocumentId(uri) + if (docId != treeId && docId.startsWith(treeId) && docId.contains('/')) { + val parentId = docId.substringBeforeLast('/') + if (parentId.length >= treeId.length) { + return DocumentsContract.buildDocumentUriUsingTree(uri, parentId).toString() + } + } + return null + } + + /** + * The same document addressed through a tree the app holds a persisted grant for, so its + * neighbours can be read too (a loose ExeFS needs main.npdm, sdk, subsdk* and romfs/ next + * to main). Null when no granted tree contains it. + * @param documentUri Document picked with ACTION_OPEN_DOCUMENT + */ + fun getTreeDocumentUri(documentUri: Uri): Uri? { + if (DocumentsContract.isTreeUri(documentUri)) { + return documentUri + } + val docId = try { + DocumentsContract.getDocumentId(documentUri) + } catch (e: Exception) { + return null + } + for (permission in context.contentResolver.persistedUriPermissions) { + val treeUri = permission.uri + if (!permission.isReadPermission || treeUri.authority != documentUri.authority || + !DocumentsContract.isTreeUri(treeUri) + ) { + continue + } + val treeId = try { + DocumentsContract.getTreeDocumentId(treeUri) + } catch (e: Exception) { + continue + } + val prefix = if (treeId.endsWith('/') || treeId.endsWith(':')) treeId else "$treeId/" + if (docId == treeId || docId.startsWith(prefix)) { + return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId) + } + } + return null + } + /** * Check whether given path exists. * @param path Native content uri path diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index bb2337a310..819598f03e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -62,13 +62,19 @@ object GameHelper { if (isValid) { val scanDepth = if (gameDir.deepScan) 3 else 1 - addGamesRecursive( - games, - gamesByProgramId, - FileUtil.listFiles(gameDirUri), - scanDepth, - mountedContainerUris - ) + val gameDirFiles = FileUtil.listFiles(gameDirUri) + val looseMain = findLooseExeFsMain(gameDirFiles) + if (looseMain != null) { + addLooseExeFsGame(games, gamesByProgramId, looseMain) + } else { + addGamesRecursive( + games, + gamesByProgramId, + gameDirFiles, + scanDepth, + mountedContainerUris + ) + } } else { badDirs.add(index) } @@ -149,13 +155,21 @@ object GameHelper { files.forEach { if (it.isDirectory) { - addGamesRecursive( - games, - gamesByProgramId, - FileUtil.listFiles(it.uri), - depth - 1, - mountedContainerUris - ) + val children = FileUtil.listFiles(it.uri) + // A loose ExeFS dump (how Switch 2 titles boot) is a game, not a folder to scan. + // Checked at every depth, shallow scans too: main is one or two levels down. + val looseMain = findLooseExeFsMain(children) + if (looseMain != null) { + addLooseExeFsGame(games, gamesByProgramId, looseMain) + } else { + addGamesRecursive( + games, + gamesByProgramId, + children, + depth - 1, + mountedContainerUris + ) + } } else { val extension = FileUtil.getExtension(it.uri).lowercase() val filePath = it.uri.toString() @@ -190,6 +204,48 @@ object GameHelper { } } + /** + * main of a loose ExeFS dump: main and main.npdm side by side in [files], or in their + * exefs/ subdirectory. Null for anything else. + */ + fun findLooseExeFsMain(files: Array): Uri? { + fun mainOf(entries: Array): Uri? { + val main = entries.firstOrNull { !it.isDirectory && it.filename == "main" } + ?: return null + val hasNpdm = entries.any { !it.isDirectory && it.filename == "main.npdm" } + return if (hasNpdm) main.uri else null + } + + mainOf(files)?.let { return it } + val exefs = files.firstOrNull { + it.isDirectory && it.filename.equals("exefs", ignoreCase = true) + } ?: return null + return mainOf(FileUtil.listFiles(exefs.uri)) + } + + private fun addLooseExeFsGame( + games: MutableList, + gamesByProgramId: MutableMap, + mainUri: Uri + ) { + val game = getGame(mainUri, true, false) ?: return + games.add(game) + if (game.programId != "0") { + gamesByProgramId[game.programId] = game + } + } + + // Name of a loose ExeFS dump's folder: the one holding exefs/, else the one holding main. + private fun looseExeFsDumpName(mainPath: String): String? { + val dir = FileUtil.getParentUri(mainPath) ?: return null + val dirName = FileUtil.getFilename(dir.toUri()) + if (!dirName.equals("exefs", ignoreCase = true)) { + return dirName + } + val titleDir = FileUtil.getParentUri(dir) ?: return dirName + return FileUtil.getFilename(titleDir.toUri()) + } + private fun mountExternalContentDirectories(mountedContainerUris: MutableSet) { val uniqueExternalContentDirs = linkedSetOf() NativeConfig.getExternalContentDirs().forEach { externalDir -> @@ -272,15 +328,18 @@ object GameHelper { var name = GameMetadata.getTitle(filePath) - // If the game's title field is empty, use the filename. + // If the game's title field is empty, use the filename (for a loose ExeFS, its folder's). if (name.isEmpty()) { name = FileUtil.getFilename(uri) + if (name == "main") { + name = looseExeFsDumpName(filePath) ?: name + } } var programId = GameMetadata.getProgramId(filePath) // If the game's ID field is empty, use the filename without extension. if (programId.isEmpty()) { - programId = name.substring(0, name.lastIndexOf(".")) + programId = name.substringBeforeLast(".") } val newGame = Game( diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 6304ef256b..3647efa5da 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -247,6 +247,14 @@ No files were found or no game directory has been selected yet. Manage game folders Allows Eden to populate the games list + Load file + Boot a game file directly, including a packed Switch 2 dump (.nx2) or its main file + Loading game + Could not load the file + This file is not a game Eden can boot. + No Switch 2 dump found here: the folder needs main and main.npdm, directly or in an exefs folder. + Folder access needed + A Switch 2 dump boots from its folder: main needs main.npdm, the sdk files and romfs next to it, and picking a single file only grants access to that file. Select the dump\'s folder (the one holding exefs and romfs) next. Skip selecting games folder? Games won\'t be displayed in the Games list if a folder isn\'t selected. https://yuzu-mirror.github.io/help/quickstart/#dumping-games diff --git a/src/common/fs/fs_android.cpp b/src/common/fs/fs_android.cpp index 9a80532227..480b23d4b4 100644 --- a/src/common/fs/fs_android.cpp +++ b/src/common/fs/fs_android.cpp @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include + #include "common/android/android_common.h" #include "common/android/id_cache.h" #include "common/assert.h" @@ -22,6 +24,8 @@ void RegisterCallbacks(JNIEnv* env, jclass clazz) { s_file_exists = env->GetStaticMethodID(native_library, "exists", "(Ljava/lang/String;)Z"); s_open_content_uri = env->GetStaticMethodID(native_library, "openContentUri", "(Ljava/lang/String;Ljava/lang/String;)I"); + s_list_children = env->GetStaticMethodID(native_library, "listChildren", + "(Ljava/lang/String;)[Ljava/lang/String;"); } void UnRegisterCallbacks() { @@ -33,6 +37,7 @@ void UnRegisterCallbacks() { s_file_exists = nullptr; s_open_content_uri = nullptr; + s_list_children = nullptr; } bool IsContentUri(const std::string& path) { @@ -95,7 +100,7 @@ bool Exists(const std::string& filepath) { std::string GetParentDirectory(const std::string& filepath) { if (s_get_parent_directory == nullptr) { - return 0; + return {}; } auto env = Common::Android::GetEnvForThread(); jstring j_return = static_cast(env->CallStaticObjectMethod( @@ -108,7 +113,7 @@ std::string GetParentDirectory(const std::string& filepath) { std::string GetFilename(const std::string& filepath) { if (s_get_filename == nullptr) { - return 0; + return {}; } auto env = Common::Android::GetEnvForThread(); jstring j_return = static_cast(env->CallStaticObjectMethod( @@ -119,4 +124,40 @@ std::string GetFilename(const std::string& filepath) { return Common::Android::GetJString(env, j_return); } +std::vector ListChildren(const std::string& dir_path) { + if (s_list_children == nullptr) { + return {}; + } + auto env = Common::Android::GetEnvForThread(); + jstring j_path = Common::Android::ToJString(env, dir_path); + auto j_entries = static_cast( + env->CallStaticObjectMethod(native_library, s_list_children, j_path)); + env->DeleteLocalRef(j_path); + if (!j_entries) { + return {}; + } + + // Four strings per child: "d" or "f", size, display name, document URI. A big directory + // makes thousands of local references, so each one is dropped once read. + const auto element = [&](jsize index) { + auto j_value = static_cast(env->GetObjectArrayElement(j_entries, index)); + std::string value = j_value ? Common::Android::GetJString(env, j_value) : std::string{}; + env->DeleteLocalRef(j_value); + return value; + }; + const jsize count = env->GetArrayLength(j_entries); + std::vector entries; + entries.reserve(static_cast(count / 4)); + for (jsize i = 0; i + 3 < count; i += 4) { + ContentEntry entry; + entry.is_directory = element(i) == "d"; + entry.size = std::strtoll(element(i + 1).c_str(), nullptr, 10); + entry.name = element(i + 2); + entry.uri = element(i + 3); + entries.push_back(std::move(entry)); + } + env->DeleteLocalRef(j_entries); + return entries; +} + } // namespace Common::FS::Android diff --git a/src/common/fs/fs_android.h b/src/common/fs/fs_android.h index b33f4beb8e..889256a20f 100644 --- a/src/common/fs/fs_android.h +++ b/src/common/fs/fs_android.h @@ -18,6 +18,15 @@ static jmethodID s_get_size; static jmethodID s_is_directory; static jmethodID s_file_exists; static jmethodID s_open_content_uri; +static jmethodID s_list_children; + +// A child of a content URI directory, as listed by the document provider. +struct ContentEntry { + std::string name; + std::string uri; + bool is_directory; + std::int64_t size; // -1 when the provider does not report it +}; enum class OpenMode { Read, @@ -42,5 +51,6 @@ bool IsDirectory(const std::string& filepath); bool Exists(const std::string& filepath); std::string GetParentDirectory(const std::string& filepath); std::string GetFilename(const std::string& filepath); +std::vector ListChildren(const std::string& dir_path); } // namespace Common::FS::Android diff --git a/src/core/file_sys/vfs/vfs_real.cpp b/src/core/file_sys/vfs/vfs_real.cpp index 7cc5315a70..2683e3c17f 100644 --- a/src/core/file_sys/vfs/vfs_real.cpp +++ b/src/core/file_sys/vfs/vfs_real.cpp @@ -68,6 +68,53 @@ bool IsWithinRoot(std::string_view root, std::string_view full_path) { return full_path.size() == root.size() || full_path[root.size()] == '/' || full_path[root.size()] == '\\'; } +bool IsContentPath(const std::string& path) { +#ifdef __ANDROID__ + return FS::Android::IsContentUri(path); +#else + return false; +#endif +} + +#ifdef __ANDROID__ +// A child of a content URI directory. Document URIs cannot be joined with '/', so children are +// found by name in the provider's listing, one path component at a time. +struct ContentChild { + FS::Android::ContentEntry entry; +}; + +std::optional FindContentChild(const std::string& dir, + std::string_view relative_path) { + std::optional found; + std::string current = dir; + size_t start = 0; + while (start <= relative_path.size()) { + const size_t end = (std::min)(relative_path.find_first_of("/\\", start), relative_path.size()); + const std::string_view component = relative_path.substr(start, end - start); + start = end + 1; + if (component.empty() || component == ".") { + continue; + } + if (found && !found->entry.is_directory) { + return std::nullopt; + } + const auto children = FS::Android::ListChildren(current); + const auto it = std::find_if(children.begin(), children.end(), + [&](const auto& child) { return child.name == component; }); + if (it == children.end()) { + return std::nullopt; + } + found = ContentChild{*it}; + current = it->uri; + } + return found; +} + +std::optional ContentEntrySize(const FS::Android::ContentEntry& entry) { + return entry.size >= 0 ? std::optional{static_cast(entry.size)} : std::nullopt; +} +#endif + constexpr FS::FileAccessMode ModeFlagsToFileAccessMode(OpenMode mode) { switch (mode) { case OpenMode::Read: @@ -395,6 +442,18 @@ std::vector RealVfsDirectory::IterateEntries( std::vector out; +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + for (const auto& entry : FS::Android::ListChildren(path)) { + if (!entry.is_directory) { + out.emplace_back( + base.OpenFileFromEntry(entry.uri, ContentEntrySize(entry), perms)); + } + } + return out; + } +#endif + const FS::DirEntryCallable callback = [this, &out](const std::filesystem::directory_entry& entry) { const auto full_path_string = FS::PathToUTF8String(entry.path()); @@ -417,6 +476,17 @@ std::vector RealVfsDirectory::IterateEntries out; +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + for (const auto& entry : FS::Android::ListChildren(path)) { + if (entry.is_directory) { + out.emplace_back(base.OpenDirectory(entry.uri, perms)); + } + } + return out; + } +#endif + const FS::DirEntryCallable callback = [this, &out](const std::filesystem::directory_entry& entry) { const auto full_path_string = FS::PathToUTF8String(entry.path()); @@ -433,9 +503,11 @@ std::vector RealVfsDirectory::IterateEntriesentry.is_directory) { + return nullptr; + } + return base.OpenFileFromEntry(child->entry.uri, ContentEntrySize(child->entry), perms); + } +#endif const auto full_path = FS::SanitizePath(path + '/' + std::string(relative_path)); if (!FS::Exists(full_path) || FS::IsDir(full_path) || !IsWithinRoot(FS::SanitizePath(path), full_path)) { @@ -452,6 +533,15 @@ VirtualFile RealVfsDirectory::GetFileRelative(std::string_view relative_path) co } VirtualDir RealVfsDirectory::GetDirectoryRelative(std::string_view relative_path) const { +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + const auto child = FindContentChild(path, relative_path); + if (!child || !child->entry.is_directory) { + return nullptr; + } + return base.OpenDirectory(child->entry.uri, perms); + } +#endif const auto full_path = FS::SanitizePath(path + '/' + std::string(relative_path)); if (!FS::Exists(full_path) || !FS::IsDir(full_path) || !IsWithinRoot(FS::SanitizePath(path), full_path)) { @@ -526,10 +616,25 @@ bool RealVfsDirectory::IsReadable() const { } std::string RealVfsDirectory::GetName() const { +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + return FS::Android::GetFilename(path); + } +#endif return path_components.empty() ? "" : std::string(path_components.back()); } VirtualDir RealVfsDirectory::GetParentDirectory() const { +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + // The provider answers with the same URI at the root of the granted tree. + const auto parent = FS::GetParentPath(path); + if (parent.empty() || parent == path) { + return nullptr; + } + return base.OpenDirectory(parent, perms); + } +#endif if (path_components.size() <= 1) { return nullptr; } @@ -575,6 +680,16 @@ std::map> RealVfsDirectory::GetEntries() std::map> out; +#ifdef __ANDROID__ + if (FS::Android::IsContentUri(path)) { + for (const auto& entry : FS::Android::ListChildren(path)) { + out.insert_or_assign(entry.name, entry.is_directory ? VfsEntryType::Directory + : VfsEntryType::File); + } + return out; + } +#endif + const FS::DirEntryCallable callback = [&out](const std::filesystem::directory_entry& entry) { const auto filename = FS::PathToUTF8String(entry.path().filename()); out.insert_or_assign(filename, From a231ad791702f6620df7bf7e8261c150716054cb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:34 -0400 Subject: [PATCH 021/154] hid: ignore motion and button updates in NPad::ControllerUpdate The callback is registered by NPad's constructor, before the applet resource exists, and dereferenced it on every update. A phone's gyro sends motion from the first frame, which crashed Switch 2 boots on Android; the function only acts on connection and battery changes. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit a7c090cbe280db312316a718765e932a9c0b3a20) --- src/hid_core/resources/npad/npad.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/hid_core/resources/npad/npad.cpp b/src/hid_core/resources/npad/npad.cpp index 2b04e2c670..19f4ed09f0 100644 --- a/src/hid_core/resources/npad/npad.cpp +++ b/src/hid_core/resources/npad/npad.cpp @@ -150,6 +150,18 @@ void NPad::ControllerUpdate(Kernel::KernelCore& kernel, Core::HID::ControllerTri return; } + // Only connection and battery changes are handled here. Motion and button updates arrive many + // times a second, a phone's gyro from the first frame: before the applet resource is set, + // which this callback (registered by the constructor) used to dereference. + if (type != Core::HID::ControllerTriggerType::Connected && + type != Core::HID::ControllerTriggerType::Disconnected && + type != Core::HID::ControllerTriggerType::Battery) { + return; + } + if (applet_resource_holder.applet_resource == nullptr) { + return; + } + for (std::size_t aruid_index = 0; aruid_index < AruidIndexMax; aruid_index++) { if (controller_idx >= controller_data[aruid_index].size()) { return; From dde935521e03a2885506de69bd90f3edd04018ad Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:35 -0400 Subject: [PATCH 022/154] texture_cache: use the null image for textures the host cannot create Mario Kart World's 8192x8192x68 BC6H virtual texture is 6 GB; the Adreno driver refuses allocations over 4 GB and has no real sparse images, so even the unbacked fallback fails. The exception hit SlotVector::insert's noexcept and terminated. insert now frees the slot and rethrows; JoinImages returns no image, which the callers (image views, render targets, blits) treat as unmapped, and the null image is never prepared for upload. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit c79b93ba11650d2e28ca7bc681847631a1e9637a) --- src/common/slot_vector.h | 11 +++++-- src/video_core/texture_cache/texture_cache.h | 32 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/common/slot_vector.h b/src/common/slot_vector.h index b94168aa51..e9eb757427 100644 --- a/src/common/slot_vector.h +++ b/src/common/slot_vector.h @@ -124,9 +124,16 @@ class SlotVector { } template - [[nodiscard]] SlotId insert(Args&&... args) noexcept { + [[nodiscard]] SlotId insert(Args&&... args) { const u32 index = FreeValueIndex(); - new (&values[index].object) T(std::forward(args)...); + try { + new (&values[index].object) T(std::forward(args)...); + } catch (...) { + // A constructor may throw (a texture the GPU cannot allocate): keep the slot free and + // let the caller handle it, instead of terminating at a noexcept boundary. + free_list.push_back(index); + throw; + } SetStorageBit(index); return SlotId{index}; diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index e4e8927bcf..c54a6a1d8f 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -2468,6 +2468,9 @@ ImageId TextureCache

::InsertImage(const ImageInfo& info, GPUVAddr gpu_addr, static_cast(info.format), info.block.width, info.block.height, info.block.depth); } const ImageId image_id = JoinImages(storage_info, gpu_addr, *cpu_addr); + if (!image_id) { + return ImageId{}; // Could not be created (see JoinImages) + } const Image& image = slot_images[image_id]; // Using "image.gpu_addr" instead of "gpu_addr" is important because it might be different const auto [it, is_new] = image_allocs_table.try_emplace(image.gpu_addr); @@ -2589,7 +2592,21 @@ ImageId TextureCache

::JoinImages(const ImageInfo& info, GPUVAddr gpu_addr, DA } } } - const ImageId new_image_id = slot_images.insert(runtime, new_info, gpu_addr, cpu_addr); + ImageId new_image_id; + try { + new_image_id = slot_images.insert(runtime, new_info, gpu_addr, cpu_addr); + } catch (const std::exception& e) { + // The host cannot hold it at all: an 8192x8192x68 BC6H virtual texture is 6 GB on a + // driver with a 4 GB allocation limit and no real sparse images (Adreno). Sample it as + // the null image rather than aborting; nothing has been joined into it yet. + static u32 logged{}; + if (logged++ < 16) { + LOG_ERROR(HW_GPU, "Image {}x{}x{} ({} levels) at {:#x} could not be created ({}); using the null image", + new_info.size.width, new_info.size.height, new_info.resources.layers, + new_info.resources.levels, gpu_addr, e.what()); + } + return ImageId{}; + } Image& new_image = slot_images[new_image_id]; new_image.allocation_tick = frame_tick; @@ -2776,6 +2793,9 @@ std::optional::BlitImages> TextureCache

::GetBlitImag dst_id = InsertImage(dst_info, dst_addr, RelaxedOptions{}); } } while (has_deleted_images); + if (!src_id || !dst_id) { + return std::nullopt; // An image that could not be created (see JoinImages) + } const ImageBase& src_image = slot_images[src_id]; const ImageBase& dst_image = slot_images[dst_id]; const bool native_bgr = runtime.HasNativeBgr(); @@ -2791,6 +2811,9 @@ std::optional::BlitImages> TextureCache

::GetBlitImag src_id = FindOrInsertImage(src_info, src_addr, RelaxedOptions{}); dst_id = FindOrInsertImage(dst_info, dst_addr, RelaxedOptions{}); } while (has_deleted_images); + if (!src_id || !dst_id) { + return std::nullopt; + } } return {BlitImages{ .dst_id = dst_id, @@ -3516,6 +3539,9 @@ void TextureCache

::SynchronizeAliases(ImageId image_id) { template void TextureCache

::PrepareImage(ImageId image_id, bool is_modification, bool invalidate) { + if (image_id == NULL_IMAGE_ID) { + return; // See PrepareImageView + } Image& image = slot_images[image_id]; if (invalidate) { image.flags &= ~(ImageFlagBits::CpuModified | ImageFlagBits::GpuModified); @@ -3570,7 +3596,9 @@ void TextureCache

::PrepareImage(ImageId image_id, bool is_modification, bool template void TextureCache

::PrepareImageView(ImageViewId image_view_id, bool is_modification, bool invalidate) { - if (!image_view_id) { + // The null view stands for textures that are unmapped or could not be created: it has no + // contents to upload (its image's format is invalid). + if (!image_view_id || image_view_id == NULL_IMAGE_VIEW_ID) { return; } const ImageViewBase& image_view = slot_image_views[image_view_id]; From 9c2f4dbc6ce66d6b9907ca831d941488716c1ede Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:35 -0400 Subject: [PATCH 023/154] gpu_split: recover from a lost back-thread wake-up The front spun forever in Drain on a semaphore while the back thread stayed parked (Mario Kart World on Android, GPU idle). libc++ parks atomic waiters in a shared table where notify_one can wake an unrelated waiter: notify all, re-wake a parked back thread from the front's long waits, and log the back thread's state if a drain lasts over 5 s. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 72036117437069b9efcab44beb758938e5702add) --- src/video_core/gpu_split.cpp | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 562580a29c..bbc67898c3 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include +#include #include #include @@ -129,7 +130,7 @@ void GpuSplit::Stop() { } back_thread.request_stop(); wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_one(); + wake.notify_all(); back_thread.join(); active = false; // The split is static and outlives the emulation session. The next session's back thread @@ -179,6 +180,11 @@ void GpuSplit::WaitForSpace(size_t words) { Pause(); } else { std::this_thread::yield(); + // As in Drain: re-wake a back thread whose wake-up was lost. + if ((spins & 1023) == 0 && back_waiting.load(std::memory_order_seq_cst)) { + wake.fetch_add(1, std::memory_order_seq_cst); + wake.notify_all(); + } } } } @@ -192,7 +198,9 @@ void GpuSplit::Publish() { published.store(write_position, std::memory_order_seq_cst); if (back_waiting.load(std::memory_order_seq_cst)) { wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_one(); + // All: libc++ (Android) parks atomic waiters in a shared hash table, where notify_one + // can wake an unrelated waiter on the same slot and leave the back thread asleep. + wake.notify_all(); } } @@ -203,6 +211,7 @@ void GpuSplit::Drain() { } Publish(); Nx2Profile::Scope scope{Nx2Profile::Zone::SplitDrain}; + auto next_report = std::chrono::steady_clock::now() + std::chrono::seconds{5}; for (u32 spins = 0;; ++spins) { consumed_cache = consumed.load(std::memory_order_acquire); if (consumed_cache == write_position) { @@ -213,6 +222,21 @@ void GpuSplit::Drain() { Pause(); } else { std::this_thread::yield(); + // Still waiting on an asleep back thread: its wake-up was lost (it froze Mario Kart + // World on Android with the front here and the back parked). Wake it again. + if ((spins & 1023) == 0 && back_waiting.load(std::memory_order_seq_cst)) { + wake.fetch_add(1, std::memory_order_seq_cst); + wake.notify_all(); + } + if ((spins & 1023) == 0 && std::chrono::steady_clock::now() >= next_report) { + next_report += std::chrono::seconds{5}; + LOG_WARNING(HW_GPU, + "GPU split drain waiting on the back thread: {} of {} words consumed, " + "back {} ({})", + consumed_cache, write_position, + back_waiting.load() ? "parked" : "running", + g_back_busy.load() ? "inside a command" : "between batches"); + } } } } From 0f0705946ae11b9a2f2ea01bb62e38585741406e Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:03:53 -0400 Subject: [PATCH 024/154] texture_cache: keep the lower levels of images the host cannot create An image larger than the sparse address space (the Qualcomm driver also refuses anything over 4 GiB) cannot be created even unbacked: Mario Kart World's 8192x8192x68 BC6H virtual texture is 6 GB and was drawn as the null image (dark/black surfaces on Android). Such images keep their mip levels from the first one that fits on, like the GPU-decoded ASTC arrays already do, and the plain upload paths now honour that: UnswizzleImage can start at a level, so a trimmed layer upload stages only the levels the host keeps. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 5cfedb2b856e70f3a3fd2eda9c41bec92d74a313) --- .../renderer_vulkan/vk_texture_cache.cpp | 56 ++++++++++++++++++- src/video_core/texture_cache/texture_cache.h | 34 ++++++++++- src/video_core/texture_cache/util.cpp | 14 +++-- src/video_core/texture_cache/util.h | 4 +- src/video_core/vulkan_common/vulkan_device.h | 5 ++ 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 6501a2c15b..10070e719e 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -354,11 +354,65 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM // over 1 GiB as RGBA8 (title 1's 8192x8192x68 ASTC 8x8 virtual textures are 24 GB each) keeps only // its mip levels from `level_offset` on, so host mip 0 is guest mip `level_offset`. Normalized // coordinates and derivative LOD still address it correctly; explicit LODs sample one level lower. +// Bytes of a 2D image's host copy from level `skip` on, for formats not decoded on the GPU. +[[nodiscard]] u64 HostImageBytes(const Device& device, const ImageInfo& info, u32 skip) { + const bool decoded = + (IsPixelFormatASTC(info.format) && !device.IsOptimalAstcSupported()) || + (VideoCore::Surface::IsPixelFormatBCn(info.format) && !device.IsOptimalBcnSupported()); + const u32 block_width = decoded ? 1 : VideoCore::Surface::DefaultBlockWidth(info.format); + const u32 block_height = decoded ? 1 : VideoCore::Surface::DefaultBlockHeight(info.format); + const u64 block_bytes = decoded ? 4 : BytesPerBlock(info.format); + u64 bytes = 0; + for (s32 level = static_cast(skip); level < info.resources.levels; ++level) { + const u32 width = (std::max)(info.size.width >> level, 1U); + const u32 height = (std::max)(info.size.height >> level, 1U); + bytes += u64{Common::DivCeil(width, block_width)} * Common::DivCeil(height, block_height) * + block_bytes; + } + return bytes * static_cast(info.resources.layers); +} + +// Most bytes a host image may span: past the sparse address space it cannot even be created +// unbacked. The Qualcomm driver also refuses any allocation over 4 GiB, sparse ones included. +[[nodiscard]] u64 MaxHostImageBytes(const Device& device) { + u64 limit = device.GetSparseAddressSpaceSize(); + if (device.GetDriverID() == VK_DRIVER_ID_QUALCOMM_PROPRIETARY) { + limit = (std::min)(limit, u64{4} << 30); + } + return limit; +} + [[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, u32& level_offset) { level_offset = 0; - if (!WillUseAcceleratedAstcDecode(device, info) || info.type != ImageType::e2D) { + if (info.type != ImageType::e2D) { return info; } + if (!WillUseAcceleratedAstcDecode(device, info)) { + // Too big for the host to create at all (Mario Kart World's 8192x8192x68 BC6H virtual + // texture is 6 GB, the Adreno limit 4 GB): keep its lower levels, as for ASTC below. + const u64 limit = MaxHostImageBytes(device); + if (limit == 0 || HostImageBytes(device, info, 0) <= limit) { + return info; + } + while (level_offset + 1 < static_cast(info.resources.levels) && + HostImageBytes(device, info, level_offset) > limit / 2) { + ++level_offset; + } + ImageInfo host = info; + host.size.width = (std::max)(info.size.width >> level_offset, 1U); + host.size.height = (std::max)(info.size.height >> level_offset, 1U); + host.resources.levels -= static_cast(level_offset); + static u32 logged{}; + if (logged++ < 16) { + LOG_WARNING(Render_Vulkan, + "{}x{}x{} image (format {}) is {} MiB, over the host's {} MiB; keeping mip " + "levels {}+ ({}x{})", + info.size.width, info.size.height, info.resources.layers, + static_cast(info.format), HostImageBytes(device, info, 0) >> 20, + limit >> 20, level_offset, host.size.width, host.size.height); + } + return host; + } const auto decoded_bytes = [&](u32 skip) { u64 bytes = 0; for (s32 level = static_cast(skip); level < info.resources.levels; ++level) { diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index c54a6a1d8f..93f1854d2b 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -48,6 +48,24 @@ using VideoCore::Surface::PixelFormat; using VideoCore::Surface::SurfaceType; using namespace Common::Literals; +// A host image that keeps only mip levels >= host_level_offset (TrimmedLevels) takes the plain +// upload copies of those levels, renumbered so host level 0 is guest level host_level_offset. +template +void TrimHostLevels(u32 host_level_offset, Copies& copies) { + if (host_level_offset == 0) { + return; + } + const s32 skip = static_cast(host_level_offset); + copies.erase(std::remove_if(copies.begin(), copies.end(), + [skip](const auto& copy) { + return copy.image_subresource.base_level < skip; + }), + copies.end()); + for (auto& copy : copies) { + copy.image_subresource.base_level -= skip; + } +} + template TextureCache

::TextureCache(Runtime& runtime_, Tegra::MaxwellDeviceMemoryManager& device_memory_) : runtime{runtime_}, device_memory{device_memory_} { @@ -1948,7 +1966,14 @@ void TextureCache

::UploadLayers(Image& image, std::span layers) { // Plain formats: each layer unswizzled on its own into its layer of the image. ImageInfo layer_info = image.info; layer_info.resources.layers = 1; - const size_t layer_size = CalculateUnswizzledSizeBytes(layer_info); + // Only the levels the host keeps (TrimmedLevels): a dropped 8192x8192 level 0 alone + // would be 64 MB of staging per layer. + const s32 first_level = static_cast(image.host_level_offset); + ImageInfo host_layer_info = layer_info; + host_layer_info.size.width = (std::max)(layer_info.size.width >> first_level, 1U); + host_layer_info.size.height = (std::max)(layer_info.size.height >> first_level, 1U); + host_layer_info.resources.levels -= first_level; + const size_t layer_size = CalculateUnswizzledSizeBytes(host_layer_info); auto staging = runtime.UploadStagingBuffer(layer_size * layers.size()); std::vector copies; for (size_t i = 0; i < layers.size(); ++i) { @@ -1958,7 +1983,8 @@ void TextureCache

::UploadLayers(Image& image, std::span layers) { &swizzle_data_buffer); auto layer_copies = UnswizzleImage(*gpu_memory, layer_addr, layer_info, swizzle_data, - staging.mapped_span.subspan(i * layer_size, layer_size)); + staging.mapped_span.subspan(i * layer_size, layer_size), + first_level); for (BufferImageCopy copy : layer_copies) { copy.buffer_offset += i * layer_size; copy.image_subresource.base_layer = static_cast(layers[i]); @@ -2031,9 +2057,11 @@ void TextureCache

::UploadImageContents(Image& image, StagingBuffer& staging) unswizzle_data_buffer.resize_destructive(image.unswizzled_size_bytes); auto copies = UnswizzleImage(*gpu_memory, gpu_addr, image.info, swizzle_data, unswizzle_data_buffer); ConvertImage(unswizzle_data_buffer, image.info, mapped_span, copies); + TrimHostLevels(image.host_level_offset, copies); image.UploadMemory(staging, copies); } else { - const auto copies = UnswizzleImage(*gpu_memory, gpu_addr, image.info, swizzle_data, mapped_span); + const auto copies = UnswizzleImage(*gpu_memory, gpu_addr, image.info, swizzle_data, + mapped_span, static_cast(image.host_level_offset)); image.UploadMemory(staging, copies); } } diff --git a/src/video_core/texture_cache/util.cpp b/src/video_core/texture_cache/util.cpp index 09e9513a5b..52f9161d78 100644 --- a/src/video_core/texture_cache/util.cpp +++ b/src/video_core/texture_cache/util.cpp @@ -854,7 +854,8 @@ boost::container::small_vector UnswizzleImage(Tegra::Memory GPUVAddr gpu_addr, const ImageInfo& info, std::span input, - std::span output) { + std::span output, + s32 first_level) { const size_t guest_size_bytes = input.size_bytes(); const u32 bpp_log2 = BytesPerBlockLog2(info.format); const Extent2D tile_size = DefaultBlockSize(info.format); @@ -890,20 +891,25 @@ boost::container::small_vector UnswizzleImage(Tegra::Memory info.tile_width_spacing); size_t guest_offset = 0; u32 host_offset = 0; - boost::container::small_vector copies(num_levels); + first_level = std::clamp(first_level, 0, num_levels - 1); + boost::container::small_vector copies(num_levels - first_level); for (s32 level = 0; level < num_levels; ++level) { + if (level < first_level) { + guest_offset += level_sizes[level]; + continue; + } const Extent3D level_size = AdjustMipSize(size, level); const u32 num_blocks_per_layer = NumBlocks(level_size, tile_size); const u32 host_bytes_per_layer = num_blocks_per_layer << bpp_log2; - copies[level] = BufferImageCopy{ + copies[level - first_level] = BufferImageCopy{ .buffer_offset = host_offset, .buffer_size = static_cast(host_bytes_per_layer) * num_layers, .buffer_row_length = Common::AlignUp(level_size.width, tile_size.width), .buffer_image_height = Common::AlignUp(level_size.height, tile_size.height), .image_subresource = { - .base_level = level, + .base_level = level - first_level, .base_layer = 0, .num_layers = info.resources.layers, }, diff --git a/src/video_core/texture_cache/util.h b/src/video_core/texture_cache/util.h index d91ee74c52..489ca3ad17 100644 --- a/src/video_core/texture_cache/util.h +++ b/src/video_core/texture_cache/util.h @@ -99,9 +99,11 @@ struct UnswizzlePlan { void ExecuteUnswizzlePlan(Tegra::MemoryManager& gpu_memory, const UnswizzlePlan& plan, std::span output); +/// first_level: unswizzle only the levels from it on, packed from the start of `output` and +/// numbered from 0 (for a host image that keeps only those levels). [[nodiscard]] boost::container::small_vector UnswizzleImage( Tegra::MemoryManager& gpu_memory, GPUVAddr gpu_addr, const ImageInfo& info, - std::span input, std::span output); + std::span input, std::span output, s32 first_level = 0); /// For a single-layer 2D block-linear image of 1x1-texel blocks: the host sparse blocks /// (tile_width x tile_height texels) that the guest byte ranges (offset, size) touch, merged into diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h index f3f9209c99..8856187992 100644 --- a/src/video_core/vulkan_common/vulkan_device.h +++ b/src/video_core/vulkan_common/vulkan_device.h @@ -1043,6 +1043,11 @@ FN_MAX_LIMIT_LIST /// yet the driver does: with the heap at its budget it is not available to new images. u64 GetDeviceMemorySlack() const; + /// Most bytes a sparse (unbacked) resource may span. + u64 GetSparseAddressSpaceSize() const { + return properties.properties.limits.sparseAddressSpaceSize; + } + u32 GetSetsPerPool() const { return sets_per_pool; } From d19ca46dddb04b24b1f6b9ef34447f1b4162e6a6 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:04:16 -0400 Subject: [PATCH 025/154] vulkan: emulate shared memory the device does not have Switch 2 compute shaders ask for 48 KB of shared memory; Adreno has 32 KB, and clamping it (Qualcomm, ARM, MoltenVK) left those shaders reading and writing past the end, which showed up as artifacts in Mario Kart World on Android. Such a shader now keeps its shared memory in a storage buffer instead: a slice per workgroup, addressed through one helper (loads, stores, atomics, the 8/16-bit store and CAS loops), with barriers covering buffer memory too. The buffer is the last descriptor; each dispatch binds a scratch buffer sized for its workgroups (4096 for indirect dispatches, capped at 512 MiB). Slower, but correct. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 068d64a5a440cb68d2a4dd1e98eaac9d2df2faa1) --- .../backend/spirv/emit_spirv_atomic.cpp | 4 +- .../backend/spirv/emit_spirv_barriers.cpp | 7 +- .../spirv/emit_spirv_shared_memory.cpp | 16 ++--- .../backend/spirv/spirv_emit_context.cpp | 72 ++++++++++++++++--- .../backend/spirv/spirv_emit_context.h | 8 +++ src/shader_recompiler/shader_info.h | 4 ++ .../renderer_vulkan/pipeline_helper.h | 11 ++- .../renderer_vulkan/vk_compute_pipeline.cpp | 20 +++++- .../renderer_vulkan/vk_compute_pipeline.h | 3 +- .../renderer_vulkan/vk_pipeline_cache.cpp | 18 +++-- .../renderer_vulkan/vk_rasterizer.cpp | 2 +- 11 files changed, 128 insertions(+), 37 deletions(-) diff --git a/src/shader_recompiler/backend/spirv/emit_spirv_atomic.cpp b/src/shader_recompiler/backend/spirv/emit_spirv_atomic.cpp index d1c08c1131..f833443d5a 100644 --- a/src/shader_recompiler/backend/spirv/emit_spirv_atomic.cpp +++ b/src/shader_recompiler/backend/spirv/emit_spirv_atomic.cpp @@ -18,9 +18,7 @@ Id SharedPointer(EmitContext& ctx, Id offset, u32 index_offset = 0) { if (index_offset > 0) { index = ctx.OpIAdd(ctx.U32[1], index, ctx.Const(index_offset)); } - return ctx.uses_explicit_workgroup_layout - ? ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, ctx.u32_zero_value, index) - : ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, index); + return ctx.SharedWordPointer(index); } Id StorageIndex(EmitContext& ctx, const IR::Value& offset, size_t element_size) { diff --git a/src/shader_recompiler/backend/spirv/emit_spirv_barriers.cpp b/src/shader_recompiler/backend/spirv/emit_spirv_barriers.cpp index 0b9a3664ff..19d3365e7a 100644 --- a/src/shader_recompiler/backend/spirv/emit_spirv_barriers.cpp +++ b/src/shader_recompiler/backend/spirv/emit_spirv_barriers.cpp @@ -18,8 +18,11 @@ void MemoryBarrier(EmitContext& ctx, spv::Scope scope) { void EmitBarrier(EmitContext& ctx) { const auto execution{spv::Scope::Workgroup}; const auto memory{spv::Scope::Workgroup}; - const auto memory_semantics{spv::MemorySemanticsMask::AcquireRelease | - spv::MemorySemanticsMask::WorkgroupMemory}; + // Emulated shared memory is a storage buffer: its writes need UniformMemory semantics. + const auto memory_semantics{ + spv::MemorySemanticsMask::AcquireRelease | spv::MemorySemanticsMask::WorkgroupMemory | + (ctx.emulates_shared_memory ? spv::MemorySemanticsMask::UniformMemory + : spv::MemorySemanticsMask::MaskNone)}; ctx.OpControlBarrier(ctx.Const(static_cast(execution)), ctx.Const(static_cast(memory)), ctx.Const(static_cast(memory_semantics))); diff --git a/src/shader_recompiler/backend/spirv/emit_spirv_shared_memory.cpp b/src/shader_recompiler/backend/spirv/emit_spirv_shared_memory.cpp index dc398aa296..dcee9529e0 100644 --- a/src/shader_recompiler/backend/spirv/emit_spirv_shared_memory.cpp +++ b/src/shader_recompiler/backend/spirv/emit_spirv_shared_memory.cpp @@ -18,7 +18,7 @@ Id Pointer(EmitContext& ctx, Id pointer_type, Id array, Id offset, u32 shift) { Id Word(EmitContext& ctx, Id offset) { const Id shift_id{ctx.Const(2U)}; const Id index{ctx.OpShiftRightArithmetic(ctx.U32[1], offset, shift_id)}; - const Id pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, index)}; + const Id pointer{ctx.SharedWordPointer(index)}; return ctx.OpLoad(ctx.U32[1], pointer); } @@ -89,8 +89,8 @@ Id EmitLoadSharedU64(EmitContext& ctx, Id offset) { const Id shift_id{ctx.Const(2U)}; const Id base_index{ctx.OpShiftRightArithmetic(ctx.U32[1], offset, shift_id)}; const Id next_index{ctx.OpIAdd(ctx.U32[1], base_index, ctx.Const(1U))}; - const Id lhs_pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, base_index)}; - const Id rhs_pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, next_index)}; + const Id lhs_pointer{ctx.SharedWordPointer(base_index)}; + const Id rhs_pointer{ctx.SharedWordPointer(next_index)}; return ctx.OpCompositeConstruct(ctx.U32[2], ctx.OpLoad(ctx.U32[1], lhs_pointer), ctx.OpLoad(ctx.U32[1], rhs_pointer)); } @@ -106,7 +106,7 @@ Id EmitLoadSharedU128(EmitContext& ctx, Id offset) { std::array values{}; for (u32 i = 0; i < 4; ++i) { const Id index{i == 0 ? base_index : ctx.OpIAdd(ctx.U32[1], base_index, ctx.Const(i))}; - const Id pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, index)}; + const Id pointer{ctx.SharedWordPointer(index)}; values[i] = ctx.OpLoad(ctx.U32[1], pointer); } return ctx.OpCompositeConstruct(ctx.U32[4], values); @@ -138,7 +138,7 @@ void EmitWriteSharedU32(EmitContext& ctx, Id offset, Id value) { } else { const Id shift{ctx.Const(2U)}; const Id word_offset{ctx.OpShiftRightArithmetic(ctx.U32[1], offset, shift)}; - pointer = ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, word_offset); + pointer = ctx.SharedWordPointer(word_offset); } ctx.OpStore(pointer, value); } @@ -152,8 +152,8 @@ void EmitWriteSharedU64(EmitContext& ctx, Id offset, Id value) { const Id shift{ctx.Const(2U)}; const Id word_offset{ctx.OpShiftRightArithmetic(ctx.U32[1], offset, shift)}; const Id next_offset{ctx.OpIAdd(ctx.U32[1], word_offset, ctx.Const(1U))}; - const Id lhs_pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, word_offset)}; - const Id rhs_pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, next_offset)}; + const Id lhs_pointer{ctx.SharedWordPointer(word_offset)}; + const Id rhs_pointer{ctx.SharedWordPointer(next_offset)}; ctx.OpStore(lhs_pointer, ctx.OpCompositeExtract(ctx.U32[1], value, 0U)); ctx.OpStore(rhs_pointer, ctx.OpCompositeExtract(ctx.U32[1], value, 1U)); } @@ -168,7 +168,7 @@ void EmitWriteSharedU128(EmitContext& ctx, Id offset, Id value) { const Id base_index{ctx.OpShiftRightArithmetic(ctx.U32[1], offset, shift)}; for (u32 i = 0; i < 4; ++i) { const Id index{i == 0 ? base_index : ctx.OpIAdd(ctx.U32[1], base_index, ctx.Const(i))}; - const Id pointer{ctx.OpAccessChain(ctx.shared_u32, ctx.shared_memory_u32, index)}; + const Id pointer{ctx.SharedWordPointer(index)}; ctx.OpStore(pointer, ctx.OpCompositeExtract(ctx.U32[1], value, i)); } } diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp index f8f966a6b5..b41f0edaaa 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp @@ -400,8 +400,9 @@ Id CasLoop(EmitContext& ctx, Operation operation, Id array_pointer, Id element_p ctx.OpBranch(continue_block); ctx.AddLabel(continue_block); - const Id word_pointer{is_struct ? ctx.OpAccessChain(element_pointer, base, zero, index) - : ctx.OpAccessChain(element_pointer, base, index)}; + const Id word_pointer{is_shared ? ctx.SharedWordPointer(index) + : is_struct ? ctx.OpAccessChain(element_pointer, base, zero, index) + : ctx.OpAccessChain(element_pointer, base, index)}; if (value_type.value == ctx.F32[2].value) { const Id u32_value{ctx.OpLoad(ctx.U32[1], word_pointer)}; const Id value{ctx.OpUnpackHalf2x16(ctx.F32[2], u32_value)}; @@ -491,6 +492,10 @@ EmitContext::EmitContext(const Profile& profile_, const RuntimeInfo& runtime_inf DefineImageBuffers(program.info, image_binding); DefineTextures(program.info, texture_binding, bindings.texture_scaling_index); DefineImages(program.info, image_binding, bindings.image_scaling_index); + if (emulates_shared_memory && Sirit::ValidId(shared_memory_u32)) { + // After every other descriptor: the host appends the buffer in the same place. + Decorate(shared_memory_u32, spv::Decoration::Binding, storage_binding++); + } DefineAttributeMemAccess(program.info); DefineWriteStorageCasLoopFunction(program.info); DefineGlobalMemoryFunctions(program.info); @@ -500,6 +505,26 @@ EmitContext::EmitContext(const Profile& profile_, const RuntimeInfo& runtime_inf EmitContext::~EmitContext() = default; +Id EmitContext::SharedWordPointer(Id word_index) { + if (emulates_shared_memory) { + // This workgroup's slice: linear workgroup index x + nx * (y + ny * z). + const Id id{OpLoad(U32[3], workgroup_id)}; + const Id count{OpLoad(U32[3], num_workgroups)}; + const Id x{OpCompositeExtract(U32[1], id, 0U)}; + const Id y{OpCompositeExtract(U32[1], id, 1U)}; + const Id z{OpCompositeExtract(U32[1], id, 2U)}; + const Id nx{OpCompositeExtract(U32[1], count, 0U)}; + const Id ny{OpCompositeExtract(U32[1], count, 1U)}; + const Id linear{OpIAdd(U32[1], x, OpIMul(U32[1], nx, OpIAdd(U32[1], y, OpIMul(U32[1], ny, z))))}; + const Id base{OpIMul(U32[1], linear, Const(shared_memory_words))}; + return OpAccessChain(shared_u32, shared_memory_u32, u32_zero_value, + OpIAdd(U32[1], base, word_index)); + } + return uses_explicit_workgroup_layout + ? OpAccessChain(shared_u32, shared_memory_u32, u32_zero_value, word_index) + : OpAccessChain(shared_u32, shared_memory_u32, word_index); +} + Id EmitContext::Def(const IR::Value& value) { if (!value.IsImmediate()) { return value.InstRecursive()->Definition(); @@ -611,6 +636,10 @@ void EmitContext::DefineSharedMemory(const IR::Program& program) { profile.support_explicit_workgroup_layout && (!program.info.uses_int8 || profile.support_workgroup_layout_8bit_access) && (!program.info.uses_int16 || profile.support_workgroup_layout_16bit_access); + emulates_shared_memory = program.info.emulated_shared_memory_size != 0; + if (emulates_shared_memory) { + uses_explicit_workgroup_layout = false; + } if (program.shared_memory_size == 0) { return; } @@ -650,13 +679,38 @@ void EmitContext::DefineSharedMemory(const IR::Program& program) { std::tie(shared_memory_u32x4, shared_u32x4, std::ignore) = make(U32[4], 16); return; } - const u32 num_elements{Common::DivCeil(program.shared_memory_size, 4U)}; - const Id type{TypeArray(U32[1], Const(num_elements))}; - shared_memory_u32_type = TypePointer(spv::StorageClass::Workgroup, type); + if (emulates_shared_memory) { + // More shared memory than the device has (48 KB Switch 2 programs on a 32 KB Adreno): a + // storage buffer holds a slice per workgroup (SharedWordPointer). Its binding comes last, + // see the constructor. + shared_memory_words = Common::DivCeil(program.info.emulated_shared_memory_size, 4U); + const Id array_type{TypeRuntimeArray(U32[1])}; + Decorate(array_type, spv::Decoration::ArrayStride, 4U); + const Id struct_type{TypeStruct(array_type)}; + Decorate(struct_type, spv::Decoration::Block); + MemberDecorate(struct_type, 0, spv::Decoration::Offset, 0U); + shared_memory_u32_type = TypePointer(spv::StorageClass::StorageBuffer, struct_type); + shared_u32 = TypePointer(spv::StorageClass::StorageBuffer, U32[1]); + shared_memory_u32 = + AddGlobalVariable(shared_memory_u32_type, spv::StorageClass::StorageBuffer); + Decorate(shared_memory_u32, spv::Decoration::DescriptorSet, 0U); + Name(shared_memory_u32, "shared_memory_emulation"); + if (profile.supported_spirv >= 0x00010400) { + interfaces.push_back(shared_memory_u32); + } + if (!Sirit::ValidId(workgroup_id)) { + workgroup_id = DefineInput(*this, U32[3], false, spv::BuiltIn::WorkgroupId); + } + num_workgroups = DefineInput(*this, U32[3], false, spv::BuiltIn::NumWorkgroups); + } else { + const u32 num_elements{Common::DivCeil(program.shared_memory_size, 4U)}; + const Id type{TypeArray(U32[1], Const(num_elements))}; + shared_memory_u32_type = TypePointer(spv::StorageClass::Workgroup, type); - shared_u32 = TypePointer(spv::StorageClass::Workgroup, U32[1]); - shared_memory_u32 = AddGlobalVariable(shared_memory_u32_type, spv::StorageClass::Workgroup); - interfaces.push_back(shared_memory_u32); + shared_u32 = TypePointer(spv::StorageClass::Workgroup, U32[1]); + shared_memory_u32 = AddGlobalVariable(shared_memory_u32_type, spv::StorageClass::Workgroup); + interfaces.push_back(shared_memory_u32); + } const Id func_type{TypeFunction(void_id, U32[1], U32[1])}; const auto make_function{[&](u32 mask, u32 size) { @@ -679,7 +733,7 @@ void EmitContext::DefineSharedMemory(const IR::Program& program) { OpBranch(continue_block); AddLabel(continue_block); - const Id word_pointer{OpAccessChain(shared_u32, shared_memory_u32, word_offset)}; + const Id word_pointer{SharedWordPointer(word_offset)}; const Id old_value{OpLoad(U32[1], word_pointer)}; const Id new_value{OpBitFieldInsert(U32[1], old_value, insert_value, bit_offset, count)}; const Id atomic_res{OpAtomicCompareExchange(U32[1], word_pointer, Const(1U), u32_zero_value, diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.h b/src/shader_recompiler/backend/spirv/spirv_emit_context.h index 442573b31a..04edc8888d 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.h +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.h @@ -280,6 +280,14 @@ class EmitContext final : public Sirit::Module { std::vector images; Id workgroup_id{}; + Id num_workgroups{}; + + /// Shared memory lives in a storage buffer, a slice of shared_memory_words per workgroup. + bool emulates_shared_memory{}; + u32 shared_memory_words{}; + + /// Pointer to a 32-bit word of shared memory, native or emulated. + Id SharedWordPointer(Id word_index); Id local_invocation_id{}; Id invocation_id{}; Id patch_vertices_in{}; diff --git a/src/shader_recompiler/shader_info.h b/src/shader_recompiler/shader_info.h index 4e5fb5c6e4..ea37511ce7 100644 --- a/src/shader_recompiler/shader_info.h +++ b/src/shader_recompiler/shader_info.h @@ -274,6 +274,10 @@ struct Info { bool is_task{}; ///< Switch 2 task program (Stage::Task) bool is_mesh{}; ///< Switch 2 mesh program (Stage::Mesh) + /// Shared memory per workgroup kept in a storage buffer instead (the device has less than the + /// program needs), bound after every other descriptor. Zero when shared memory is native. + u32 emulated_shared_memory_size{}; + bool uses_workgroup_id{}; bool uses_local_invocation_id{}; bool uses_invocation_id{}; diff --git a/src/video_core/renderer_vulkan/pipeline_helper.h b/src/video_core/renderer_vulkan/pipeline_helper.h index 07de25f18f..7734d0bef0 100644 --- a/src/video_core/renderer_vulkan/pipeline_helper.h +++ b/src/video_core/renderer_vulkan/pipeline_helper.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include @@ -140,7 +141,8 @@ inline void WriteDescriptorBuffer(const Device& device, const DescriptorBufferLa Shader::NumDescriptors(info.texture_buffer_descriptors) + Shader::NumDescriptors(info.image_buffer_descriptors) + Shader::NumDescriptors(info.texture_descriptors) + - Shader::NumDescriptors(info.image_descriptors); + Shader::NumDescriptors(info.image_descriptors) + + (info.emulated_shared_memory_size != 0 ? 1U : 0U); } class DescriptorLayoutBuilder { @@ -273,6 +275,13 @@ class DescriptorLayoutBuilder { Add(VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, stage, info.image_buffer_descriptors); Add(VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, stage, info.texture_descriptors); Add(VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, stage, info.image_descriptors); + if (info.emulated_shared_memory_size != 0) { + // Emulated shared memory's storage buffer: last, as in the SPIR-V + struct SharedMemoryBuffer { + u32 count{1}; + }; + Add(VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, stage, std::array{}); + } } private: diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 324fbabe8b..0876d1c1d3 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -55,7 +55,9 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk builder.Add(info, VK_SHADER_STAGE_COMPUTE_BIT); uses_push_descriptor = builder.CanUsePushDescriptor(); - uses_descriptor_buffer = builder.CanUseDescriptorBuffer() && descriptor_buffer_ring.IsValid(); + // Emulated shared memory's scratch buffer has no device address for a descriptor buffer. + uses_descriptor_buffer = builder.CanUseDescriptorBuffer() && descriptor_buffer_ring.IsValid() && + info.emulated_shared_memory_size == 0; descriptor_set_layout = builder.CreateDescriptorSetLayout(uses_push_descriptor, uses_descriptor_buffer); if (uses_descriptor_buffer) { @@ -160,7 +162,8 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, Tegra::MemoryManager& gpu_memory, Scheduler& scheduler, - BufferCache& buffer_cache, TextureCache& texture_cache) { + BufferCache& buffer_cache, TextureCache& texture_cache, + StagingBufferPool& staging_pool) { guest_descriptor_queue.Acquire(scheduler, num_descriptor_entries, uses_descriptor_buffer); buffer_cache.SetComputeUniformBufferState(info.constant_buffer_mask, &uniform_buffer_sizes); @@ -280,6 +283,19 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, const VideoCommon::ImageViewInOut* views_it{views.data()}; PushImageDescriptors(texture_cache, guest_descriptor_queue, info, rescaling, samplers_it, views_it); + if (info.emulated_shared_memory_size != 0) { + // Emulated shared memory, a slice per workgroup. An indirect dispatch's size is only + // known on the GPU: it gets room for 4096 workgroups. + const auto& qmd{kepler_compute.launch_description}; + u64 workgroups = static_cast(qmd.grid_dim_x.Value()) * qmd.grid_dim_y.Value() * + qmd.grid_dim_z.Value(); + if (workgroups == 0 || kepler_compute.GetIndirectComputeAddress()) { + workgroups = 4096; + } + const u64 size = (std::min)(workgroups * info.emulated_shared_memory_size, u64{512} << 20); + const StagingBufferRef scratch{staging_pool.Request(size, MemoryUsage::DeviceLocal)}; + guest_descriptor_queue.AddBuffer(scratch.buffer, scratch.offset, size); + } if (!is_built.load(std::memory_order::relaxed)) { // Wait for the pipeline to be built diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.h b/src/video_core/renderer_vulkan/vk_compute_pipeline.h index fba0519b58..dbb4d1fee5 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.h +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.h @@ -50,7 +50,8 @@ class ComputePipeline { [[nodiscard]] bool Configure(Tegra::Engines::KeplerCompute& kepler_compute, Tegra::MemoryManager& gpu_memory, Scheduler& scheduler, - BufferCache& buffer_cache, TextureCache& texture_cache); + BufferCache& buffer_cache, TextureCache& texture_cache, + StagingBufferPool& staging_pool); bool IsBound() const noexcept { return static_cast(pipeline); diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 5399ad6e17..eff77e9ad0 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1616,18 +1616,16 @@ std::unique_ptr PipelineCache::CreateComputePipeline( } auto program{TranslateProgram(pools.inst, pools.block, env, cfg, host_info)}; - const VkDriverIdKHR driver_id = device.GetDriverID(); - const bool needs_shared_mem_clamp = - driver_id == VK_DRIVER_ID_QUALCOMM_PROPRIETARY || - driver_id == VK_DRIVER_ID_ARM_PROPRIETARY || driver_id == VK_DRIVER_ID_MOLTENVK; const u32 max_shared_memory = device.GetMaxComputeSharedMemorySize(); - if (needs_shared_mem_clamp && program.shared_memory_size > max_shared_memory) { + if (max_shared_memory != 0 && program.shared_memory_size > max_shared_memory) { + // More than the device has (48 KB Switch 2 programs on a 32 KB Adreno): keep it in a + // storage buffer, a slice per workgroup, rather than clamp it, which corrupted what those + // shaders computed. Slower, but correct. LOG_WARNING(Render_Vulkan, - "Compute shader {:#016x} requests {}KB shared memory but device max is {}KB - clamping", - key.unique_hash, - program.shared_memory_size / 1024, - max_shared_memory / 1024); - program.shared_memory_size = max_shared_memory; + "Compute shader {:#016x} needs {} KB of shared memory, the device has {} KB; " + "emulating it in a storage buffer", + key.unique_hash, program.shared_memory_size / 1024, max_shared_memory / 1024); + program.info.emulated_shared_memory_size = program.shared_memory_size; } const std::vector code{EmitSPIRV(profile, program)}; device.SaveShader(code); diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index bb8c7f57a3..13d6a2e589 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1035,7 +1035,7 @@ void RasterizerVulkan::DispatchCompute() { buffer_cache.Nx2CollectListCopies(false); }; if (!pipeline->Configure(*kepler_compute, *gpu_memory, scheduler, buffer_cache, - texture_cache)) { + texture_cache, staging_pool)) { if (kepler_compute->IsNativeMode()) throw Shader::RuntimeError("Native compute resources unavailable"); return; } From 13089643b56e6916dd152f6616ba7d3cc8802ce2 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:04:16 -0400 Subject: [PATCH 026/154] vulkan: dump the SPIR-V of graphics pipelines the driver rejects The Qualcomm driver fails some Switch 2 pipelines with VK_ERROR_UNKNOWN. Each pipeline now holds its stages' SPIR-V while it builds, and on failure writes them to dump/failed_pipelines/_ .spv for spirv-val/spirv-dis. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit c93e1fdce932e7ee2acb3e336b50d76cf5bb1ca3) --- .../renderer_vulkan/vk_graphics_pipeline.cpp | 39 ++++++++++++++++++- .../renderer_vulkan/vk_graphics_pipeline.h | 3 +- .../renderer_vulkan/vk_pipeline_cache.cpp | 4 +- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index d3ffc661c2..ac3299baf9 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -30,6 +30,9 @@ #include "video_core/vulkan_common/vulkan_device.h" #include "video_core/gpu_logging/gpu_logging.h" #include "common/settings.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" #include "video_core/nx2_profile.h" #if defined(__clang__) && defined(NDEBUG) @@ -259,6 +262,33 @@ ConfigureFuncPtr ConfigureFunc(const std::array& m return FindSpec(modules, infos); } +// Writes the stages of a pipeline the driver rejected to dump/failed_pipelines/_.spv. +void DumpFailedPipeline(const GraphicsPipelineCacheKey& key, + const std::array, NUM_STAGES>& spirv) { + static constexpr std::array stage_names{"vertex", "tess_control", "tess_eval", "geometry", + "fragment"}; + const auto dir = + Common::FS::GetEdenPath(Common::FS::EdenPath::DumpDir) / "failed_pipelines"; + if (!Common::FS::CreateDirs(dir)) { + return; + } + for (size_t stage = 0; stage < NUM_STAGES; ++stage) { + if (spirv[stage].empty()) { + continue; + } + const u64 hash = key.unique_hashes[stage + 1]; + const auto path = dir / fmt::format("{:016x}_{}.spv", hash, stage_names[stage]); + Common::FS::IOFile file{path, Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile}; + if (file.IsOpen()) { + void(file.WriteSpan(std::span{spirv[stage]})); + } + LOG_CRITICAL(Render_Vulkan, " {} stage {:016x}: {} bytes of SPIR-V dumped to {}", + stage_names[stage], hash, spirv[stage].size() * sizeof(u32), + Common::FS::PathToUTF8String(path)); + } +} + } // Anonymous namespace // TODO(crueter): This is the worst-formatted code I have EVER seen @@ -270,7 +300,8 @@ GraphicsPipeline::GraphicsPipeline( Common::ThreadWorker* worker_thread, PipelineStatistics* pipeline_statistics, RenderPassCache& render_pass_cache, const GraphicsPipelineCacheKey& key_, std::array stages, - const std::array& infos) + const std::array& infos, + std::array, NUM_STAGES> spirv) : key{key_}, device{device_}, texture_cache{texture_cache_}, buffer_cache{buffer_cache_}, pipeline_cache(pipeline_cache_), scheduler{scheduler_}, guest_descriptor_queue{guest_descriptor_queue_}, @@ -324,13 +355,17 @@ GraphicsPipeline::GraphicsPipeline( } } - auto func{[this, shader_notify, &render_pass_cache, pipeline_statistics] { + // The stages' SPIR-V is kept only while the pipeline builds: a driver that rejects it gets + // it written to dump/failed_pipelines for inspection (spirv-val, spirv-dis). + auto func{[this, shader_notify, &render_pass_cache, pipeline_statistics, + spirv = std::move(spirv)] { const VkRenderPass render_pass{render_pass_cache.Get(MakeRenderPassKey(key.state, device))}; Validate(); try { MakePipeline(render_pass); } catch (const vk::Exception& exception) { LOG_CRITICAL(Render_Vulkan, "Graphics pipeline build failed: {}", exception.what()); + DumpFailedPipeline(key, spirv); std::scoped_lock lock{build_mutex}; is_built = true; build_condvar.notify_one(); diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.h b/src/video_core/renderer_vulkan/vk_graphics_pipeline.h index 6d6e2ab12d..5101db223b 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.h +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.h @@ -84,7 +84,8 @@ class GraphicsPipeline { DescriptorBufferRing& descriptor_buffer_ring, Common::ThreadWorker* worker_thread, PipelineStatistics* pipeline_statistics, RenderPassCache& render_pass_cache, const GraphicsPipelineCacheKey& key, std::array stages, - const std::array& infos); + const std::array& infos, + std::array, NUM_STAGES> spirv = {}); bool HasDynamicVertexInput() const noexcept { return key.state.dynamic_vertex_input; } bool SupportsAlphaToCoverage() const noexcept { diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index eff77e9ad0..d78e34bf6b 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1232,6 +1232,7 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( } std::array infos{}; std::array modules; + std::array, Maxwell::MaxShaderStage> spirv; // For a pipeline the driver rejects const Shader::IR::Program* previous_stage{}; Shader::Backend::Bindings binding; @@ -1253,6 +1254,7 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( const std::vector code{EmitSPIRV(profile, runtime_info, program, binding)}; device.SaveShader(code); modules[stage_index] = BuildShader(device, code); + spirv[stage_index] = code; // Text log + .spv dump. Text log is gated by gpu_log_level != Off; .spv dump // is independent and gated only by gpu_log_shader_dumps. @@ -1282,7 +1284,7 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( return std::make_unique( scheduler, buffer_cache, texture_cache, vulkan_pipeline_cache, &shader_notify, device, descriptor_pool, guest_descriptor_queue, descriptor_buffer_ring, thread_worker, statistics, - render_pass_cache, key, std::move(modules), infos); + render_pass_cache, key, std::move(modules), infos, std::move(spirv)); } catch (const Shader::Exception& exception) { auto hash = key.Hash(); From 26dbba2a5654d7931deec6f1ed0f0d53b07a8136 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:09:22 -0400 Subject: [PATCH 027/154] vulkan: dump rejected compute shaders and emulated shared memory ones The Qualcomm driver also rejects a Switch 2 compute shader with VK_ERROR_UNKNOWN. Compute pipelines keep their SPIR-V while they build and write it to dump/failed_pipelines on failure; shaders using the shared memory emulation are written to dump/emulated_shared_memory, to validate what the emulation generates. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit add6eee3614ebdc7d357a2d3f2c4e635fb4709af) --- .../renderer_vulkan/pipeline_helper.h | 21 +++++++++++++++++++ .../renderer_vulkan/vk_compute_pipeline.cpp | 7 +++++-- .../renderer_vulkan/vk_compute_pipeline.h | 3 ++- .../renderer_vulkan/vk_pipeline_cache.cpp | 5 ++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/pipeline_helper.h b/src/video_core/renderer_vulkan/pipeline_helper.h index 7734d0bef0..a1553b0c6c 100644 --- a/src/video_core/renderer_vulkan/pipeline_helper.h +++ b/src/video_core/renderer_vulkan/pipeline_helper.h @@ -9,6 +9,14 @@ #include #include #include +#include +#include + +#include + +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" #include @@ -135,6 +143,19 @@ inline void WriteDescriptorBuffer(const Device& device, const DescriptorBufferLa } } +/// Writes SPIR-V to dump//.spv, for spirv-val and spirv-dis. +inline void DumpSpirv(std::string_view subdir, std::string_view name, std::span code) { + const auto dir = Common::FS::GetEdenPath(Common::FS::EdenPath::DumpDir) / subdir; + if (code.empty() || !Common::FS::CreateDirs(dir)) { + return; + } + Common::FS::IOFile file{dir / fmt::format("{}.spv", name), Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile}; + if (file.IsOpen()) { + void(file.WriteSpan(code)); + } +} + [[nodiscard]] inline u32 NumDescriptorEntries(const Shader::Info& info) { return Shader::NumDescriptors(info.constant_buffer_descriptors) + Shader::NumDescriptors(info.storage_buffers_descriptors) + diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 0876d1c1d3..b6b58c765a 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -39,7 +39,8 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk Common::ThreadWorker* thread_worker, PipelineStatistics* pipeline_statistics, VideoCore::ShaderNotify* shader_notify, const Shader::Info& info_, - vk::ShaderModule spv_module_, u64 shader_hash_) + vk::ShaderModule spv_module_, u64 shader_hash_, + std::vector spirv) : device{device_}, pipeline_cache(pipeline_cache_), guest_descriptor_queue{guest_descriptor_queue_}, descriptor_buffer_ring{descriptor_buffer_ring_}, info{info_}, @@ -82,7 +83,8 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk } } - auto func{[this, shader_notify, pipeline_statistics] { + // The SPIR-V is kept only while the pipeline builds, for a driver that rejects it. + auto func{[this, shader_notify, pipeline_statistics, spirv = std::move(spirv)] { const VkPipelineShaderStageRequiredSubgroupSizeCreateInfoEXT subgroup_size_ci{ .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_REQUIRED_SUBGROUP_SIZE_CREATE_INFO_EXT, .pNext = nullptr, @@ -124,6 +126,7 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk } catch (const vk::Exception& exception) { LOG_CRITICAL(Render_Vulkan, "Adreno rejected compute shader {:016X}: {}", shader_hash, exception.what()); + DumpSpirv("failed_pipelines", fmt::format("{:016x}_compute", shader_hash), spirv); std::scoped_lock lock{build_mutex}; is_built = true; build_condvar.notify_one(); diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.h b/src/video_core/renderer_vulkan/vk_compute_pipeline.h index dbb4d1fee5..9f16ed59ca 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.h +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.h @@ -40,7 +40,8 @@ class ComputePipeline { Common::ThreadWorker* thread_worker, PipelineStatistics* pipeline_statistics, VideoCore::ShaderNotify* shader_notify, const Shader::Info& info, - vk::ShaderModule spv_module, u64 shader_hash); + vk::ShaderModule spv_module, u64 shader_hash, + std::vector spirv = {}); ComputePipeline& operator=(ComputePipeline&&) noexcept = delete; ComputePipeline(ComputePipeline&&) noexcept = delete; diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index d78e34bf6b..ad8c212d4f 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1632,6 +1632,9 @@ std::unique_ptr PipelineCache::CreateComputePipeline( const std::vector code{EmitSPIRV(profile, program)}; device.SaveShader(code); vk::ShaderModule spv_module{BuildShader(device, code)}; + if (program.info.emulated_shared_memory_size != 0) { + DumpSpirv("emulated_shared_memory", fmt::format("{:016x}", key.unique_hash), code); + } // Text log + .spv dump. Same split as the graphics path. const bool should_log = GPU::Logging::IsActive(); @@ -1658,7 +1661,7 @@ std::unique_ptr PipelineCache::CreateComputePipeline( guest_descriptor_queue, descriptor_buffer_ring, thread_worker, statistics, &shader_notify, program.info, std::move(spv_module), - key.unique_hash); + key.unique_hash, code); } catch (const Shader::Exception& exception) { LOG_ERROR(Render_Vulkan, "{}", exception.what()); From 89c0c22b11a0a2398f785551a8273c6f8c7cdbea Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:13:59 -0400 Subject: [PATCH 028/154] spirv: do not decorate the emulated shared memory's types twice SPIR-V types are unique, so the emulated buffer's uint[] and its struct are the u32 storage buffers' types too, and both paths decorated them: spirv-val rejected every emulated shader (ArrayStride given twice). Decorate them only when the storage buffers did not. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 5c9ee97f45af6a58986be23372fa01380dd79997) --- .../backend/spirv/spirv_emit_context.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp index b41f0edaaa..e373980c0a 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp @@ -493,6 +493,15 @@ EmitContext::EmitContext(const Profile& profile_, const RuntimeInfo& runtime_inf DefineTextures(program.info, texture_binding, bindings.texture_scaling_index); DefineImages(program.info, image_binding, bindings.image_scaling_index); if (emulates_shared_memory && Sirit::ValidId(shared_memory_u32)) { + // SPIR-V types are unique, so the emulated buffer's are the u32 storage buffers' ones: + // decorate them only when DefineStorageBuffers did not (twice is invalid). + if (!Sirit::ValidId(storage_types.U32.array)) { + const Id array_type{TypeRuntimeArray(U32[1])}; + Decorate(array_type, spv::Decoration::ArrayStride, 4U); + const Id struct_type{TypeStruct(array_type)}; + Decorate(struct_type, spv::Decoration::Block); + MemberDecorate(struct_type, 0, spv::Decoration::Offset, 0U); + } // After every other descriptor: the host appends the buffer in the same place. Decorate(shared_memory_u32, spv::Decoration::Binding, storage_binding++); } @@ -684,11 +693,8 @@ void EmitContext::DefineSharedMemory(const IR::Program& program) { // storage buffer holds a slice per workgroup (SharedWordPointer). Its binding comes last, // see the constructor. shared_memory_words = Common::DivCeil(program.info.emulated_shared_memory_size, 4U); - const Id array_type{TypeRuntimeArray(U32[1])}; - Decorate(array_type, spv::Decoration::ArrayStride, 4U); - const Id struct_type{TypeStruct(array_type)}; - Decorate(struct_type, spv::Decoration::Block); - MemberDecorate(struct_type, 0, spv::Decoration::Offset, 0U); + // Decorated in the constructor: these are the u32 storage buffers' types too. + const Id struct_type{TypeStruct(TypeRuntimeArray(U32[1]))}; shared_memory_u32_type = TypePointer(spv::StorageClass::StorageBuffer, struct_type); shared_u32 = TypePointer(spv::StorageClass::StorageBuffer, U32[1]); shared_memory_u32 = From 50af16e6823d4394eccd613dc5304099c72f19bb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:18:17 -0400 Subject: [PATCH 029/154] vulkan: keep presenting after the surface cannot be recreated On a lost surface the present thread recreates it; when the window is already gone (the app sent to the background on Android) that fails with another error, which was rethrown on the present thread and ended the process. Drop the frame and retry with the next ones instead. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 33f711d7a4a57d0ca7c7aa5594f3184988d895ae) --- .../renderer_vulkan/vk_present_manager.cpp | 14 +++++++++++++- .../renderer_vulkan/vk_present_manager.h | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_present_manager.cpp b/src/video_core/renderer_vulkan/vk_present_manager.cpp index c1724e1ab4..36f0ab46ab 100644 --- a/src/video_core/renderer_vulkan/vk_present_manager.cpp +++ b/src/video_core/renderer_vulkan/vk_present_manager.cpp @@ -397,7 +397,7 @@ void PresentManager::DiscardFrame(Frame* frame) { } void PresentManager::CopyToSwapchain(Frame* frame) { - bool requires_recreation = false; + bool requires_recreation = surface_recreation_pending; for (u32 attempt = 0; attempt < MAX_PRESENT_ATTEMPTS; ++attempt) { try { @@ -407,11 +407,23 @@ void PresentManager::CopyToSwapchain(Frame* frame) { surface = CreateSurface(instance, render_window.GetWindowInfo()); #endif RecreateSwapchain(frame); + surface_recreation_pending = false; } // Draw to swapchain. return CopyToSwapchainImpl(frame); } catch (const vk::Exception& except) { + if (requires_recreation) { + // The window is gone (the app went to the background on Android): drop this + // frame and try again with the next one rather than end the process. + static u32 logged{}; + if (logged++ < 8) { + LOG_WARNING(Render_Vulkan, "Surface could not be recreated ({}); retrying", + except.what()); + } + surface_recreation_pending = true; + break; + } if (except.GetResult() != VK_ERROR_SURFACE_LOST_KHR) { throw; } diff --git a/src/video_core/renderer_vulkan/vk_present_manager.h b/src/video_core/renderer_vulkan/vk_present_manager.h index ab2b76f10f..05b989b004 100644 --- a/src/video_core/renderer_vulkan/vk_present_manager.h +++ b/src/video_core/renderer_vulkan/vk_present_manager.h @@ -106,6 +106,9 @@ class PresentManager { bool blit_supported; bool storage_supported; bool use_present_thread; + /// The surface was lost and could not be made again yet (no window while the app is in the + /// background on Android): later frames retry instead of the present thread throwing. + bool surface_recreation_pending{}; std::atomic image_count{}; std::atomic swapchain_image_count{}; std::atomic swapchain_image_format{}; From 6b623a48f493b0c967fb24c29827110d0367f6fc Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:34:57 -0400 Subject: [PATCH 030/154] vulkan: back large texture arrays on phones instead of leaving them black Mario Kart World's terrain textures are 8192x8192x68 arrays. On a GPU that samples ASTC natively (Adreno) the ASTC ones never took the per-layer sparse path, which was only for GPU-decoded ASTC, and stayed unbacked: all zeros, dark surfaces. The BC6H one got 4 of 68 layers under the 384 MiB cap. - ASTC sampled natively uploads layer by layer like other plain formats. - On mobile GPUs (memory is the system's RAM) one sparse image may bind 1.5 GiB with 16 GiB of RAM or more, and images are trimmed to that, so a whole array fits (BC6H at mip 1+, ~1.45 GB). - An image there that cannot be sparse is backed whole when it fits (or replaced once written, if its memory is still unwritten), rather than stay unbacked. On upstream: kept next to the SparseTiles path; MakeImage takes back_huge after upstream's aliased. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit dcaaea0f39bb9494ef33408a5d2c491b23f81119) --- .../renderer_vulkan/vk_texture_cache.cpp | 70 ++++++++++++++++--- .../vulkan_common/vulkan_memory_allocator.cpp | 5 +- .../vulkan_common/vulkan_memory_allocator.h | 4 +- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 10070e719e..66b8b1a2e4 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -18,6 +18,7 @@ #include #include "common/alignment.h" #include "common/bit_util.h" +#include "common/memory_detect.h" #include "common/div_ceil.h" #include "common/literals.h" #include "common/settings.h" @@ -196,10 +197,13 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { VideoCore::Surface::GetFormatType(info.format) != VideoCore::Surface::SurfaceType::ColorTexture) { return false; } + // ASTC the host samples as it is (Adreno) uploads layer by layer like other plain formats: + // without this, Mario Kart World's ASTC 8x8 terrain arrays stayed unbacked (black) on phones. const bool layer_uploads = IsHugeAstcArray(device, info) || - (!IsPixelFormatASTC(info.format) && - (!IsPixelFormatBCn(info.format) || device.IsOptimalBcnSupported())); + (IsPixelFormatASTC(info.format) + ? device.IsOptimalAstcSupported() + : (!IsPixelFormatBCn(info.format) || device.IsOptimalBcnSupported())); const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); return layer_uploads && size >= (32U << 20) && Tegra::Nx2InSparseRegion(gpu_addr, size); } @@ -276,9 +280,30 @@ enum class SparseTilesMode { } } +// Phone and tablet GPUs: device memory is the system's RAM, shared with everything else. +[[nodiscard]] bool IsMobileGpu(const Device& device) { + switch (device.GetDriverID()) { + case VK_DRIVER_ID_QUALCOMM_PROPRIETARY: + case VK_DRIVER_ID_MESA_TURNIP: + case VK_DRIVER_ID_ARM_PROPRIETARY: + case VK_DRIVER_ID_MESA_PANVK: + case VK_DRIVER_ID_IMAGINATION_PROPRIETARY: + return true; + default: + return false; + } +} + // The most device memory one SparseLayers image binds: 768 MiB, 384 MiB on GPUs with less than -// 7 GiB (title 1's BC6H arrays are 85 MB a layer). +// 7 GiB (title 1's BC6H arrays are 85 MB a layer). On a phone with 16 GiB of RAM or more, 1.5 GiB: +// a whole trimmed array (68 layers of Mario Kart World's BC6H and ASTC 8x8 textures), whose +// missing layers otherwise sample as black. [[nodiscard]] VkDeviceSize SparseLayersCap(const Device& device) { + if (IsMobileGpu(device)) { + return Common::GetMemInfo().TotalPhysicalMemory >= (u64{16} << 30) + ? (VkDeviceSize{1536} << 20) + : (VkDeviceSize{768} << 20); + } return device.GetDeviceLocalMemory() < (u64{7} << 30) ? (VkDeviceSize{384} << 20) : (VkDeviceSize{768} << 20); } @@ -390,12 +415,18 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM if (!WillUseAcceleratedAstcDecode(device, info)) { // Too big for the host to create at all (Mario Kart World's 8192x8192x68 BC6H virtual // texture is 6 GB, the Adreno limit 4 GB): keep its lower levels, as for ASTC below. - const u64 limit = MaxHostImageBytes(device); + // On a phone, also no more than one sparse image may bind, so every layer gets memory. + const bool mobile = IsMobileGpu(device); + const u64 max_bytes = MaxHostImageBytes(device); + const u64 limit = mobile ? (std::min)(max_bytes == 0 ? ~u64{} : max_bytes, + u64{SparseLayersCap(device)}) + : max_bytes; + const u64 target = mobile ? limit : limit / 2; if (limit == 0 || HostImageBytes(device, info, 0) <= limit) { return info; } while (level_offset + 1 < static_cast(info.resources.levels) && - HostImageBytes(device, info, level_offset) > limit / 2) { + HostImageBytes(device, info, level_offset) > target) { ++level_offset; } ImageInfo host = info; @@ -497,7 +528,8 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM [[nodiscard]] vk::Image MakeImage(const Device& device, const MemoryAllocator& allocator, const ImageInfo& info, std::span view_formats, std::optional format_override = {}, - bool unbacked = false, bool aliased = false) { + bool unbacked = false, bool aliased = false, + bool back_huge = false) { if (info.type == ImageType::Buffer) { return vk::Image{}; } @@ -530,7 +562,7 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM return image; } } - return allocator.CreateImage(image_ci); + return allocator.CreateImage(image_ci, false, back_huge); } [[nodiscard]] vk::ImageView MakeStorageView(const vk::Device& device, u32 level, VkImage image, @@ -2700,14 +2732,36 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu const ImageInfo host_info = TrimmedHostInfo(runtime_.device, info, host_level_offset); const u64 texels = u64{host_info.size.width} * host_info.size.height * host_info.size.depth * static_cast(host_info.resources.layers); + // On a phone a large image can still be backed whole (it is RAM): when it fits one sparse + // image's share, rather than stay all zeros for want of sparse residency in its format. + const bool backable = IsMobileGpu(runtime_.device) && + HostImageBytes(runtime_.device, host_info, 0) <= + SparseLayersCap(runtime_.device); if (UsesSparseLayers(runtime_.device, info, gpu_addr_) && InitSparseLayers()) { flags |= VideoCommon::ImageFlagBits::SparseLayers; } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && InitSparseTiles()) { flags |= VideoCommon::ImageFlagBits::SparseTiles; - } else if (texels <= (u64{1} << 28)) { + } else if (texels <= (u64{1} << 28) || + (backable && IsUnwrittenSparse(runtime_.device, info, gpu_addr_))) { // Not too big to back (see MemoryAllocator::CreateImage): unbacked because nothing // was written there yet, so the first write gets it replaced. flags |= VideoCommon::ImageFlagBits::UnwrittenSparse; + } else if (backable) { + vk::Image backed = MakeImage( + runtime_.device, runtime_.memory_allocator, host_info, + IsHugeAstcArray(runtime_.device, info) ? std::span{HugeAstcViewFormats} + : IsOpaqueBc1Astc(runtime_.device, info) + ? std::span{OpaqueAstcViewFormats} + : runtime->ViewFormats(info.format), + AstcHostFormat(runtime_.device, info), false, false, true); + if (backed && !backed.IsUnbacked()) { + LOG_INFO(Render_Vulkan, "{}x{}x{} image at {:#x} has no sparse residency here; " + "backing it whole", + host_info.size.width, host_info.size.height, host_info.resources.layers, + gpu_addr_); + original_image = std::move(backed); + flags &= ~VideoCommon::ImageFlagBits::Unbacked; + } } } if (host_level_offset != 0) { diff --git a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp index a0a39abe39..57ecaa65ad 100644 --- a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp +++ b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp @@ -206,7 +206,8 @@ MemoryAllocator::MemoryAllocator(const Device &device_) MemoryAllocator::~MemoryAllocator() = default; -vk::Image MemoryAllocator::CreateImage(const VkImageCreateInfo &ci, bool dedicated) const +vk::Image MemoryAllocator::CreateImage(const VkImageCreateInfo &ci, bool dedicated, + bool back_huge) const { const VmaAllocationCreateInfo alloc_ci = { // Best fit: images freed by the thousand leave holes in the blocks, which new ones @@ -231,7 +232,7 @@ vk::Image MemoryAllocator::CreateImage(const VkImageCreateInfo &ci, bool dedicat // backed: VMA would quietly place them in system memory once VRAM is full. They take the // unbacked sparse fallback below. const u64 texels = u64{ci.extent.width} * ci.extent.height * ci.extent.depth * ci.arrayLayers; - const bool huge = texels > (u64{1} << 28); + const bool huge = !back_huge && texels > (u64{1} << 28); VkResult result = huge ? VK_ERROR_OUT_OF_DEVICE_MEMORY : vmaCreateImage(allocator, &ci, &alloc_ci, &handle, &allocation, &alloc_info); if (result == VK_ERROR_OUT_OF_DEVICE_MEMORY && !huge) { diff --git a/src/video_core/vulkan_common/vulkan_memory_allocator.h b/src/video_core/vulkan_common/vulkan_memory_allocator.h index 8b9e72b3e3..7e7e0c213b 100644 --- a/src/video_core/vulkan_common/vulkan_memory_allocator.h +++ b/src/video_core/vulkan_common/vulkan_memory_allocator.h @@ -108,7 +108,9 @@ namespace Vulkan { /// `dedicated`: own device memory, returned to the driver on destruction (short-lived /// images would otherwise leave holes in the shared blocks). - vk::Image CreateImage(const VkImageCreateInfo &ci, bool dedicated = false) const; + /// back_huge: back an image even past the size this keeps unbacked (unified memory). + vk::Image CreateImage(const VkImageCreateInfo &ci, bool dedicated = false, + bool back_huge = false) const; /// A sparse resident image with nothing bound (reads return zero, writes are dropped), or /// a null image when the device can't make one. From 6df324fb0c4378c6fb2dab047e9697a926841854 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:34:58 -0400 Subject: [PATCH 031/154] spirv: keep emulated shared memory accesses inside the buffer An indirect dispatch can have more workgroups than the scratch buffer had slices, and a program can address past its shared memory; a write past the end can fault the GPU (Turnip reported device loss). The workgroup slice and the word are clamped, and the host binds at least as many slices as a shader may address (256 MiB at most). Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 551403ee63a157703588401a6a33b29d13faea93) --- .../backend/spirv/spirv_emit_context.cpp | 10 ++++++++-- src/shader_recompiler/shader_info.h | 11 +++++++++++ .../renderer_vulkan/vk_compute_pipeline.cpp | 9 +++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp index e373980c0a..ce46b55bf4 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp @@ -525,9 +525,15 @@ Id EmitContext::SharedWordPointer(Id word_index) { const Id nx{OpCompositeExtract(U32[1], count, 0U)}; const Id ny{OpCompositeExtract(U32[1], count, 1U)}; const Id linear{OpIAdd(U32[1], x, OpIMul(U32[1], nx, OpIAdd(U32[1], y, OpIMul(U32[1], ny, z))))}; - const Id base{OpIMul(U32[1], linear, Const(shared_memory_words))}; + // Never past the buffer, which a write there can turn into a GPU fault (device lost): an + // indirect dispatch can have more workgroups than slices, and a program can address past + // its shared memory size. The host binds at least this many slices. + const u32 slices{EmulatedSharedMemorySlices(shared_memory_words * 4)}; + const Id slice{OpUMin(U32[1], linear, Const(slices - 1))}; + const Id word{OpUMin(U32[1], word_index, Const(shared_memory_words - 1))}; + const Id base{OpIMul(U32[1], slice, Const(shared_memory_words))}; return OpAccessChain(shared_u32, shared_memory_u32, u32_zero_value, - OpIAdd(U32[1], base, word_index)); + OpIAdd(U32[1], base, word)); } return uses_explicit_workgroup_layout ? OpAccessChain(shared_u32, shared_memory_u32, u32_zero_value, word_index) diff --git a/src/shader_recompiler/shader_info.h b/src/shader_recompiler/shader_info.h index ea37511ce7..4f4ba36714 100644 --- a/src/shader_recompiler/shader_info.h +++ b/src/shader_recompiler/shader_info.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include #include @@ -267,6 +268,16 @@ struct MeshInfo { boost::container::static_vector primitive_lines; }; +/// Most bytes an emulated shared memory buffer spans: shaders clamp their workgroup slice to it +/// and the host binds at least min(workgroups, slices that fit) slices. +constexpr u32 EMULATED_SHARED_MEMORY_MAX_BYTES = 256U << 20; + +[[nodiscard]] constexpr u32 EmulatedSharedMemorySlices(u32 bytes_per_workgroup) { + return bytes_per_workgroup == 0 + ? 1U + : (std::max)(EMULATED_SHARED_MEMORY_MAX_BYTES / bytes_per_workgroup, 1U); +} + struct Info { static constexpr size_t MAX_INDIRECT_CBUFS{14}; static constexpr size_t MAX_CBUFS{18}; diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index b6b58c765a..78f2ec3e06 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -287,15 +287,16 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, PushImageDescriptors(texture_cache, guest_descriptor_queue, info, rescaling, samplers_it, views_it); if (info.emulated_shared_memory_size != 0) { - // Emulated shared memory, a slice per workgroup. An indirect dispatch's size is only - // known on the GPU: it gets room for 4096 workgroups. + // Emulated shared memory, a slice per workgroup, as many as the shader may address (it + // clamps to them). An indirect dispatch's size is only known on the GPU: all of them. const auto& qmd{kepler_compute.launch_description}; + const u64 slices = Shader::EmulatedSharedMemorySlices(info.emulated_shared_memory_size); u64 workgroups = static_cast(qmd.grid_dim_x.Value()) * qmd.grid_dim_y.Value() * qmd.grid_dim_z.Value(); if (workgroups == 0 || kepler_compute.GetIndirectComputeAddress()) { - workgroups = 4096; + workgroups = slices; } - const u64 size = (std::min)(workgroups * info.emulated_shared_memory_size, u64{512} << 20); + const u64 size = (std::min)(workgroups, slices) * info.emulated_shared_memory_size; const StagingBufferRef scratch{staging_pool.Request(size, MemoryUsage::DeviceLocal)}; guest_descriptor_queue.AddBuffer(scratch.buffer, scratch.offset, size); } From dc2866359a3a7cc4fc0ae695e9055e4ae4b2d6f7 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:48:41 -0400 Subject: [PATCH 032/154] host1x: notify syncpoint waiters only when there are some Every increment called notify_all, a system call on Android even with no waiter (bionic's condition variable tracks none): 7.6% of the GPU back thread in Mario Kart World. Waiters now count themselves under the same lock, and an increment notifies only when the count is nonzero. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit ebd7aa6ff55bf562ac76349681336839f2935ae7) --- src/video_core/host1x/syncpoint_manager.cpp | 23 ++++++++++++++------- src/video_core/host1x/syncpoint_manager.h | 7 +++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/video_core/host1x/syncpoint_manager.cpp b/src/video_core/host1x/syncpoint_manager.cpp index 7b7353b6ca..d2afc02f55 100644 --- a/src/video_core/host1x/syncpoint_manager.cpp +++ b/src/video_core/host1x/syncpoint_manager.cpp @@ -58,23 +58,25 @@ void SyncpointManager::DeregisterHostAction(u32 syncpoint_id, const ActionHandle } void SyncpointManager::IncrementGuest(u32 syncpoint_id) { - Increment(syncpoints_guest[syncpoint_id], wait_guest_cv, guest_action_storage[syncpoint_id]); + Increment(syncpoints_guest[syncpoint_id], wait_guest_cv, guest_waiters, + guest_action_storage[syncpoint_id]); } void SyncpointManager::IncrementHost(u32 syncpoint_id) { - Increment(syncpoints_host[syncpoint_id], wait_host_cv, host_action_storage[syncpoint_id]); + Increment(syncpoints_host[syncpoint_id], wait_host_cv, host_waiters, + host_action_storage[syncpoint_id]); } void SyncpointManager::WaitGuest(u32 syncpoint_id, u32 expected_value) { - Wait(syncpoints_guest[syncpoint_id], wait_guest_cv, expected_value); + Wait(syncpoints_guest[syncpoint_id], wait_guest_cv, guest_waiters, expected_value); } void SyncpointManager::WaitHost(u32 syncpoint_id, u32 expected_value) { - Wait(syncpoints_host[syncpoint_id], wait_host_cv, expected_value); + Wait(syncpoints_host[syncpoint_id], wait_host_cv, host_waiters, expected_value); } void SyncpointManager::Increment(std::atomic& syncpoint, std::condition_variable& wait_cv, - std::list& action_storage) { + u32& waiters, std::list& action_storage) { auto new_value{syncpoint.fetch_add(1, std::memory_order_acq_rel) + 1}; std::scoped_lock lk(guard); @@ -86,18 +88,25 @@ void SyncpointManager::Increment(std::atomic& syncpoint, std::condition_var it->action(); it = action_storage.erase(it); } - wait_cv.notify_all(); + // Only with someone waiting: a notify is a system call even without waiters (bionic tracks + // none), and syncpoints are incremented thousands of times a second (7.6% of the GPU back + // thread in Mario Kart World on Android). Waiters count themselves under `guard`. + if (waiters != 0) { + wait_cv.notify_all(); + } } void SyncpointManager::Wait(std::atomic& syncpoint, std::condition_variable& wait_cv, - u32 expected_value) { + u32& waiters, u32 expected_value) { const auto pred = [&]() { return syncpoint.load(std::memory_order_acquire) >= expected_value; }; if (pred()) { return; } std::unique_lock lk(guard); + ++waiters; wait_cv.wait(lk, pred); + --waiters; } } // namespace Host1x diff --git a/src/video_core/host1x/syncpoint_manager.h b/src/video_core/host1x/syncpoint_manager.h index d6e65af3eb..607367dab2 100644 --- a/src/video_core/host1x/syncpoint_manager.h +++ b/src/video_core/host1x/syncpoint_manager.h @@ -69,7 +69,7 @@ class SyncpointManager { } private: - void Increment(std::atomic& syncpoint, std::condition_variable& wait_cv, + void Increment(std::atomic& syncpoint, std::condition_variable& wait_cv, u32& waiters, std::list& action_storage); ActionHandle RegisterAction(std::atomic& syncpoint, @@ -78,7 +78,8 @@ class SyncpointManager { void DeregisterAction(std::list& action_storage, const ActionHandle& handle); - void Wait(std::atomic& syncpoint, std::condition_variable& wait_cv, u32 expected_value); + void Wait(std::atomic& syncpoint, std::condition_variable& wait_cv, u32& waiters, + u32 expected_value); std::array, NUM_MAX_SYNCPOINTS> syncpoints_guest{}; std::array, NUM_MAX_SYNCPOINTS> syncpoints_host{}; @@ -89,6 +90,8 @@ class SyncpointManager { std::mutex guard; std::condition_variable wait_guest_cv; std::condition_variable wait_host_cv; + u32 guest_waiters{}; ///< Threads in Wait on wait_guest_cv, under guard + u32 host_waiters{}; ///< Threads in Wait on wait_host_cv, under guard }; } // namespace Host1x From 2f0a864058d3f1da959b1b06b711fa4ec5b6c34e Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:11:47 -0400 Subject: [PATCH 033/154] settings: build shaders asynchronously by default and for Switch 2 titles Switch 2 titles build thousands of pipelines while they play; building them on the GPU thread stalls it (Mario Kart World on Android: 120+ a 10 s window, 200+ ms of GPU thread time). The setting now defaults to on, and a Switch 2 title turns it on for its session (a per-game value, like the 12 GiB memory layout) unless set for that game. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 35e8c211b13a1e1ea4270975e367763e40509f1c) --- src/common/settings.h | 2 +- src/core/core.cpp | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/common/settings.h b/src/common/settings.h index 58801390ae..5d6236c6bf 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -676,7 +676,7 @@ struct Values { SwitchableSetting emulate_bgr565{linkage, false, "emulate_bgr565", Category::RendererHacks}; - SwitchableSetting use_asynchronous_shaders{linkage, false, "use_asynchronous_shaders", + SwitchableSetting use_asynchronous_shaders{linkage, true, "use_asynchronous_shaders", Category::RendererHacks}; SwitchableSetting gpu_unswizzle_texture_size{linkage, diff --git a/src/core/core.cpp b/src/core/core.cpp index 00ee551ef8..40fe7855e0 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -367,6 +367,14 @@ struct System::Impl { Settings::values.memory_layout_mode.SetGlobal(false); Settings::values.memory_layout_mode.SetValue(Settings::MemoryLayout::Memory_12Gb); } + // Switch 2 titles build thousands of pipelines while they play: build them asynchronously + // (a hitch-free frame instead of a GPU thread stall), unless set for this game itself. + if (switch2_program && Settings::values.use_asynchronous_shaders.UsingGlobal() && + !Settings::values.use_asynchronous_shaders.GetValue()) { + LOG_INFO(Core, "Switch 2 program: building shaders asynchronously"); + Settings::values.use_asynchronous_shaders.SetGlobal(false); + Settings::values.use_asynchronous_shaders.SetValue(true); + } // The Switch 2 counter runs at 31.25 MHz and its SDK converts ticks with that constant // (nn::os::GetSystemTickFrequency, TickManager::ConvertToTimeSpan/ConvertToTick). At 19.2 MHz From 163ff963d5bc64204cf586f922447f4d36e9eea9 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:30:44 -0400 Subject: [PATCH 034/154] gpu_split: sleep on the back thread instead of spinning The front waited for the back thread most of each frame (85% of its time in Mario Kart World), yielding in a loop that kept a big core busy and the phone at 92-103 C. After a short spin it now sleeps on a futex (WaitOnAddress on Windows) that the back thread bumps when it hands ring space back while the front waits. Waits time out after 2 ms, so a lost wake-up costs one timeout instead of a hang. On upstream: WaitForWriters, added since, also sleeps on the back thread past its spin, and the back hands its position back through HandBack when the front waits for it. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 200ef7681a24013e68b8c6b245e9904e1627639c) --- src/video_core/gpu_split.cpp | 93 +++++++++++++++++++++++++++++------- src/video_core/gpu_split.h | 8 ++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index bbc67898c3..f982a16281 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -14,6 +14,13 @@ #define NOMINMAX #endif #include +#pragma comment(lib, "Synchronization.lib") +#elif defined(__linux__) +#include +#include +#include +#include +#include #endif #include @@ -42,6 +49,38 @@ void Pause() { #endif } +// Sleeps until `word` moves on from `seen`, for at most `timeout`: a lost wake-up costs one +// timeout instead of a hang. Raw futexes: libc++ skips the wake syscall when its own waiter count +// for the address is zero, so its notify can't pair with these waits. +void WaitWord(std::atomic& word, u32 seen, std::chrono::microseconds timeout) { + static_assert(sizeof(std::atomic) == sizeof(u32)); +#if defined(_WIN32) + WaitOnAddress(&word, &seen, sizeof(seen), + static_cast(std::max(timeout.count() / 1000, 1))); +#elif defined(__linux__) + timespec ts{}; + ts.tv_sec = static_cast(timeout.count() / 1'000'000); + ts.tv_nsec = static_cast(timeout.count() % 1'000'000) * 1000; + syscall(SYS_futex, reinterpret_cast(&word), FUTEX_WAIT_PRIVATE, seen, &ts, nullptr, 0); +#else + (void)word; + (void)seen; + (void)timeout; + std::this_thread::yield(); +#endif +} + +void WakeWord(std::atomic& word) { +#if defined(_WIN32) + WakeByAddressAll(&word); +#elif defined(__linux__) + syscall(SYS_futex, reinterpret_cast(&word), FUTEX_WAKE_PRIVATE, INT_MAX, nullptr, nullptr, + 0); +#else + (void)word; +#endif +} + [[maybe_unused]] std::atomic g_back_thread_id{}; std::atomic g_back_busy{}; @@ -149,6 +188,7 @@ void GpuSplit::Stop() { published.store(0, std::memory_order_relaxed); consumed.store(0, std::memory_order_relaxed); back_waiting.store(false, std::memory_order_relaxed); + front_waiting.store(false, std::memory_order_relaxed); } u64* GpuSplit::ReserveSlow(size_t words) { @@ -179,16 +219,28 @@ void GpuSplit::WaitForSpace(size_t words) { if (spins < 4096) { Pause(); } else { - std::this_thread::yield(); - // As in Drain: re-wake a back thread whose wake-up was lost. - if ((spins & 1023) == 0 && back_waiting.load(std::memory_order_seq_cst)) { - wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_all(); - } + SleepOnBack(); } } } +void GpuSplit::SleepOnBack() { + // Re-wake a back thread whose wake-up was lost (it froze Mario Kart World on Android with the + // front waiting here and the back parked). + if (back_waiting.load(std::memory_order_seq_cst)) { + wake.fetch_add(1, std::memory_order_seq_cst); + wake.notify_all(); + } + // The back bumps `drained` after storing `consumed` when it sees front_waiting: read it + // before the last look at `consumed`, so a store in between ends the wait at once. + front_waiting.store(true, std::memory_order_seq_cst); + const u32 seen = drained.load(std::memory_order_seq_cst); + if (consumed.load(std::memory_order_seq_cst) == consumed_cache) { + WaitWord(drained, seen, std::chrono::milliseconds{2}); + } + front_waiting.store(false, std::memory_order_relaxed); +} + void GpuSplit::Publish() { if (published_local == write_position) { return; @@ -218,17 +270,13 @@ void GpuSplit::Drain() { publish_every = 8; return; } - if (spins < 16384) { + if (spins < 8192) { Pause(); } else { - std::this_thread::yield(); - // Still waiting on an asleep back thread: its wake-up was lost (it froze Mario Kart - // World on Android with the front here and the back parked). Wake it again. - if ((spins & 1023) == 0 && back_waiting.load(std::memory_order_seq_cst)) { - wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_all(); - } - if ((spins & 1023) == 0 && std::chrono::steady_clock::now() >= next_report) { + // Sleep instead of spinning on: the front spent most of each frame here, keeping a big + // core busy and the phone hot. + SleepOnBack(); + if (std::chrono::steady_clock::now() >= next_report) { next_report += std::chrono::seconds{5}; LOG_WARNING(HW_GPU, "GPU split drain waiting on the back thread: {} of {} words consumed, " @@ -258,11 +306,20 @@ bool GpuSplit::WaitForWriters() { if (spins < 16384) { Pause(); } else { - std::this_thread::yield(); + SleepOnBack(); } } } +void GpuSplit::HandBack(u64 position) { + // Seq_cst store and load against SleepOnBack's: one of the two sides sees the other. + consumed.store(position, std::memory_order_seq_cst); + if (front_waiting.load(std::memory_order_seq_cst)) { + drained.fetch_add(1, std::memory_order_seq_cst); + WakeWord(drained); + } +} + void GpuSplit::SwitchEngine(Tegra::Engines::EngineInterface* engine) { front_engine = engine; u64* const entry = Reserve(2); @@ -414,12 +471,12 @@ void GpuSplit::BackLoop(std::stop_token stop_token) { 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); + HandBack(read_position); last_store = read_position; } } g_back_busy.store(false, std::memory_order_release); - consumed.store(read_position, std::memory_order_release); + HandBack(read_position); } back_running.store(false, std::memory_order_release); } diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index 200dddc81e..5f1c0e7e0e 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -167,6 +167,10 @@ class GpuSplit { } } void WaitForSpace(size_t words); + /// Sleeps a while on the back thread handing ring space back. + void SleepOnBack(); + /// Back side: hands the ring up to `position` back, waking a sleeping front. + void HandBack(u64 position); void SelectEngine(Tegra::Engines::EngineInterface* engine) { if (engine != front_engine) [[unlikely]] { SwitchEngine(engine); @@ -209,6 +213,10 @@ class GpuSplit { alignas(128) std::atomic wait_target{~u64{0}}; alignas(128) std::atomic back_waiting{}; std::atomic wake{}; + /// Set while the front sleeps on the back thread; the back bumps `drained` when it hands ring + /// space back then. + alignas(64) std::atomic front_waiting{}; + std::atomic drained{}; std::jthread back_thread; }; From 94df90e59cd9e4fbdd3fb762c2c9064d564a1768 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:02:18 -0400 Subject: [PATCH 035/154] vulkan: never skip draws that write memory while their pipeline builds With asynchronous shaders, a draw whose pipeline was still building was skipped. Runtime pipelines build on the workers either way: skipping is all the setting changes. A skipped draw that writes storage buffers, images, global memory or transform feedback buffers leaves the data later passes read (culling results, indirect arguments, counters) stale. Mario Kart World then faulted the GPU within a minute, with CP opcode errors on both Turnip and Qualcomm's driver. That never happened with the setting off. Such draws now wait for their pipeline in the Vulkan worker, as they do with asynchronous shaders off. Draws that only write render targets are still skipped. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit b83dae2ace53f66bdaa7b7aca7ea52a6f6c3d6d1) --- src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp | 6 ++++++ src/video_core/renderer_vulkan/vk_graphics_pipeline.h | 7 +++++++ src/video_core/renderer_vulkan/vk_pipeline_cache.cpp | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index ac3299baf9..84a8da34ec 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -323,7 +323,13 @@ GraphicsPipeline::GraphicsPipeline( num_image_elements += Shader::NumDescriptors(info->texture_descriptors); num_image_elements += Shader::NumDescriptors(info->image_descriptors); num_descriptor_entries += NumDescriptorEntries(*info); + const auto written = [](const auto& descriptor) { return descriptor.is_written; }; + writes_memory = writes_memory || info->stores_global_memory || + std::ranges::any_of(info->storage_buffers_descriptors, written) || + std::ranges::any_of(info->image_buffer_descriptors, written) || + std::ranges::any_of(info->image_descriptors, written); } + writes_memory = writes_memory || key.state.xfb_enabled != 0; fragment_has_color0_output = stage_infos[NUM_STAGES - 1].stores_frag_color[0]; DescriptorLayoutBuilder builder{MakeBuilder(device, stage_infos)}; diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.h b/src/video_core/renderer_vulkan/vk_graphics_pipeline.h index 5101db223b..72b1490e8c 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.h +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.h @@ -144,6 +144,12 @@ class GraphicsPipeline { return is_built.load(std::memory_order::relaxed); } + /// Whether its shaders write memory other than render targets: storage buffers, images, + /// global memory or transform feedback buffers. + [[nodiscard]] bool WritesMemory() const noexcept { + return writes_memory; + } + template static auto MakeConfigureSpecFunc() { return [](GraphicsPipeline* pl, bool is_indexed) { return pl->ConfigureImpl(is_indexed); }; @@ -191,6 +197,7 @@ class GraphicsPipeline { size_t num_image_elements{}; u32 num_textures{}; bool fragment_has_color0_output{}; + bool writes_memory{}; vk::DescriptorSetLayout descriptor_set_layout; DescriptorAllocator descriptor_allocator; diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index ad8c212d4f..85b125652f 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1128,6 +1128,13 @@ GraphicsPipeline* PipelineCache::BuiltPipeline(GraphicsPipeline* pipeline) const if (!use_asynchronous_shaders) { return pipeline; } + // Draws that write memory wait for their pipeline (in the Vulkan worker) instead of being + // skipped: a skipped one left buffers later passes read (culling results, indirect arguments, + // counters) stale, and Mario Kart World then faulted the GPU (CP opcode errors on Turnip and + // Qualcomm's driver alike, never with asynchronous shaders off). + if (pipeline->WritesMemory()) { + return pipeline; + } return CanSkipDraw() ? nullptr : pipeline; } From 35330153a24abe033d282e86a03fabac62847ecb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:02:18 -0400 Subject: [PATCH 036/154] vulkan: hand recorded draws to the worker every 32 draws on Android Android dispatched the recorded commands to the Vulkan worker every 4 draws, and each dispatch woke the worker thread: 5.6% of Mario Kart World's GPU back thread went to those wake-ups. Dispatch every 32 draws as on desktop. Submissions stay every 512 draws. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit fea7b2161ed415b9ccd678fc7e542bee855ca034) --- src/video_core/renderer_vulkan/vk_rasterizer.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 13d6a2e589..2a77bdb9b6 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1545,8 +1545,10 @@ void RasterizerVulkan::LoadDiskResources(u64 title_id, std::stop_token stop_load void RasterizerVulkan::FlushWork() { #ifdef __ANDROID__ + // Dispatched every 32 draws (was 4): waking the worker cost 5.6% of the GPU back thread in + // Mario Kart World. Submitted every 512 still, to keep a phone GPU fed. static constexpr u32 DRAWS_TO_DISPATCH = 512; - static constexpr u32 CHECK_MASK = 3; + static constexpr u32 CHECK_MASK = 31; #else // Every 32 draws (was 8): each dispatch woke the worker thread, and Switch 2 titles make // ~55,000 draws a second. A full chunk is dispatched on its own (Scheduler::Record). From 5bcfe3c90278833992df020e858baeeca9520beb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:21:11 -0400 Subject: [PATCH 037/154] buffer_cache: flush the 64 KiB around a macro's memory read Each buffer download waits for the whole GPU. Mario Kart World's macros read the indirect draw arguments a culling dispatch wrote a few words at a time, and each new page they reached waited again: 12.6% of the GPU back thread. A macro read (MME_DMA_READ) now downloads the GPU-written data of the same buffer in the 64 KiB around it, so the reads after it find their words already flushed. Other flushes are unchanged. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit f96ad7ce8e0c07e93b8e461c27716659326650e5) --- src/video_core/buffer_cache/buffer_cache.h | 16 ++++++++++++++-- src/video_core/buffer_cache/buffer_cache_base.h | 3 ++- src/video_core/cache_types.h | 3 +++ src/video_core/engines/maxwell_3d.cpp | 4 +++- src/video_core/macro.cpp | 4 +++- src/video_core/renderer_vulkan/vk_rasterizer.cpp | 3 ++- 6 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index af7e8e5db6..c10ac8d10b 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -262,9 +262,21 @@ std::optional BufferCache

::GetFlushArea(DA } template -void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { +void BufferCache

::DownloadMemory(DAddr device_addr, u64 size, bool around) { ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { - DownloadBufferMemory(buffer, device_addr, size); + if (!around) { + DownloadBufferMemory(buffer, device_addr, size); + return; + } + // Each download waits for the whole GPU. Mario Kart World's macros read the indirect + // draw arguments a culling dispatch wrote a few words at a time, and each new page waited + // again; one download of the window serves the reads after it. + constexpr u64 Window = u64{64} << 10; + const DAddr begin = + (std::max)(Common::AlignDown(device_addr, Window), buffer.CpuAddr()); + const DAddr end = (std::min)(Common::AlignUp(device_addr + size, Window), + buffer.CpuAddr() + buffer.SizeBytes()); + DownloadBufferMemory(buffer, begin, end - begin); }); } diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 5ad051191c..72ff27e921 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -247,7 +247,8 @@ class BufferCache : public VideoCommon::ChannelSetupCaches GetFlushArea(DAddr device_addr, u64 size); diff --git a/src/video_core/cache_types.h b/src/video_core/cache_types.h index 1a5db3c552..fbce53ad53 100644 --- a/src/video_core/cache_types.h +++ b/src/video_core/cache_types.h @@ -14,6 +14,9 @@ enum class CacheType : u32 { QueryCache = 1 << 1, BufferCache = 1 << 2, ShaderCache = 1 << 3, + /// With BufferCache: a buffer download also takes the GPU-written data around the range in + /// the same buffer (it waits for the GPU either way), for reads of the neighbouring words. + BufferCacheAround = 1 << 4, NoTextureCache = QueryCache | BufferCache | ShaderCache, NoBufferCache = TextureCache | QueryCache | ShaderCache, NoQueryCache = TextureCache | BufferCache | ShaderCache, diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index c4797f8d82..911c19f732 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -1171,7 +1171,9 @@ void Maxwell3D::FrontDmaRead(VideoCommon::GpuSplit& split, FrontState& fs, u32 m VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::DmaReadBytes, read_bytes); } if (memory_manager.IsMemoryDirty(source, read_bytes, VideoCommon::CacheType::BufferCache)) { - memory_manager.FlushRegion(source, read_bytes, VideoCommon::CacheType::BufferCache); + memory_manager.FlushRegion(source, read_bytes, + VideoCommon::CacheType::BufferCache | + VideoCommon::CacheType::BufferCacheAround); } for (u32 i = 0; i < words; ++i) { const u32 word = memory_manager.Read(source + u64{i} * sizeof(u32)); diff --git a/src/video_core/macro.cpp b/src/video_core/macro.cpp index 7d0b7afad0..2d1dad125a 100644 --- a/src/video_core/macro.cpp +++ b/src/video_core/macro.cpp @@ -1433,7 +1433,9 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u } } if (memory.IsMemoryDirty(source, read_bytes, VideoCommon::CacheType::BufferCache)) { - memory.FlushRegion(source, read_bytes, VideoCommon::CacheType::BufferCache); + memory.FlushRegion(source, read_bytes, + VideoCommon::CacheType::BufferCache | + VideoCommon::CacheType::BufferCacheAround); static u64 flushes{}; if (++flushes % 2000 == 1) { LOG_INFO(HW_GPU, "Native MME DMA read flushed GPU-written memory ({} times)", flushes); diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 2a77bdb9b6..29a7c6d3be 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1154,7 +1154,8 @@ void RasterizerVulkan::FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType } if ((True(which & VideoCommon::CacheType::BufferCache))) { std::scoped_lock lock{buffer_cache.mutex}; - buffer_cache.DownloadMemory(addr, size); + buffer_cache.DownloadMemory(addr, size, + True(which & VideoCommon::CacheType::BufferCacheAround)); } if ((True(which & VideoCommon::CacheType::QueryCache))) { query_cache.FlushRegion(addr, size); From f6e1d88f1c84e1e75b6e0a7f9576e1f50429442f Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:21:12 -0400 Subject: [PATCH 038/154] buffer_cache: skip synchronizing ranges with no CPU writes since Every bind walked the CPU write tracking of its range for pages to upload, mostly finding none: 5% of Mario Kart World's GPU back thread. Each 4 MiB tracking region now counts the times its pages are marked CPU modified. SynchronizeBuffer remembers the ranges it synchronized and the count their region had then, and skips a range while that count holds. Deleting a buffer forgets them all. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 939cd8356d7f84aa260622481526d6c159b17870) --- src/video_core/buffer_cache/buffer_cache.h | 13 +++++++++++++ src/video_core/buffer_cache/buffer_cache_base.h | 10 ++++++++++ src/video_core/buffer_cache/memory_tracker_base.h | 12 ++++++++++++ src/video_core/buffer_cache/word_manager.h | 10 ++++++++++ 4 files changed, 45 insertions(+) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index c10ac8d10b..a63c8a4a49 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -2193,6 +2193,18 @@ void BufferCache

::TouchBuffer(Buffer& buffer, BufferId buffer_id) noexcept { template bool BufferCache

::SynchronizeBuffer(Buffer& buffer, DAddr device_addr, u32 size) { + // Most binds come back to a range nothing was written to since it was synchronized: skip + // walking its pages while its region saw no CPU writes (they took 5% of Mario Kart World's + // GPU back thread). + const u64 generation = memory_tracker.RangeGeneration(device_addr, size); + SyncedRange& synced = synced_ranges[((device_addr >> 8) ^ size) % synced_ranges.size()]; + if (generation != 0 && synced.generation == generation && synced.device_addr == device_addr && + synced.size == size && synced.buffer == &buffer) { + return true; + } + if (generation != 0) { + synced = SyncedRange{&buffer, device_addr, size, generation}; + } #ifdef __ANDROID__ const bool use_optimized_vertex_buffers = Settings::values.use_optimized_vertex_buffers.GetValue(); #else @@ -2514,6 +2526,7 @@ void BufferCache

::DeleteBuffer(BufferId buffer_id, bool do_not_mark) { replace(channel_state->compute_uniform_buffers); replace(channel_state->compute_storage_buffers); + synced_ranges.fill({}); // Mark the whole buffer as CPU written to stop tracking CPU writes if (!do_not_mark) { Buffer& 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 72ff27e921..50db1dfb94 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -563,6 +563,16 @@ class BufferCache : public VideoCommon::ChannelSetupCaches upload_copies; + /// Ranges SynchronizeBuffer left with nothing to upload, at their region's CPU write + /// generation (direct mapped by address and size). + struct SyncedRange { + const Buffer* buffer; + DAddr device_addr; + u32 size; + u64 generation; + }; + std::array synced_ranges{}; + MemoryTracker memory_tracker; Common::RangeSet uncommitted_gpu_modified_ranges; diff --git a/src/video_core/buffer_cache/memory_tracker_base.h b/src/video_core/buffer_cache/memory_tracker_base.h index 8936da023f..f5c8ad60b1 100644 --- a/src/video_core/buffer_cache/memory_tracker_base.h +++ b/src/video_core/buffer_cache/memory_tracker_base.h @@ -77,6 +77,18 @@ class MemoryTrackerBase { }); } + /// The CPU write generation of the region holding the range (see WordManager), or 0 when + /// the range spans regions or has none yet. A range synchronized at one generation has + /// nothing new to upload while its region's generation stays the same. + [[nodiscard]] u64 RangeGeneration(VAddr query_cpu_addr, u64 query_size) const noexcept { + const u64 page = query_cpu_addr >> HIGHER_PAGE_BITS; + if (query_size == 0 || page != (query_cpu_addr + query_size - 1) >> HIGHER_PAGE_BITS || + page >= NUM_HIGH_PAGES || top_tier[page] == nullptr) { + return 0; + } + return top_tier[page]->CpuGeneration(); + } + /// Unmark region as CPU modified, notifying the device_tracker about this change void UnmarkRegionAsCpuModified(VAddr dirty_cpu_addr, u64 query_size) { IteratePages(dirty_cpu_addr, query_size, [](Manager* manager, u64 offset, size_t size) { diff --git a/src/video_core/buffer_cache/word_manager.h b/src/video_core/buffer_cache/word_manager.h index 76d608bbe0..05989d87c7 100644 --- a/src/video_core/buffer_cache/word_manager.h +++ b/src/video_core/buffer_cache/word_manager.h @@ -51,6 +51,10 @@ struct WordManager { } explicit WordManager() = default; + [[nodiscard]] u64 CpuGeneration() const noexcept { + return cpu_generation; + } + static u64 ExtractBits(u64 word, size_t page_start, size_t page_end) { constexpr size_t number_bits = sizeof(u64) * 8; const size_t limit_page_end = number_bits - (std::min)(page_end, number_bits); @@ -116,6 +120,9 @@ struct WordManager { /// @param dirty_addr Base address to mark or unmark as modified /// @param size Size in bytes to mark or unmark as modified void ChangeRegionState(Type type, bool enable, u64 dirty_addr, u64 size) noexcept { + if (enable && type == Type::CPU) { + ++cpu_generation; + } std::span state_words = Span(type); [[maybe_unused]] std::span untracked_words = Span(Type::Untracked); [[maybe_unused]] std::span cached_words = Span(Type::CachedCPU); @@ -253,6 +260,7 @@ struct WordManager { CollectChangedRanges(false, word_index, untracked_words[word_index], cached_bits, ranges); untracked_words[word_index] |= cached_bits; cpu_words[word_index] |= cached_bits; + cpu_generation += cached_bits != 0 ? 1 : 0; cached_words[word_index] = 0; } if (!ranges.empty()) { @@ -322,6 +330,8 @@ struct WordManager { std::array heap = {}; DeviceTracker* tracker = nullptr; VAddr cpu_addr = 0; + /// Counts the times pages were marked CPU modified (MemoryTrackerBase::RangeGeneration). + u64 cpu_generation = 1; }; } // namespace VideoCommon From f89a79a8c90cdcea7bc499f11f6980cb00b44b64 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:10:23 -0400 Subject: [PATCH 039/154] macro: compile Turing macros to ARM64 Switch 2 titles on Android interpreted every MME macro call: the compiled macro code (macro_turing_jit.h) is x86-64 only, so ARM64 ran the macro memo and the interpreter on the GPU thread for the ~1M calls a second of a 3D scene. macro_turing_jit_arm64.h ports TuringMacroJit to ARM64 with oaknut, op by op and with the same interface and semantics: State layout, callbacks, errors, the END delay slot, loops, carry, predication, lane 1's operands read before lane 0 writes, the instruction limit counted on indirect jumps. The code goes into its own pages, written and then made read-only and executable with the instruction cache invalidated (macro_turing_jit_arm64.cpp). Conditional jumps to the shared exits go through stubs placed every 256 KiB, as B.cond reaches only 1 MiB (all 4096 instructions take ~660 KiB). 3D and compute macros use it like the x86-64 code; EDEN_NX2_MME_JIT=0 still goes back to the interpreter. Nobody can debug it on a phone, so it checks itself: after a compile, the first 64 calls of each entry also run the interpreter on a copy of their inputs. Only the compiled code calls out; the interpreter replays the register reads, and the FIFO and data RAM words the emits brought in, and every emit, read, the result, FIFO use and the data RAM are compared. A difference is logged once (with the call's parameters and code) and that program goes back to the memo and the interpreter. Calls the compiled code runs differently on purpose (never-written registers read as 0, the jump-counted limit, a failing instruction reporting another lane's error) are not compared. Checked offline: the ARM64 code, run in Unicorn, matched the x86-64 code run natively on 24,720 calls of 1500 random programs, including 4096-instruction ones (final State, emits, reads, FIFO, data RAM, callee-saved registers, stack), with caller-saved registers and the upper bits of callback results trashed at every call; six injected codegen bugs were each caught. The self-check, run on the host against the x86-64 code, matched on 9,095 checked calls and caught injected data RAM, FIFO, emit and read differences. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 9c110d38936a666c9c835e7183b5f9b8f1ad4323) --- src/video_core/CMakeLists.txt | 6 + src/video_core/engines/kepler_compute.cpp | 10 +- src/video_core/macro.cpp | 13 +- src/video_core/macro.h | 3 +- src/video_core/macro_turing_jit.h | 5 + src/video_core/macro_turing_jit_arm64.cpp | 51 + src/video_core/macro_turing_jit_arm64.h | 1082 +++++++++++++++++++++ 7 files changed, 1164 insertions(+), 6 deletions(-) create mode 100644 src/video_core/macro_turing_jit_arm64.cpp create mode 100644 src/video_core/macro_turing_jit_arm64.h diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index d23d3eb9b5..ae1aa84d0a 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -500,6 +500,12 @@ if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64) target_link_libraries(video_core PRIVATE dynarmic::dynarmic) endif() +if (ARCHITECTURE_arm64) + # The Turing macro JIT's ARM64 emitter (macro_turing_jit_arm64.h). + target_sources(video_core PRIVATE macro_turing_jit_arm64.cpp macro_turing_jit_arm64.h) + target_link_libraries(video_core PRIVATE merry::oaknut) +endif() + if (ANDROID AND ARCHITECTURE_arm64) target_link_libraries(video_core PRIVATE adrenotools) endif() diff --git a/src/video_core/engines/kepler_compute.cpp b/src/video_core/engines/kepler_compute.cpp index dd7a3017ec..3daf8c642a 100644 --- a/src/video_core/engines/kepler_compute.cpp +++ b/src/video_core/engines/kepler_compute.cpp @@ -31,6 +31,9 @@ #ifdef ARCHITECTURE_x86_64 #include "video_core/macro_turing_jit.h" #endif +#ifdef ARCHITECTURE_arm64 +#include "video_core/macro_turing_jit_arm64.h" +#endif #include "video_core/rasterizer_interface.h" #include "video_core/textures/decoders.h" @@ -180,8 +183,9 @@ bool KeplerCompute::ProcessNativeMethod(Core::System& system, u32 method, u32 ar CallMethod(system, address, value, true); return true; }; -#ifdef ARCHITECTURE_x86_64 - // Compiled macro code, as for 3D macros (macro_turing_jit.h); EDEN_NX2_MME_JIT=0 off. +#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) + // Compiled macro code, as for 3D macros (macro_turing_jit.h, macro_turing_jit_arm64.h); + // EDEN_NX2_MME_JIT=0 off. static const bool use_jit = [] { const char* value = std::getenv("EDEN_NX2_MME_JIT"); return value == nullptr || value[0] != '0'; @@ -192,6 +196,8 @@ bool KeplerCompute::ProcessNativeMethod(Core::System& system, u32 method, u32 ar } native_macro->native_jit->Prepare(native_macro->native_code, native_macro->native_code_generation); + } + if (use_jit && !watched && native_macro->native_jit->Usable()) { const auto result = native_macro->native_jit->Execute( start, native_parameters, std::span{*native_macro->native_interpreter.active_dram}, diff --git a/src/video_core/macro.cpp b/src/video_core/macro.cpp index 2d1dad125a..b92061c05b 100644 --- a/src/video_core/macro.cpp +++ b/src/video_core/macro.cpp @@ -19,6 +19,9 @@ #include "common/x64/xbyak.h" #include "video_core/macro_turing_jit.h" #endif +#ifdef ARCHITECTURE_arm64 +#include "video_core/macro_turing_jit_arm64.h" +#endif #include "common/assert.h" #include "common/scope_exit.h" @@ -1468,9 +1471,11 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u } return true; }; -#ifdef ARCHITECTURE_x86_64 - // Macro code runs compiled to x86-64 (macro_turing_jit.h) instead of through the memo - // and the interpreter; EDEN_NX2_MME_JIT=0 goes back to those. +#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) + // Macro code runs compiled to x86-64 or ARM64 (macro_turing_jit.h, + // macro_turing_jit_arm64.h) instead of through the memo and the interpreter; + // EDEN_NX2_MME_JIT=0 goes back to those, as does ARM64 code that did not match the + // interpreter on its checked calls. static const bool use_jit = [] { const char* value = std::getenv("EDEN_NX2_MME_JIT"); return value == nullptr || value[0] != '0'; @@ -1480,6 +1485,8 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u native_jit = std::make_shared(); } native_jit->Prepare(native_code, native_code_generation); + } + if (use_jit && native_jit->Usable()) { fifo.assign(parameters.begin(), parameters.end()); const auto result = native_jit->Execute( method, fifo, std::span{*native_interpreter.active_dram}, diff --git a/src/video_core/macro.h b/src/video_core/macro.h index 0d574636e9..3840394a67 100644 --- a/src/video_core/macro.h +++ b/src/video_core/macro.h @@ -261,7 +261,8 @@ struct MacroEngine { // Calls replayed instead of interpreted (macro_memo.h), and what the current call wrote. u64 native_code_generation{}; BasicMacroMemo<::Common::unordered_map> native_memo; - /// The macro code compiled to x86-64 (EDEN_NX2_MME_JIT=0 turns it off), created on first use. + /// The macro code compiled to x86-64 or ARM64 (EDEN_NX2_MME_JIT=0 turns it off), created on + /// first use. std::shared_ptr native_jit; MacroMemoPairs native_writes; u64 native_calls{}; diff --git a/src/video_core/macro_turing_jit.h b/src/video_core/macro_turing_jit.h index b57ba8c377..d0b591a4f5 100644 --- a/src/video_core/macro_turing_jit.h +++ b/src/video_core/macro_turing_jit.h @@ -99,6 +99,11 @@ class TuringMacroJit { compiled_generation = generation; } + /// Always true here; the ARM64 port (macro_turing_jit_arm64.h) can give up on a program. + bool Usable() const { + return true; + } + template Result Execute(Word start, std::vector& parameters, std::span data_ram, Read read, Emit emit) { diff --git a/src/video_core/macro_turing_jit_arm64.cpp b/src/video_core/macro_turing_jit_arm64.cpp new file mode 100644 index 0000000000..59296e7dee --- /dev/null +++ b/src/video_core/macro_turing_jit_arm64.cpp @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#include "video_core/macro_turing_jit_arm64.h" + +namespace Tegra { + +void* TuringMacroJit::AllocateCode(std::size_t bytes) { +#ifdef _WIN32 + return VirtualAlloc(nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); +#else + void* const memory = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + return memory == MAP_FAILED ? nullptr : memory; +#endif +} + +bool TuringMacroJit::SealCode(void* memory, std::size_t bytes) { +#ifdef _WIN32 + DWORD old_protection{}; + if (!VirtualProtect(memory, bytes, PAGE_EXECUTE_READ, &old_protection)) { + return false; + } + return FlushInstructionCache(GetCurrentProcess(), memory, bytes) != 0; +#else + if (mprotect(memory, bytes, PROT_READ | PROT_EXEC) != 0) { + return false; + } + char* const begin = static_cast(memory); + __builtin___clear_cache(begin, begin + bytes); + return true; +#endif +} + +void TuringMacroJit::FreeCode(void* memory, std::size_t bytes) { +#ifdef _WIN32 + VirtualFree(memory, 0, MEM_RELEASE); +#else + munmap(memory, bytes); +#endif +} + +} // namespace Tegra diff --git a/src/video_core/macro_turing_jit_arm64.h b/src/video_core/macro_turing_jit_arm64.h new file mode 100644 index 0000000000..102161663b --- /dev/null +++ b/src/video_core/macro_turing_jit_arm64.h @@ -0,0 +1,1082 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "common/logging.h" +#include "video_core/macro_turing.h" + +namespace Tegra { + +// ARM64 code for the TU104/GA10x MME, the port of the x86-64 TuringMacroJit (macro_turing_jit.h) +// with the same interface and semantics: every instruction of the uploaded macro memory compiled +// once (each a block that falls through to the next one, branches and loops through a jump +// table), emits (method writes) and register reads calling back into C++. +// +// Nobody can step through this code on a phone, so it checks itself: after each compile, the +// first CheckedCalls calls of each entry point also run TuringMacroInterpreter, which replays what +// the compiled code saw (register reads, the FIFO words and data RAM words its emits added) +// instead of calling out, and every emit, read, the result and the data RAM are compared. A +// difference is logged once and the program goes back to the interpreter (Usable() is false). +class TuringMacroJit { +public: + using Word = std::uint32_t; + using Result = TuringMacroInterpreter::Result; + + struct State { + std::array regs; + Word carry; + Word method; + Word increment; + Word loop_count; + Word loop_start; + Word loop_end; + Word next_pc; + Word steps; + Word start; + Word consumed; + Word params_size; + Word fail_pc; + std::uint8_t end_next; + std::uint8_t stopped; + std::uint8_t cancel_end; + std::uint8_t pad; + Word scratch_x; + Word scratch_y; + const Word* params; + Word* dram; + void* user; + bool (*emit)(State*, Word method, Word value); + Word (*read)(State*, Word address); + const std::vector* fifo; + }; + + enum Error : Word { + None = 0, + FifoUnderflow, + InvalidSource, + InvalidDestination, + UnsupportedExtended, + InvalidMerge, + InvalidLoop, + DataRamReadOutOfBounds, + DataRamWriteOutOfBounds, + InvalidOutput, + UnsupportedEmit, + OutsideCode, + StepLimit, + }; + + static std::string_view ErrorText(Word error) { + constexpr std::array texts{ + "", + "parameter FIFO underflow", + "invalid source register", + "invalid destination register", + "unsupported extended operation", + "invalid merge destination", + "invalid loop", + "data RAM read out of bounds", + "data RAM write out of bounds", + "invalid output selector", + "unsupported emitted method", + "instruction outside uploaded RAM", + "instruction limit exceeded", + }; + return error < texts.size() ? texts[error] : "unknown"; + } + + /// Calls of each entry point that also run the interpreter after a compile. + static constexpr std::uint8_t CheckedCalls = 64; + + /// Compiles `code` (3 words per instruction) when it differs from what was compiled. + void Prepare(std::span code, std::uint64_t generation) { + if (generation == compiled_generation) { + return; + } + compiled_generation = generation; + program.reset(); + diverged = false; + try { + program = std::make_unique(code); + } catch (const std::exception& exception) { + if (!compile_failure_logged.exchange(true)) { + LOG_ERROR(HW_GPU, "Turing macro JIT: could not compile the macro code to ARM64 ({}); " + "it runs in the interpreter", exception.what()); + } + return; + } + compiled_code.assign(code.begin(), code.end()); + checks_left.assign(program->instruction_count + 1, CheckedCalls); + if (!active_logged.exchange(true)) { + LOG_INFO(HW_GPU, "Turing macro JIT: macro code compiled to ARM64 ({} instructions, " + "{} KiB); the first {} calls of each entry are checked against the " + "interpreter", + program->instruction_count, program->code_bytes / 1024, CheckedCalls); + } + } + + /// False when the code could not be compiled or differed from the interpreter: the caller + /// then runs the macro memo and the interpreter. + bool Usable() const { + return program != nullptr && !diverged; + } + + template + Result Execute(Word start, std::vector& parameters, std::span data_ram, Read read, + Emit emit) { + if (!program) [[unlikely]] { + return {false, 0, 0, ErrorText(OutsideCode)}; + } + std::uint8_t& left = checks_left[(std::min)(start, checks_left.size() - 1)]; + if (left != 0) [[unlikely]] { + --left; + return ExecuteChecked(start, parameters, data_ram, read, emit); + } + return Run(start, parameters, data_ram, read, emit); + } + + /// The code for `code` without its jump table (see Relocate), for tests. + struct Assembly { + std::vector words; + std::size_t table_offset{}; + std::size_t entry_offset{}; + /// Byte offset of each instruction's code. + std::vector targets; + }; + + static Assembly Assemble(std::span code) { + return Assembler{code}.Finish(); + } + + /// Writes the jump table of `assembly`, copied to `words`, for code placed at `base`. + static void Relocate(std::span words, const Assembly& assembly, + std::uintptr_t base) { + for (std::size_t pc = 0; pc < assembly.targets.size(); ++pc) { + const std::uint64_t address = std::uint64_t{base} + assembly.targets[pc]; + const std::size_t index = assembly.table_offset / sizeof(std::uint32_t) + pc * 2; + words[index] = static_cast(address); + words[index + 1] = static_cast(address >> 32); + } + } + +private: + using Decoded = TuringMacroInterpreter::Decoded; + + template + struct Thunks { + Read& read; + Emit& emit; + static bool CallEmit(State* state, Word method, Word value) { + const bool ok = static_cast(state->user)->emit(method, value); + // MME_DMA_READ_FIFOED appends to the FIFO from inside the emit. + state->params = state->fifo->data(); + state->params_size = static_cast(state->fifo->size()); + return ok; + } + static Word CallRead(State* state, Word address) { + return static_cast(state->user)->read(address); + } + }; + + template + Result Run(Word start, std::vector& parameters, std::span data_ram, Read& read, + Emit& emit) { + State state; + state.regs.fill(0); + state.carry = state.method = state.increment = 0; + state.loop_count = state.loop_start = state.loop_end = 0; + state.consumed = 0; + state.fail_pc = 0; + state.end_next = state.stopped = state.cancel_end = 0; + state.start = start; + state.steps = 1000000; + state.params = parameters.data(); + state.params_size = static_cast(parameters.size()); + state.dram = data_ram.data(); + state.fifo = ¶meters; + Thunks thunks{read, emit}; + state.user = &thunks; + state.emit = &Thunks::CallEmit; + state.read = &Thunks::CallRead; + const Word error = program->entry(&state); + if (error == None) { + return {true, state.fail_pc, state.consumed, {}}; + } + return {false, state.fail_pc, state.consumed, ErrorText(error)}; + } + + struct AssemblerBuffer { + std::vector words; + }; + + // Register use: x19 State*; w20/w21 the instruction's FIFO loads; w22/w23 its ALU results; + // x24 the jump table. w0 (value), w1 (x) and w2 (y) as eax/ecx/edx in the x86-64 code; + // x16/x17 scratch. Callee-saved x19-x24, x29 and x30 are kept on a 64-byte frame, so sp stays + // 16-byte aligned at the callbacks. + class Assembler : private AssemblerBuffer, public oaknut::VectorCodeGenerator { + public: + explicit Assembler(std::span code) : oaknut::VectorCodeGenerator(words) { + Generate(code); + } + + Assembly Finish() { + Assembly assembly; + assembly.table_offset = table_offset; + assembly.entry_offset = entry_offset; + assembly.targets.reserve(labels.size()); + for (const oaknut::Label& label : labels) { + assembly.targets.push_back(static_cast(label.offset())); + } + assembly.words = std::move(words); + return assembly; + } + + private: + using Cond = oaknut::Cond; + using WReg = oaknut::WReg; + using XReg = oaknut::XReg; + using Label = oaknut::Label; + + static constexpr WReg W0{0}, W1{1}, W2{2}, W16{16}, W17{17}; + static constexpr XReg X0{0}, X1{1}, X16{16}, X19{19}, X20{20}, X21{21}, X22{22}, X23{23}, + X24{24}, X29{29}, X30{30}; + static constexpr oaknut::WzrReg WZR{}; + static constexpr oaknut::SpReg sp{}; + static constexpr XReg st{19}; + static constexpr WReg load0{20}, load1{21}, alu0{22}, alu1{23}; + static constexpr XReg table{24}; + + static constexpr std::size_t RegOffset(Word reg) { + return offsetof(State, regs) + reg * sizeof(Word); + } + + static WReg Alu(Word lane) { + return lane == 0 ? alu0 : alu1; + } + + void Generate(std::span code) { + const Word count = static_cast(code.size() / 3); + labels.resize(count); + std::vector decoded(count); + for (Word pc = 0; pc < count; ++pc) { + decoded[pc] = TuringMacroInterpreter::Decode( + {code[pc * 3 + 2], code[pc * 3 + 1], code[pc * 3]}); + } + Analyze(decoded); + + // The jump table comes first (Relocate fills it), within ADR range of the entry. + table_offset = static_cast(offset()); + l(table_label); + for (Word pc = 0; pc < count; ++pc) { + dx(0); + } + + entry_offset = static_cast(offset()); + STP(X29, X30, sp, oaknut::PreIndexed{}, -64); + MOV(X29, sp); + STP(X19, X20, sp, 16); + STP(X21, X22, sp, 32); + STP(X23, X24, sp, 48); + MOV(st, X0); + ADR(table, table_label); + LDR(W0, st, offsetof(State, start)); + CmpImm(W0, count); + B(Cond::HS, Far(fail_outside)); + LDR(X16, table, X0, oaknut::IndexExt::LSL, 3); + BR(X16); + + for (Word pc = 0; pc < count; ++pc) { + // B.cond and CBZ reach 1 MiB: out-of-line jumps to the shared labels go through + // stubs placed every 256 KiB. + if (!stubs.empty() && static_cast(offset()) - window_start > 256 * 1024) { + FlushStubs(true); + } + l(labels[pc]); + Instruction(pc, count, decoded[pc]); + } + // Every instruction ends with a jump. + FlushStubs(false); + + // Dispatch on w0 (the next pc). Every backward edge comes through here, so the + // instruction limit is counted here. + l(dispatch); + LDR(W16, st, offsetof(State, steps)); + SUBS(W16, W16, 1); + STR(W16, st, offsetof(State, steps)); + B(Cond::EQ, fail_steps); + CmpImm(W0, count); + B(Cond::HS, fail_outside); + LDR(X16, table, X0, oaknut::IndexExt::LSL, 3); + BR(X16); + + l(success); + MOV(W0, WZR); + l(leave); + LDP(X23, X24, sp, 48); + LDP(X21, X22, sp, 32); + LDP(X19, X20, sp, 16); + LDP(X29, X30, sp, oaknut::PostIndexed{}, 64); + RET(); + const auto fail_with = [&](Label& label, Word error) { + l(label); + MOV(W0, error); + B(leave); + }; + fail_with(fail_underflow, FifoUnderflow); + fail_with(fail_source, InvalidSource); + fail_with(fail_destination, InvalidDestination); + fail_with(fail_extended, UnsupportedExtended); + fail_with(fail_merge, InvalidMerge); + fail_with(fail_loop, InvalidLoop); + fail_with(fail_dread, DataRamReadOutOfBounds); + fail_with(fail_dwrite, DataRamWriteOutOfBounds); + fail_with(fail_output, InvalidOutput); + fail_with(fail_emit, UnsupportedEmit); + fail_with(fail_outside, OutsideCode); + fail_with(fail_steps, StepLimit); + } + + // A label near the current position that jumps to `target` (placed by FlushStubs). + Label& Far(Label& target) { + for (const auto& stub : stubs) { + if (stub->target == &target) { + return stub->label; + } + } + if (stubs.empty()) { + window_start = static_cast(offset()); + } + stubs.push_back(std::make_unique()); + stubs.back()->target = ⌖ + return stubs.back()->label; + } + + void FlushStubs(bool reachable) { + if (stubs.empty()) { + return; + } + Label over; + if (reachable) { + B(over); + } + for (const auto& stub : stubs) { + l(stub->label); + B(*stub->target); + } + if (reachable) { + l(over); + } + stubs.clear(); + } + + void CmpImm(WReg reg, Word value) { + if (value < 4096) { + CMP(reg, value); + } else { + MOV(W17, value); + CMP(reg, W17); + } + } + + // Loads MME register `reg` (as an operand of `lane`) into `dst`. + void Get(WReg dst, const Decoded& d, Word reg, Word lane) { + if (reg < 24) { + LDR(dst, st, RegOffset(reg)); + return; + } + switch (reg) { + case 24: MOV(dst, WZR); return; + case 25: MOV(dst, Word(std::int32_t(std::int16_t(d.imm[lane])))); return; + case 26: MOV(dst, Word(std::int32_t(std::int16_t(d.imm[1 - lane])))); return; + case 27: MOV(dst, (d.imm[0] << 16) | d.imm[1]); return; + case 28: MOV(dst, load0); return; + case 29: MOV(dst, load1); return; + default: B(fail_source); return; + } + } + + static bool IsFlow(Word op) { + return op >= 23 && op <= 29; + } + + // Where a loop can end (its back edge is checked only there) and which instructions can + // run right after an END (only those check for the end of the macro). + void Analyze(const std::vector& decoded) { + const Word count = static_cast(decoded.size()); + loop_end.assign(count, false); + after_end.assign(count, false); + const auto mark = [&](std::vector& set, std::int64_t pc) { + if (pc >= 0 && pc < count) set[static_cast(pc)] = true; + }; + for (Word pc = 0; pc < count; ++pc) { + for (Word lane = 0; lane < 2; ++lane) { + const Decoded& d = decoded[pc]; + if (d.op[lane] == 23 && d.imm[lane] >= 2 && pc + d.imm[lane] - 1 < count) { + loop_end[pc + d.imm[lane] - 1] = true; + } + } + } + for (Word pc = 0; pc < count; ++pc) { + const Decoded& d = decoded[pc]; + if (!d.end) continue; + mark(after_end, std::int64_t{pc} + 1); + for (Word lane = 0; lane < 2; ++lane) { + const Word imm = d.imm[lane]; + switch (d.op[lane]) { + case 23: mark(after_end, std::int64_t{pc} + imm); break; + case 24: mark(after_end, std::int64_t{pc} + (imm & 0x7fff)); break; + case 25: case 26: case 27: case 28: case 29: + mark(after_end, std::int64_t{pc} + (std::int32_t(imm << 19) >> 19)); + break; + default: break; + } + } + } + // An END at a loop's last instruction can be followed by the loop's second one. + for (Word pc = 0; pc < count; ++pc) { + for (Word lane = 0; lane < 2; ++lane) { + const Decoded& d = decoded[pc]; + if (d.op[lane] == 23 && d.imm[lane] >= 2) { + const Word end = pc + d.imm[lane] - 1; + if (end < count && decoded[end].end) mark(after_end, std::int64_t{pc} + 1); + } + } + } + } + + void Instruction(Word pc, Word count, const Decoded& d) { + // FIFO loads, in lane order. + for (Word i = 0; i < 2; ++i) { + const WReg load = i == 0 ? load0 : load1; + if (!d.load[i]) { + MOV(load, WZR); + continue; + } + LDR(W0, st, offsetof(State, consumed)); + LDR(W16, st, offsetof(State, params_size)); + CMP(W0, W16); + B(Cond::HS, Far(fail_underflow)); + LDR(X16, st, offsetof(State, params)); + LDR(load, X16, X0, oaknut::IndexExt::LSL, 2); + ADD(W0, W0, 1); + STR(W0, st, offsetof(State, consumed)); + } + if (!d.pred_mode) { + Body(pc, count, d, 15, true); + return; + } + Label when_false; + Get(W0, d, d.pred_reg, 0); + CBZ(W0, when_false); + Body(pc, count, d, d.mask_true, false); + l(when_false); + Body(pc, count, d, d.mask_false, true); + } + + // `last`: the code that follows is the next instruction's. + void Body(Word pc, Word count, const Decoded& d, Word mask, bool last) { + // An inactive lane's result reads as 0 when an output selects it. + for (Word lane = 0; lane < 2; ++lane) { + if (mask & (1U << lane)) continue; + bool selected = false; + for (Word out = 0; out < 2; ++out) { + if (!(mask & (4U << out))) continue; + selected |= d.out_method[out] == lane + 1 || d.out_emit[out] == lane + 1; + } + if (selected) { + MOV(Alu(lane), WZR); + } + } + const bool flow = ((mask & 1) && IsFlow(d.op[0])) || ((mask & 2) && IsFlow(d.op[1])); + if (flow) { + MOV(W16, pc + 1); + STR(W16, st, offsetof(State, next_pc)); + STRB(WZR, st, offsetof(State, cancel_end)); + } + // Lane 1's operands are read before lane 0 commits its destination (or calls out). + const bool lane0_active = (mask & 1) != 0; + const bool early_operands = + (mask & 2) && lane0_active && + (d.op[0] == 22 || (d.dst[0] < 24 && (d.dst[0] == d.src_a[1] || d.dst[0] == d.src_b[1]))); + if (early_operands) { + Get(W0, d, d.src_a[1], 1); + STR(W0, st, offsetof(State, scratch_x)); + Get(W0, d, d.src_b[1], 1); + STR(W0, st, offsetof(State, scratch_y)); + } + for (Word lane = 0; lane < 2; ++lane) { + if (!(mask & (1U << lane))) { + continue; + } + if (lane == 1 && early_operands) { + LDR(W1, st, offsetof(State, scratch_x)); + LDR(W2, st, offsetof(State, scratch_y)); + } else { + Get(W1, d, d.src_a[lane], lane); + Get(W2, d, d.src_b[lane], lane); + } + Lane(pc, d, lane); + // w0 holds the lane's value. + MOV(Alu(lane), W0); + if (d.dst[lane] < 24) { + STR(W0, st, RegOffset(d.dst[lane])); + } else if (d.dst[lane] != 24) { + B(fail_destination); + } + } + for (Word lane = 0; lane < 2; ++lane) { + if (!(mask & (4U << lane))) { + continue; + } + if (d.out_method[lane]) { + Output(W0, d, d.out_method[lane]); + AND(W1, W0, 0xfff); + STR(W1, st, offsetof(State, method)); + UBFX(W0, W0, 12, 4); + STR(W0, st, offsetof(State, increment)); + } + if (d.out_emit[lane]) { + Output(W2, d, d.out_emit[lane]); + LDR(W1, st, offsetof(State, method)); + MOV(X0, st); + LDR(X16, st, offsetof(State, emit)); + BLR(X16); + TST(W0, 0xff); + B(Cond::EQ, Far(fail_emit)); + LDR(W0, st, offsetof(State, increment)); + LDR(W1, st, offsetof(State, method)); + ADD(W1, W1, W0); + STR(W1, st, offsetof(State, method)); + } + } + // An END in the previous instruction ends the macro after this one. + if (after_end[pc]) { + LDRB(W16, st, offsetof(State, end_next)); + CBNZ(W16, Far(success)); + } + if (flow) { + LDRB(W16, st, offsetof(State, stopped)); + CBNZ(W16, Far(success)); + } + if (d.end) { + if (flow) { + LDRB(W16, st, offsetof(State, cancel_end)); + EOR(W16, W16, 1); + STRB(W16, st, offsetof(State, end_next)); + } else { + MOV(W16, 1); + STRB(W16, st, offsetof(State, end_next)); + } + } + if (loop_end[pc]) { + Label no_loop; + LDR(W0, st, offsetof(State, loop_count)); + CBZ(W0, no_loop); + LDR(W16, st, offsetof(State, loop_end)); + CmpImm(W16, pc); + B(Cond::NE, no_loop); + SUB(W0, W0, 1); + STR(W0, st, offsetof(State, loop_count)); + LDR(W0, st, offsetof(State, loop_start)); + ADD(W0, W0, 1); + B(dispatch); + l(no_loop); + } + if (flow) { + LDR(W0, st, offsetof(State, next_pc)); + if (pc + 1 < count) { + CmpImm(W0, pc + 1); + B(Cond::NE, Far(dispatch)); + if (!last) { + B(labels[pc + 1]); + } + } else { + B(dispatch); + } + } else if (pc + 1 < count) { + if (!last) { + B(labels[pc + 1]); + } + } else { + B(fail_outside); + } + } + + void Output(WReg dst, const Decoded& d, Word selector) { + switch (selector) { + case 1: MOV(dst, alu0); return; + case 2: MOV(dst, alu1); return; + case 3: MOV(dst, load0); return; + case 4: MOV(dst, load1); return; + case 5: MOV(dst, d.imm[0]); return; + case 6: MOV(dst, d.imm[1]); return; + case 8: MOV(dst, d.imm[0] >> 12); return; + case 9: MOV(dst, d.imm[1] >> 12); return; + case 10: MOV(dst, (d.imm[0] << 16) | d.imm[1]); return; + default: B(fail_output); return; + } + } + + // The condition of comparison `op` (CMP x, y). + static Cond Condition(Word op) { + switch (op) { + case 17: case 25: return Cond::LT; + case 18: case 26: return Cond::LO; + case 19: case 27: return Cond::LE; + case 20: case 28: return Cond::LS; + default: return Cond::EQ; + } + } + + // w1 = x, w2 = y; leaves the value in w0. + void Lane(Word pc, const Decoded& d, Word lane) { + const Word imm = d.imm[lane]; + const Word op = d.op[lane]; + switch (op) { + case 0: + ADDS(W0, W1, W2); + CSET(W16, Cond::CS); + STR(W16, st, offsetof(State, carry)); + return; + case 1: + ADD(W0, W1, W2); + LDR(W16, st, offsetof(State, carry)); + ADD(W0, W0, W16); + return; + case 2: + // The carry is the borrow (x < y): ARM64's C flag is its inverse. + SUBS(W0, W1, W2); + CSET(W16, Cond::LO); + STR(W16, st, offsetof(State, carry)); + return; + case 3: + SUB(W0, W1, W2); + LDR(W16, st, offsetof(State, carry)); + SUB(W0, W0, W16); + return; + case 4: + SMULL(X0, W1, W2); + LSR(X16, X0, 32); + STR(W16, st, offsetof(State, carry)); + MOV(W0, W0); + return; + case 6: + UMULL(X0, W1, W2); + LSR(X16, X0, 32); + STR(W16, st, offsetof(State, carry)); + MOV(W0, W0); + return; + case 5: + LDR(W0, st, offsetof(State, carry)); + return; + case 7: + CmpImm(W1, 0x1000); + B(Cond::NE, Far(fail_extended)); + MOV(W0, WZR); + return; + case 8: + CLZ(W0, W1); + return; + case 9: LSL(W0, W1, W2); return; + case 10: LSR(W0, W1, W2); return; + case 11: ASR(W0, W1, W2); return; + case 12: AND(W0, W1, W2); return; + case 13: AND(W0, W1, W2); MVN(W0, W0); return; + case 14: ORR(W0, W1, W2); return; + case 15: EOR(W0, W1, W2); return; + case 16: { + const Word dest = (imm >> 10) & 63, bit_count = (imm >> 5) & 31; + const Word source = imm & 31; + if (dest >= 32) { + B(fail_merge); + return; + } + const Word bits_mask = (1U << bit_count) - 1; + MOV(W16, ~(bits_mask << dest)); + AND(W0, W1, W16); + LSR(W2, W2, source); + MOV(W16, bits_mask); + AND(W2, W2, W16); + LSL(W2, W2, dest); + ORR(W0, W0, W2); + return; + } + case 17: case 18: case 19: case 20: case 21: + CMP(W1, W2); + CSETM(W0, Condition(op)); + return; + case 22: + ADD(W16, W1, W2); + AND(W1, W16, 0xffff); + MOV(X0, st); + LDR(X16, st, offsetof(State, read)); + BLR(X16); + return; + case 23: { + if (imm < 2) { + B(fail_loop); + return; + } + Label skip, done; + LDR(W16, st, offsetof(State, loop_count)); + CBNZ(W16, Far(fail_loop)); + CBZ(W1, skip); + SUB(W1, W1, 1); + STR(W1, st, offsetof(State, loop_count)); + MOV(W16, pc); + STR(W16, st, offsetof(State, loop_start)); + MOV(W16, pc + imm - 1); + STR(W16, st, offsetof(State, loop_end)); + B(done); + l(skip); + MOV(W16, pc + imm); + STR(W16, st, offsetof(State, next_pc)); + l(done); + MOV(W0, WZR); + return; + } + case 24: + MOV(W16, pc + (imm & 0x7fff)); + STR(W16, st, offsetof(State, next_pc)); + MOV(W0, WZR); + return; + case 25: case 26: case 27: case 28: case 29: { + // Taken when the comparison equals bit 15. + Label not_taken; + CMP(W1, W2); + const Cond condition = Condition(op); + B((imm & 0x8000) ? oaknut::invert(condition) : condition, not_taken); + const auto branch_offset = std::int32_t(imm << 19) >> 19; + if (branch_offset == -4096) { + MOV(W16, 1); + STRB(W16, st, offsetof(State, stopped)); + } else { + MOV(W16, Word(std::int32_t(pc) + branch_offset)); + STR(W16, st, offsetof(State, next_pc)); + if (imm & 0x4000) { + MOV(W16, 1); + STRB(W16, st, offsetof(State, cancel_end)); + } else { + STRB(WZR, st, offsetof(State, cancel_end)); + } + } + l(not_taken); + MOV(W0, WZR); + return; + } + case 30: + CmpImm(W1, 0xc00); + B(Cond::HS, Far(fail_dread)); + LDR(X16, st, offsetof(State, dram)); + LDR(W0, X16, X1, oaknut::IndexExt::LSL, 2); + return; + case 31: + CmpImm(W1, 0xc00); + B(Cond::HS, Far(fail_dwrite)); + LDR(X16, st, offsetof(State, dram)); + STR(W2, X16, X1, oaknut::IndexExt::LSL, 2); + MOV(W0, WZR); + return; + default: + MOV(W0, WZR); + return; + } + } + + struct Stub { + Label label; + Label* target{}; + }; + + std::vector

::GetFlushArea(DA } template -void BufferCache

::DownloadMemory(DAddr device_addr, u64 size, bool around) { +void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { - if (!around) { - DownloadBufferMemory(buffer, device_addr, size); - return; - } // Each download waits for the whole GPU. Mario Kart World's macros read the indirect - // draw arguments a culling dispatch wrote a few words at a time, and each new page waited - // again; one download of the window serves the reads after it. + // draw arguments a culling dispatch wrote a few words at a time, and its CPU reads back + // GPU-written data, each new page waiting again: one download of the window serves the + // reads after it. Early is safe: a CPU write ends the GPU-written state of its range + // (WriteMemory), so newer CPU data is never downloaded over. constexpr u64 Window = u64{64} << 10; const DAddr begin = (std::max)(Common::AlignDown(device_addr, Window), buffer.CpuAddr()); diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 50db1dfb94..e42d9ab6d8 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -247,8 +247,8 @@ class BufferCache : public VideoCommon::ChannelSetupCaches GetFlushArea(DAddr device_addr, u64 size); diff --git a/src/video_core/cache_types.h b/src/video_core/cache_types.h index fbce53ad53..1a5db3c552 100644 --- a/src/video_core/cache_types.h +++ b/src/video_core/cache_types.h @@ -14,9 +14,6 @@ enum class CacheType : u32 { QueryCache = 1 << 1, BufferCache = 1 << 2, ShaderCache = 1 << 3, - /// With BufferCache: a buffer download also takes the GPU-written data around the range in - /// the same buffer (it waits for the GPU either way), for reads of the neighbouring words. - BufferCacheAround = 1 << 4, NoTextureCache = QueryCache | BufferCache | ShaderCache, NoBufferCache = TextureCache | QueryCache | ShaderCache, NoQueryCache = TextureCache | BufferCache | ShaderCache, diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 0af9ffd7fe..dd29d16f9b 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -1183,9 +1183,7 @@ void Maxwell3D::FrontDmaRead(VideoCommon::GpuSplit& split, FrontState& fs, u32 m VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::DmaReadBytes, read_bytes); } if (memory_manager.IsMemoryDirty(source, read_bytes, VideoCommon::CacheType::BufferCache)) { - memory_manager.FlushRegion(source, read_bytes, - VideoCommon::CacheType::BufferCache | - VideoCommon::CacheType::BufferCacheAround); + memory_manager.FlushRegion(source, read_bytes, VideoCommon::CacheType::BufferCache); } for (u32 i = 0; i < words; ++i) { const u32 word = memory_manager.Read(source + u64{i} * sizeof(u32)); diff --git a/src/video_core/gpu.cpp b/src/video_core/gpu.cpp index 6dbabcb43a..29d9742ea7 100644 --- a/src/video_core/gpu.cpp +++ b/src/video_core/gpu.cpp @@ -131,6 +131,7 @@ struct GPU::Impl { std::unique_lock lck{sync_request_mutex}; const u64 fence = ++last_sync_fence; sync_requests.emplace_back(std::forward(action)); + has_sync_requests.store(true, std::memory_order_release); return fence; } @@ -152,7 +153,12 @@ struct GPU::Impl { /// Tick pending requests within the GPU. void TickWork() { + // Runs at every draw: skip the lock while nothing is queued. + if (!has_sync_requests.load(std::memory_order_acquire)) { + return; + } std::unique_lock lck{sync_request_mutex}; + has_sync_requests.store(false, std::memory_order_relaxed); while (!sync_requests.empty()) { auto request = std::move(sync_requests.front()); sync_requests.pop_front(); @@ -356,6 +362,7 @@ struct GPU::Impl { std::condition_variable sync_cv; std::list> sync_requests; + std::atomic has_sync_requests{}; std::atomic current_sync_fence{}; u64 last_sync_fence{}; std::mutex sync_request_mutex; diff --git a/src/video_core/macro.cpp b/src/video_core/macro.cpp index b92061c05b..d7eedb7a94 100644 --- a/src/video_core/macro.cpp +++ b/src/video_core/macro.cpp @@ -1436,9 +1436,7 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u } } if (memory.IsMemoryDirty(source, read_bytes, VideoCommon::CacheType::BufferCache)) { - memory.FlushRegion(source, read_bytes, - VideoCommon::CacheType::BufferCache | - VideoCommon::CacheType::BufferCacheAround); + memory.FlushRegion(source, read_bytes, VideoCommon::CacheType::BufferCache); static u64 flushes{}; if (++flushes % 2000 == 1) { LOG_INFO(HW_GPU, "Native MME DMA read flushed GPU-written memory ({} times)", flushes); diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 29a7c6d3be..2a77bdb9b6 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1154,8 +1154,7 @@ void RasterizerVulkan::FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType } if ((True(which & VideoCommon::CacheType::BufferCache))) { std::scoped_lock lock{buffer_cache.mutex}; - buffer_cache.DownloadMemory(addr, size, - True(which & VideoCommon::CacheType::BufferCacheAround)); + buffer_cache.DownloadMemory(addr, size); } if ((True(which & VideoCommon::CacheType::QueryCache))) { query_cache.FlushRegion(addr, size); From c7b69d843920ab23be1fac61339bce5e519afebf Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:51:31 -0400 Subject: [PATCH 043/154] gpu_split: park the back thread on a futex too The back thread slept in std::atomic::wait. On Android, libc++ spins there with sched_yield before sleeping, and parks waiters in a shared table where a notify could miss this one. It now uses the same futex wait and wake as the front: it sleeps at once, and a missed wake-up costs 2 ms instead of a hang. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 6d9020577765705906e2b622e55e43277b9f2c37) --- src/video_core/gpu_split.cpp | 19 +++++++++++++------ src/video_core/gpu_split.h | 6 +----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index cf4d34da22..3bd0545dbf 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -170,7 +170,7 @@ void GpuSplit::Stop() { } back_thread.request_stop(); wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_all(); + WakeWord(wake); back_thread.join(); active = false; // The split is static and outlives the emulation session. The next session's back thread @@ -225,12 +225,19 @@ void GpuSplit::WaitForSpace(size_t words) { } } +void GpuSplit::RequestIdleHook() { + hook_requested.store(true, std::memory_order_seq_cst); + wake.fetch_add(1, std::memory_order_seq_cst); + // The back parks on a raw futex (BackLoop): a std::atomic notify may not reach it. + WakeWord(wake); +} + void GpuSplit::SleepOnBack() { // Re-wake a back thread whose wake-up was lost (it froze Mario Kart World on Android with the // front waiting here and the back parked). if (back_waiting.load(std::memory_order_seq_cst)) { wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_all(); + WakeWord(wake); } // The back bumps `drained` after storing `consumed` when it sees front_waiting: read it // before the last look at `consumed`, so a store in between ends the wait at once. @@ -251,9 +258,7 @@ void GpuSplit::Publish() { published.store(write_position, std::memory_order_seq_cst); if (back_waiting.load(std::memory_order_seq_cst)) { wake.fetch_add(1, std::memory_order_seq_cst); - // All: libc++ (Android) parks atomic waiters in a shared hash table, where notify_one - // can wake an unrelated waiter on the same slot and leave the back thread asleep. - wake.notify_all(); + WakeWord(wake); } } @@ -450,7 +455,9 @@ void GpuSplit::BackLoop(std::stop_token stop_token) { // A request made after the hook ran (RequestIdleHook) runs it before sleeping. if (available == read_position && !stop_token.stop_requested() && !hook_requested.load(std::memory_order_seq_cst)) { - wake.wait(seen, std::memory_order_seq_cst); + // A futex, not std::atomic::wait: libc++ spins with sched_yield first, + // and parks waiters in a shared table where a notify could miss this one. + WaitWord(wake, seen, std::chrono::milliseconds{2}); } back_waiting.store(false, std::memory_order_relaxed); continue; diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index 5f1c0e7e0e..cc4330ace5 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -60,11 +60,7 @@ class GpuSplit { return back_running.load(std::memory_order_acquire); } /// Any thread: has the back call the idle hook soon, between entries or at once if it sleeps. - void RequestIdleHook() { - hook_requested.store(true, std::memory_order_seq_cst); - wake.fetch_add(1, std::memory_order_seq_cst); - wake.notify_one(); - } + void RequestIdleHook(); /// Starts the back thread once the split is wanted (EDEN_NX2_SPLIT=1, a Switch 2 title, /// commands read without cache flushes). Called by the front between commands. From 8eb764b37dd847c87c6f3dec2d3b88e32187e7c4 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:01:43 -0400 Subject: [PATCH 044/154] core: set environment variables from an env.txt The EDEN_NX2_* debugging switches are environment variables, which Android has no way to set. The KEY=VALUE lines of an env.txt in the user directory now become environment variables when a program loads. Each switch is read on first use, so a change takes an app restart. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 9c6374b0ef12816d54ba6dd9889b4765fb5a4f9e) --- src/core/core.cpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index 364f9a9fce..0efed6e353 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -369,6 +371,28 @@ struct System::Impl { Settings::values.memory_layout_mode.SetGlobal(false); Settings::values.memory_layout_mode.SetValue(Settings::MemoryLayout::Memory_12Gb); } + // The KEY=VALUE lines of an env.txt in the user directory become environment variables: + // the EDEN_NX2_* switches, which Android has no other way to set. Each is read the first + // time it is used, so a change takes a restart of the app. + if (std::ifstream env{Common::FS::GetEdenPath(Common::FS::EdenPath::EdenDir) / "env.txt"}) { + for (std::string line; std::getline(env, line);) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) { + line.pop_back(); + } + const size_t equals = line.find('='); + if (line.empty() || line[0] == '#' || equals == std::string::npos || equals == 0) { + continue; + } + const std::string key = line.substr(0, equals); + const std::string value = line.substr(equals + 1); +#ifdef _WIN32 + _putenv_s(key.c_str(), value.c_str()); +#else + setenv(key.c_str(), value.c_str(), 1); +#endif + LOG_INFO(Core, "env.txt: {}={}", key, value); + } + } // A gpu_profile file in the user directory turns the GPU threads' profile on: their time // per zone every 10 s in the log (EDEN_NX2_GPU_PROFILE, for Android). if (Common::FS::Exists(Common::FS::GetEdenPath(Common::FS::EdenPath::EdenDir) / From 8df24aeb43eb413ebe7e5c458bab1f0a11eb19c6 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:12:11 -0400 Subject: [PATCH 045/154] gpu_split: the owner check on Android too EDEN_NX2_SPLIT_CHECK reports a thread other than the back using the Vulkan scheduler while the back runs entries: two threads recording commands at once. It only worked on Windows. Linux and Android now compare thread ids too, and log the thread's name and a stack of module offsets. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 90455c57654b5cf348c4867ec2831f07ed6fe329) --- src/video_core/gpu_split.cpp | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 3bd0545dbf..a9bd10c0f1 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -2,8 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include +#include #include #include +#include #include #ifdef _MSC_VER @@ -18,7 +20,10 @@ #elif defined(__linux__) #include #include +#include #include +#include +#include #include #include #endif @@ -117,6 +122,48 @@ void GpuSplit::CheckOwner(const char* where) { reinterpret_cast(module))); } std::fflush(stderr); +#elif defined(__linux__) + if (!g_back_busy.load(std::memory_order_acquire)) { + return; + } + const u32 thread = static_cast(gettid()); + if (thread == g_back_thread_id.load(std::memory_order_relaxed)) { + return; + } + static std::atomic reports{}; + if (reports.fetch_add(1) >= 12) { + return; + } + char name[16] = "?"; + pthread_getname_np(pthread_self(), name, sizeof(name)); + struct Frames { + std::array pcs{}; + size_t count{}; + } frames; + _Unwind_Backtrace( + [](_Unwind_Context* context, void* arg) { + auto& f = *static_cast(arg); + if (f.count == f.pcs.size()) { + return _URC_END_OF_STACK; + } + f.pcs[f.count++] = reinterpret_cast(_Unwind_GetIP(context)); + return _URC_NO_REASON; + }, + &frames); + std::string stack; + for (size_t i = 1; i < frames.count; ++i) { + Dl_info info{}; + const auto pc = reinterpret_cast(frames.pcs[i]); + if (dladdr(frames.pcs[i], &info) != 0 && info.dli_fname != nullptr) { + const char* base = std::strrchr(info.dli_fname, '/'); + stack += fmt::format(" {}+{:#x}", base ? base + 1 : info.dli_fname, + pc - reinterpret_cast(info.dli_fbase)); + } else { + stack += fmt::format(" {:#x}", pc); + } + } + LOG_ERROR(HW_GPU, "GPU split owner check: {} on thread {} ({}) while the back runs:{}", where, + thread, name, stack); #else (void)where; #endif @@ -427,6 +474,8 @@ void GpuSplit::BackLoop(std::stop_token stop_token) { system->RegisterHostThread(); #ifdef _WIN32 g_back_thread_id.store(GetCurrentThreadId(), std::memory_order_relaxed); +#elif defined(__linux__) + g_back_thread_id.store(static_cast(gettid()), std::memory_order_relaxed); #endif u64 read_position = 0; while (!stop_token.stop_requested()) { From 9736fd34410534fc1bf689bcb09f5b155437b69f Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:36:50 -0400 Subject: [PATCH 046/154] buffer_cache: switches for the sync skip and the download window EDEN_NX2_SYNC_CACHE=0 synchronizes every range every time, and EDEN_NX2_DOWNLOAD_WINDOW=0 downloads only the range asked for. They are for narrowing down the GPU faults Mario Kart World has had on Android since these went in. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit c7749d510dc387c0b6112b970082dc6f9c4b2441) --- src/video_core/buffer_cache/buffer_cache.h | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 5b6a6151e7..7c87a0de23 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -263,7 +263,16 @@ std::optional BufferCache

::GetFlushArea(DA template void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { + // EDEN_NX2_DOWNLOAD_WINDOW=0 downloads only the range asked for. + static const bool window = [] { + const char* value = std::getenv("EDEN_NX2_DOWNLOAD_WINDOW"); + return value == nullptr || value[0] != '0'; + }(); ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { + if (!window) { + DownloadBufferMemory(buffer, device_addr, size); + return; + } // Each download waits for the whole GPU. Mario Kart World's macros read the indirect // draw arguments a culling dispatch wrote a few words at a time, and its CPU reads back // GPU-written data, each new page waiting again: one download of the window serves the @@ -2194,7 +2203,12 @@ bool BufferCache

::SynchronizeBuffer(Buffer& buffer, DAddr device_addr, u32 si // Most binds come back to a range nothing was written to since it was synchronized: skip // walking its pages while its region saw no CPU writes (they took 5% of Mario Kart World's // GPU back thread). - const u64 generation = memory_tracker.RangeGeneration(device_addr, size); + // EDEN_NX2_SYNC_CACHE=0 synchronizes every range every time. + static const bool sync_cache = [] { + const char* value = std::getenv("EDEN_NX2_SYNC_CACHE"); + return value == nullptr || value[0] != '0'; + }(); + const u64 generation = sync_cache ? memory_tracker.RangeGeneration(device_addr, size) : 0; SyncedRange& synced = synced_ranges[((device_addr >> 8) ^ size) % synced_ranges.size()]; if (generation != 0 && synced.generation == generation && synced.device_addr == device_addr && synced.size == size && synced.buffer == &buffer) { From 108507efe827cc97569e4b87405e0015d1b5c9fa Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:05:23 -0400 Subject: [PATCH 047/154] buffer_cache: download only the range asked for again The 64 KiB download window corrupted guest memory. Its premise was that a CPU write always ends the GPU-written state of its range. That only holds for writes that fault. After a page's first fault it stays unprotected until its next upload, and later CPU writes there reach no cache. A range the GPU wrote and the CPU then rewrote stays marked GPU-written. A download of a neighbouring range then copied the old GPU data over the CPU's newer data. Mario Kart World on Android faulted the GPU every few seconds (CP opcode errors) and lost it within a minute or two. With the window off, it raced 12 minutes without a fault, with everything else on. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 8e41cb92c3b1f7634b290c30acb596aa0e5cf7f9) --- src/video_core/buffer_cache/buffer_cache.h | 21 +------------------ .../buffer_cache/buffer_cache_base.h | 1 - 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 7c87a0de23..c789feb989 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -263,27 +263,8 @@ std::optional BufferCache

::GetFlushArea(DA template void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { - // EDEN_NX2_DOWNLOAD_WINDOW=0 downloads only the range asked for. - static const bool window = [] { - const char* value = std::getenv("EDEN_NX2_DOWNLOAD_WINDOW"); - return value == nullptr || value[0] != '0'; - }(); ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { - if (!window) { - DownloadBufferMemory(buffer, device_addr, size); - return; - } - // Each download waits for the whole GPU. Mario Kart World's macros read the indirect - // draw arguments a culling dispatch wrote a few words at a time, and its CPU reads back - // GPU-written data, each new page waiting again: one download of the window serves the - // reads after it. Early is safe: a CPU write ends the GPU-written state of its range - // (WriteMemory), so newer CPU data is never downloaded over. - constexpr u64 Window = u64{64} << 10; - const DAddr begin = - (std::max)(Common::AlignDown(device_addr, Window), buffer.CpuAddr()); - const DAddr end = (std::min)(Common::AlignUp(device_addr + size, Window), - buffer.CpuAddr() + buffer.SizeBytes()); - DownloadBufferMemory(buffer, begin, end - begin); + DownloadBufferMemory(buffer, device_addr, size); }); } diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index e42d9ab6d8..1ecb2a62ed 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -247,7 +247,6 @@ class BufferCache : public VideoCommon::ChannelSetupCaches GetFlushArea(DAddr device_addr, u64 size); From 1943371908930e98c8dec409a39b1bd2ce1eaa25 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:13:31 -0400 Subject: [PATCH 048/154] vulkan: never skip draws of Switch 2 programs while their pipeline builds Asynchronous shaders skip a draw whose pipeline is still building. Switch 2 programs render once what they reuse for a long time. After such skips, Mario Kart World raced a whole track washed in another scene's lighting. Their draws now wait for the pipeline in the Vulkan worker. Pipelines still build on the workers alongside each other, so new ones cost a wait the first time instead of a skipped draw. The comment on draws that write memory no longer blames them for the GPU faults. Those came from the 64 KiB download window. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit aa27310bb71e344fced426bce4e698906b4c9f74) --- .../renderer_vulkan/vk_pipeline_cache.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 85b125652f..d39241294a 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1128,10 +1128,15 @@ GraphicsPipeline* PipelineCache::BuiltPipeline(GraphicsPipeline* pipeline) const if (!use_asynchronous_shaders) { return pipeline; } - // Draws that write memory wait for their pipeline (in the Vulkan worker) instead of being - // skipped: a skipped one left buffers later passes read (culling results, indirect arguments, - // counters) stale, and Mario Kart World then faulted the GPU (CP opcode errors on Turnip and - // Qualcomm's driver alike, never with asynchronous shaders off). + // Switch 2 programs render once what they then reuse for a long time: Mario Kart World raced + // in another scene's lighting after draws were skipped while their pipelines built. Their + // draws wait for the pipeline instead (in the Vulkan worker), which still builds on the + // workers alongside the others. + if (Settings::CurrentProgramIsSwitch2()) { + return pipeline; + } + // Draws that write memory wait too: skipping one leaves what later passes read (culling + // results, indirect arguments, counters) stale. if (pipeline->WritesMemory()) { return pipeline; } From b97f0d0babe8c7679d26ff54e5319c0a4f081047 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:13:32 -0400 Subject: [PATCH 049/154] buffer_cache: download the owned, protected pages around a range A buffer download waits for the whole GPU, and Mario Kart World's macros and CPU read GPU-written data a few words at a time. Taking the 64 KiB around each download cut those waits, but corrupted guest memory and faulted the GPU. The likely reason is that the tracking is per page. The window reached the partial pages a buffer shares with its neighbours, and clearing them dropped a neighbour's GPU-written state and copied bytes from outside the buffer. The window now takes only whole pages of the same buffer, and only those still write-protected. A CPU write there faults and flushes first, so the GPU's data is the newest. The range asked for is downloaded as before, all in one wait (DownloadBufferRanges). EDEN_NX2_DOWNLOAD_WINDOW=0 turns the window off. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit d426cefc00a6b4cdb55f4f5d8af6b28d64c04540) --- src/video_core/buffer_cache/buffer_cache.h | 93 ++++++++++++++----- .../buffer_cache/buffer_cache_base.h | 3 + .../buffer_cache/memory_tracker_base.h | 8 ++ 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index c789feb989..8735a23a46 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -263,8 +263,46 @@ std::optional BufferCache

::GetFlushArea(DA template void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { + // EDEN_NX2_DOWNLOAD_WINDOW=0 downloads only the range asked for. + static const bool window = [] { + const char* value = std::getenv("EDEN_NX2_DOWNLOAD_WINDOW"); + return value == nullptr || value[0] != '0'; + }(); ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { - DownloadBufferMemory(buffer, device_addr, size); + if (!window) { + DownloadBufferMemory(buffer, device_addr, size); + return; + } + // Each download waits for the whole GPU, and Mario Kart World's macros and CPU read + // GPU-written data a few words at a time: also take the GPU-written pages of the same + // buffer in the 64 KiB around the range, so the reads after them find it flushed. + // Only whole pages of this buffer: the tracking is per page, and clearing a page this + // buffer shares with another would drop the other's GPU-written state and copy bytes + // from outside this buffer (taking every page in the window corrupted guest memory + // and faulted the GPU within minutes). And only pages still write-protected: a CPU write + // there faults and flushes first, so the GPU's data is the newest. + constexpr u64 Window = u64{64} << 10; + constexpr u64 Page = Core::DEVICE_PAGESIZE; + const DAddr owned_begin = Common::AlignUp(buffer.CpuAddr(), Page); + const DAddr owned_end = Common::AlignDown(buffer.CpuAddr() + buffer.SizeBytes(), Page); + const DAddr begin = (std::max)(Common::AlignDown(device_addr, Window), owned_begin); + const DAddr end = (std::min)(Common::AlignUp(device_addr + size, Window), owned_end); + const DAddr asked_begin = Common::AlignDown(device_addr, Page); + const DAddr asked_end = Common::AlignUp(device_addr + size, Page); + boost::container::small_vector, 8> ranges{{device_addr, size}}; + for (DAddr page = begin; page < end; page += Page) { + if ((page >= asked_begin && page < asked_end) || + !memory_tracker.IsRegionGpuModified(page, Page) || + memory_tracker.IsRegionUntracked(page, Page)) { + continue; + } + if (ranges.size() > 1 && ranges.back().first + ranges.back().second == page) { + ranges.back().second += Page; + } else { + ranges.emplace_back(page, Page); + } + } + DownloadBufferRanges(buffer, ranges); }); } @@ -2421,31 +2459,40 @@ void BufferCache

::DownloadBuffersMemory(std::span buffer_ids) template void BufferCache

::DownloadBufferMemory(Buffer& buffer, DAddr device_addr, u64 size) { + const std::array ranges{std::pair{device_addr, size}}; + DownloadBufferRanges(buffer, ranges); +} + +template +void BufferCache

::DownloadBufferRanges(Buffer& buffer, + std::span> ranges) { boost::container::small_vector copies; u64 total_size_bytes = 0; u64 largest_copy = 0; - memory_tracker.ForEachDownloadRangeAndClear( - device_addr, size, [&](u64 device_addr_out, u64 range_size) { - const DAddr buffer_addr = buffer.CpuAddr(); - const auto add_download = [&](DAddr start, DAddr end) { - const u64 new_offset = start - buffer_addr; - const u64 new_size = end - start; - copies.push_back(BufferCopy{ - .src_offset = new_offset, - .dst_offset = total_size_bytes, - .size = new_size, - }); - // Align up to avoid cache conflicts - constexpr u64 align = 64ULL; - constexpr u64 mask = ~(align - 1ULL); - total_size_bytes += (new_size + align - 1) & mask; - largest_copy = (std::max)(largest_copy, new_size); - }; - - gpu_modified_ranges.ForEachInRange(device_addr_out, range_size, add_download); - ClearDownload(device_addr_out, range_size); - gpu_modified_ranges.Subtract(device_addr_out, range_size); - }); + for (const auto& [device_addr, size] : ranges) { + memory_tracker.ForEachDownloadRangeAndClear( + device_addr, size, [&](u64 device_addr_out, u64 range_size) { + const DAddr buffer_addr = buffer.CpuAddr(); + const auto add_download = [&](DAddr start, DAddr end) { + const u64 new_offset = start - buffer_addr; + const u64 new_size = end - start; + copies.push_back(BufferCopy{ + .src_offset = new_offset, + .dst_offset = total_size_bytes, + .size = new_size, + }); + // Align up to avoid cache conflicts + constexpr u64 align = 64ULL; + constexpr u64 mask = ~(align - 1ULL); + total_size_bytes += (new_size + align - 1) & mask; + largest_copy = (std::max)(largest_copy, new_size); + }; + + gpu_modified_ranges.ForEachInRange(device_addr_out, range_size, add_download); + ClearDownload(device_addr_out, range_size); + gpu_modified_ranges.Subtract(device_addr_out, range_size); + }); + } if (total_size_bytes == 0) { return; } diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 1ecb2a62ed..251bbb511a 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -506,6 +506,9 @@ class BufferCache : public VideoCommon::ChannelSetupCaches> ranges); + /// Downloads the GPU-written data of several buffers with one wait for the GPU. void DownloadBuffersMemory(std::span buffer_ids); diff --git a/src/video_core/buffer_cache/memory_tracker_base.h b/src/video_core/buffer_cache/memory_tracker_base.h index f5c8ad60b1..b13e3577b4 100644 --- a/src/video_core/buffer_cache/memory_tracker_base.h +++ b/src/video_core/buffer_cache/memory_tracker_base.h @@ -63,6 +63,14 @@ class MemoryTrackerBase { }); } + /// Returns true if a region has pages CPU writes no longer fault on (written by the CPU and + /// not uploaded since, or written through the cached path) + [[nodiscard]] bool IsRegionUntracked(VAddr query_cpu_addr, u64 query_size) noexcept { + return IteratePages(query_cpu_addr, query_size, [](Manager* manager, u64 offset, size_t size) { + return manager->IsRegionModified(Type::Untracked, offset, size); + }); + } + /// Returns true if a region has been marked as Preflushable [[nodiscard]] bool IsRegionPreflushable(VAddr query_cpu_addr, u64 query_size) noexcept { return IteratePages(query_cpu_addr, query_size, [](Manager* manager, u64 offset, size_t size) { From 5e59d48a691b09d07998e159f05b087d985f3aff Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:29:14 -0400 Subject: [PATCH 050/154] memory_manager: guard the kind map in PageTableOp too The GPU threads read the page kinds (GetPageKind, GetMemoryLayoutSize) under the guard, and BigPageTableOp writes them under it. PageTableOp wrote them without it. Mario Kart World maps GPU memory from service threads all the time (nx2:gpu SubCreateGpuOnlyRegion), and one such map crashed inside the kind map's RangeMap while a GPU thread read it. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 1f6b2f8ee01d4e26cd85c5f3ac77990c470558b1) --- src/video_core/memory_manager.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/video_core/memory_manager.cpp b/src/video_core/memory_manager.cpp index ae64372b9c..6b8e5b1cf2 100644 --- a/src/video_core/memory_manager.cpp +++ b/src/video_core/memory_manager.cpp @@ -151,7 +151,13 @@ GPUVAddr MemoryManager::PageTableOp(GPUVAddr gpu_addr, [[maybe_unused]] DAddr de remaining_size -= page_size; } flush_modified(); - kind_map.Map(gpu_addr, gpu_addr + size, kind); + { + // As BigPageTableOp: the GPU threads read the kinds (GetPageKind, + // GetMemoryLayoutSize) while service threads map. Unguarded, a map from + // nvhost_as_gpu crashed Mario Kart World inside RangeMap. + std::unique_lock lock(guard); + kind_map.Map(gpu_addr, gpu_addr + size, kind); + } return gpu_addr; } From 1932cb241f3000ddfca96b13e85c0347efced16a Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:29:14 -0400 Subject: [PATCH 051/154] buffer_cache: leave the download window off by default Even taking only whole, write-protected pages of the buffer, the 64 KiB download window faulted Mario Kart World's GPU within two minutes (CP opcode errors). With it off, the game raced 12 minutes without a fault. The page-sharing explanation did not hold, and why is not found yet. EDEN_NX2_DOWNLOAD_WINDOW=1 turns it on, for testing. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit ed143b048b0c32acc5f1ce5872756e2f430156b3) --- src/video_core/buffer_cache/buffer_cache.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 8735a23a46..2861d8c075 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -263,10 +263,12 @@ std::optional BufferCache

::GetFlushArea(DA template void BufferCache

::DownloadMemory(DAddr device_addr, u64 size) { - // EDEN_NX2_DOWNLOAD_WINDOW=0 downloads only the range asked for. + // EDEN_NX2_DOWNLOAD_WINDOW=1 also takes the pages around the range, below. Off: even with + // only whole, protected pages of the buffer, Mario Kart World faulted the GPU within two + // minutes (CP opcode errors), and raced 12 minutes without one with it off. Why is not found. static const bool window = [] { const char* value = std::getenv("EDEN_NX2_DOWNLOAD_WINDOW"); - return value == nullptr || value[0] != '0'; + return value != nullptr && value[0] != '0'; }(); ForEachBufferInRange(device_addr, size, [&](BufferId, Buffer& buffer) { if (!window) { From a5611ea7441d398545483a58e94a2e5f2a9c5b9c Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:55:23 -0400 Subject: [PATCH 052/154] vulkan_device: memory use and budget without VK_EXT_memory_budget Qualcomm's driver has no VK_EXT_memory_budget. The texture cache then summed its images' sizes for its memory use, counting sparse and unbacked images whole. Its budget was the 4 GiB integrated default. In Mario Kart World it "used" 8.5 GB with 4.7 GB allocated, far past its critical 3.8 GB, and evicted textures nonstop. The process grew to 15-18 GB until Android killed it while loading each race. Without the extension, the usage is now what VMA's allocations use. On an integrated GPU, the budget is a quarter of the system's RAM, and at least 4 GiB: about 6 GiB on a 24 GiB phone, near what Turnip's budget gives there. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 869d8df55071760e0301a1c4f081f00324478e11) --- .../vulkan_common/vulkan_device.cpp | 25 ++++++++++++++++++- src/video_core/vulkan_common/vulkan_device.h | 3 ++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index 2499f46f87..18a60a9955 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -24,6 +24,7 @@ #include "common/fs/fs.h" #include "common/fs/path_util.h" #include "common/literals.h" +#include "common/memory_detect.h" #include #include "common/settings.h" #include "common/settings_enums.h" @@ -1716,6 +1717,21 @@ void Device::ReleaseCustomBorderColorSamplers(size_t count) const { } u64 Device::GetDeviceMemoryUsage() const { + // Without VK_EXT_memory_budget (Qualcomm's driver), what VMA's allocations use. The caches' + // own estimate counted sparse and unbacked images at their full size: Mario Kart World + // "used" 8.5 GB with 4.7 GB allocated, and evicted textures nonstop until the phone ran out of + // memory. + if (!extensions.memory_budget) { + u64 used{}; + if (allocator != nullptr) { + std::array heap_budgets{}; + vmaGetHeapBudgets(allocator, heap_budgets.data()); + for (const size_t heap : valid_heap_memory) { + used += heap_budgets[heap].statistics.allocationBytes; + } + } + return used; + } VkPhysicalDeviceMemoryBudgetPropertiesEXT budget{}; budget.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; physical.GetMemoryProperties(&budget); @@ -1742,7 +1758,14 @@ u64 Device::GetDeviceMemorySlack() const { u64 Device::GetDeviceMemoryBudget() const { if (!extensions.memory_budget) { - return 0; + // A phone GPU without the extension shares the system's RAM: a quarter of it (at least + // 4 GiB), ~6 GiB on a 24 GiB phone, what Turnip's budget gives on the same one. The + // integrated default of 4 GiB was under what Mario Kart World keeps in use. + if (!is_integrated) { + return 0; + } + const u64 quarter = Common::GetMemInfo().TotalPhysicalMemory / 4; + return (std::max)(quarter, u64{4} << 30); } VkPhysicalDeviceMemoryBudgetPropertiesEXT budget{}; budget.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h index 8856187992..dd735285c0 100644 --- a/src/video_core/vulkan_common/vulkan_device.h +++ b/src/video_core/vulkan_common/vulkan_device.h @@ -1026,8 +1026,9 @@ FN_MAX_LIMIT_LIST return device_access_memory; } + /// With VK_EXT_memory_budget from the driver, or VMA's allocations without it. bool CanReportMemoryUsage() const { - return extensions.memory_budget; + return extensions.memory_budget || allocator != nullptr; } u64 GetDeviceMemoryUsage() const; From 25e1b4c6e99a5727ad3d37b2ea315ca1931890dd Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:05:50 -0400 Subject: [PATCH 053/154] buffer_cache: read GPU-written buffers directly on integrated GPUs Mario Kart World's CPU reads GPU-written buffer data all the time. Each read copied the data to a staging buffer and waited for all the GPU's work (Finish): 17% of the GPU back thread on Android. An integrated GPU's buffers live in memory the host can read. Each buffer now records the tick of the last command written to modify it: shader writes, copies, clears and uploads (NoteGpuWrite). A download maps the buffer (vk::Buffer::HostView, separate from Mapped so writes still go through ordered copies), waits only for that tick, which is often complete already, and reads it. Submissions end with a barrier making GPU writes visible to host reads. Buffers whose memory the host cannot see are copied as before. EDEN_NX2_DIRECT_DOWNLOADS=0 turns it off. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 12e184d83af1e3b92218edbc821f2acddc95f5a1) --- src/video_core/buffer_cache/buffer_base.h | 11 ++++++ src/video_core/buffer_cache/buffer_cache.h | 36 ++++++++++++++++++- .../buffer_cache/buffer_cache_base.h | 3 ++ .../renderer_vulkan/vk_buffer_cache.cpp | 13 +++++++ .../renderer_vulkan/vk_buffer_cache.h | 16 +++++++++ .../renderer_vulkan/vk_scheduler.cpp | 12 +++++++ .../vulkan_common/vulkan_wrapper.cpp | 32 +++++++++++++++++ src/video_core/vulkan_common/vulkan_wrapper.h | 15 +++++++- 8 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_base.h b/src/video_core/buffer_cache/buffer_base.h index d7eb21612f..ac2481c313 100644 --- a/src/video_core/buffer_cache/buffer_base.h +++ b/src/video_core/buffer_cache/buffer_base.h @@ -129,6 +129,16 @@ class BufferBase { write_tick = write_tick_; } + /// The GPU tick of the last command recorded to modify this buffer (shader writes, copies, + /// clears): what a direct host read of its memory waits for. + [[nodiscard]] u64 GpuWriteTick() const noexcept { + return gpu_write_tick; + } + + void SetGpuWriteTick(u64 tick) noexcept { + gpu_write_tick = tick; + } + private: VAddr cpu_addr = 0; BufferFlagBits flags{}; @@ -136,6 +146,7 @@ class BufferBase { size_t lru_id = SIZE_MAX; size_t size_bytes = 0; u64 write_tick = 0; + u64 gpu_write_tick = 0; }; } // namespace VideoCommon diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 2861d8c075..31013e56c1 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -378,6 +378,7 @@ bool BufferCache

::DMACopy(GPUVAddr src_address, GPUVAddr dest_address, u64 am const auto& copy = copies[0]; src_buffer.MarkUsage(copy.src_offset, copy.size); dest_buffer.MarkUsage(copy.dst_offset, copy.size); + NoteGpuWrite(dest_buffer); runtime.CopyBuffer(dest_buffer, src_buffer, copies, true); if (has_new_downloads) { memory_tracker.MarkRegionAsGpuModified(*cpu_dest_address, amount); @@ -411,6 +412,7 @@ bool BufferCache

::DMAClear(GPUVAddr dst_address, u64 amount, u32 value) { const BufferId buffer = FindBuffer(*cpu_dst_address, static_cast(size), false); Buffer& dest_buffer = slot_buffers[buffer]; const u32 offset = dest_buffer.Offset(*cpu_dst_address); + NoteGpuWrite(dest_buffer); runtime.ClearBuffer(dest_buffer, offset, size, value); dest_buffer.MarkUsage(offset, size); return true; @@ -934,6 +936,7 @@ void BufferCache

::BindHostIndexBuffer() { auto upload_staging = runtime.UploadStagingBuffer(size); std::array copies{{BufferCopy{.src_offset = upload_staging.offset, .dst_offset = 0, .size = size}}}; std::memcpy(upload_staging.mapped_span.data(), draw_state.inline_index_draw_indexes.data(), size); + NoteGpuWrite(buffer); runtime.CopyBuffer(buffer, upload_staging.buffer, copies, true); } else { buffer.ImmediateUpload(0, draw_state.inline_index_draw_indexes); @@ -1786,11 +1789,19 @@ void BufferCache

::UpdateComputeTextureBuffers() { }); } +template +void BufferCache

::NoteGpuWrite(Buffer& buffer) { + if constexpr (!IS_OPENGL) { + buffer.SetGpuWriteTick(runtime.CurrentTick()); + } +} + template void BufferCache

::MarkWrittenBuffer(BufferId buffer_id, DAddr device_addr, u32 size, bool needs_sync) { if constexpr (!IS_OPENGL) { + Buffer& buffer = slot_buffers[buffer_id]; + NoteGpuWrite(buffer); if (needs_sync) { - Buffer& buffer = slot_buffers[buffer_id]; buffer.setWriteTick(runtime.CurrentTick()); } } @@ -2145,6 +2156,7 @@ void BufferCache

::JoinOverlap(BufferId new_buffer_id, BufferId overlap_id, .size = overlap.SizeBytes(), }); new_buffer.MarkUsage(copies[0].dst_offset, copies[0].size); + NoteGpuWrite(new_buffer); runtime.CopyBuffer(new_buffer, overlap, copies, true); #ifdef YUZU_LEGACY if (immediately_free) @@ -2165,6 +2177,7 @@ BufferId BufferCache

::CreateBuffer(DAddr device_addr, u32 wanted_size, slot_buffers.insert(runtime, overlap.begin, size, sparse_compatible); auto& new_buffer = slot_buffers[new_buffer_id]; const size_t size_bytes = new_buffer.SizeBytes(); + NoteGpuWrite(new_buffer); runtime.ClearBuffer(new_buffer, 0, size_bytes, 0); new_buffer.MarkUsage(0, size_bytes); for (const BufferId overlap_id : overlap.ids) { @@ -2359,6 +2372,7 @@ void BufferCache

::MappedUploadMemory([[maybe_unused]] Buffer& buffer, copy.src_offset += upload_staging.offset; } const bool can_reorder = runtime.CanReorderUpload(buffer, copies); + NoteGpuWrite(buffer); runtime.CopyBuffer(buffer, upload_staging.buffer, copies, true, can_reorder); } } @@ -2402,6 +2416,7 @@ void BufferCache

::InlineMemoryImplementation(DAddr dest_address, size_t copy_ u8* const src_pointer = upload_staging.mapped_span.data(); std::memcpy(src_pointer, inlined_buffer.data(), copy_size); const bool can_reorder = runtime.CanReorderUpload(buffer, copies); + NoteGpuWrite(buffer); runtime.CopyBuffer(buffer, upload_staging.buffer, copies, true, can_reorder); } else { buffer.ImmediateUpload(buffer.Offset(dest_address), inlined_buffer.first(copy_size)); @@ -2498,6 +2513,25 @@ void BufferCache

::DownloadBufferRanges(Buffer& buffer, if (total_size_bytes == 0) { return; } + if constexpr (requires { runtime.CanReadBuffersDirectly(); buffer.HostView(); }) { + // An integrated GPU's buffers are in the memory the host reads: wait for the GPU to + // complete the last command that wrote this buffer (often done already) and read it, + // instead of copying it out and waiting for all the GPU's work. Mario Kart World's CPU + // reads GPU-written data all the time, each such wait 17% of the GPU back thread. + const u64 tick = buffer.GpuWriteTick(); + if (tick != 0 && runtime.CanReadBuffersDirectly()) { + const std::span host = buffer.HostView(); + if (!host.empty()) { + runtime.WaitTick(tick); + buffer.InvalidateHostView(); + for (const BufferCopy& copy : copies) { + device_memory.WriteBlockUnsafe(buffer.CpuAddr() + copy.src_offset, + host.data() + copy.src_offset, copy.size); + } + return; + } + } + } if constexpr (USE_MEMORY_MAPS) { auto download_staging = runtime.DownloadStagingBuffer(total_size_bytes); diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 251bbb511a..e2e53b2c2b 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -506,6 +506,9 @@ class BufferCache : public VideoCommon::ChannelSetupCaches> ranges); diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp index 70c68343ce..1709a0f4ab 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp @@ -448,6 +448,19 @@ u64 BufferCacheRuntime::CurrentTick() { return scheduler.CurrentTick(); } +bool BufferCacheRuntime::CanReadBuffersDirectly() const noexcept { + // EDEN_NX2_DIRECT_DOWNLOADS=0 copies every download. + static const bool enabled = [] { + const char* value = std::getenv("EDEN_NX2_DIRECT_DOWNLOADS"); + return value == nullptr || value[0] != '0'; + }(); + return enabled && device.IsIntegrated(); +} + +void BufferCacheRuntime::WaitTick(u64 tick) { + scheduler.Wait(tick); +} + bool BufferCacheRuntime::IsFree(u64 tick) { return scheduler.IsFree(tick); } diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.h b/src/video_core/renderer_vulkan/vk_buffer_cache.h index 68a0c037f5..4f9dbea7d9 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.h +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.h @@ -69,6 +69,15 @@ class Buffer : public VideoCommon::BufferBase { return last_usage_tick; } + /// The memory for direct host reads (see vk::Buffer::HostView). + [[nodiscard]] std::span HostView() { + return buffer.HostView(); + } + + void InvalidateHostView() const { + buffer.InvalidateHostView(); + } + operator VkBuffer() const noexcept { return *buffer; } @@ -128,6 +137,13 @@ class BufferCacheRuntime { void Finish(); + /// Integrated GPUs: buffer downloads read the buffers' memory directly once the GPU completed + /// their last writes, instead of copying them and waiting for all its work. + [[nodiscard]] bool CanReadBuffersDirectly() const noexcept; + + /// Waits for the GPU to complete `tick`, submitting it first when it is still recording. + void WaitTick(u64 tick); + u64 GetDeviceLocalMemory() const; u64 GetDeviceMemoryUsage() const; diff --git a/src/video_core/renderer_vulkan/vk_scheduler.cpp b/src/video_core/renderer_vulkan/vk_scheduler.cpp index f23e07ec30..22be7823e8 100644 --- a/src/video_core/renderer_vulkan/vk_scheduler.cpp +++ b/src/video_core/renderer_vulkan/vk_scheduler.cpp @@ -431,6 +431,18 @@ u64 Scheduler::SubmitExecution(VkSemaphore signal_semaphore, VkSemaphore wait_se .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT, }; upload_cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, WRITE_BARRIER); + if (device.IsIntegrated()) { + // Buffer downloads read buffer memory directly once their tick completes + // (BufferCacheRuntime::CanReadBuffersDirectly): make the GPU's writes visible. + static constexpr VkMemoryBarrier HOST_READ_BARRIER{ + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, + .pNext = nullptr, + .srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT, + .dstAccessMask = VK_ACCESS_HOST_READ_BIT, + }; + cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_HOST_BIT, + 0, HOST_READ_BARRIER); + } upload_cmdbuf.End(); cmdbuf.End(); diff --git a/src/video_core/vulkan_common/vulkan_wrapper.cpp b/src/video_core/vulkan_common/vulkan_wrapper.cpp index d0bca88125..ee9e47ff81 100644 --- a/src/video_core/vulkan_common/vulkan_wrapper.cpp +++ b/src/video_core/vulkan_common/vulkan_wrapper.cpp @@ -555,8 +555,40 @@ void Buffer::SetObjectNameEXT(const char* name) const { SetObjectName(dld, owner, handle, VK_OBJECT_TYPE_BUFFER, name); } +std::span Buffer::HostView() { + if (!host_view.empty()) { + return host_view; + } + if (allocator == nullptr || allocation == nullptr) { + return {}; + } + VkMemoryPropertyFlags flags{}; + vmaGetAllocationMemoryProperties(allocator, allocation, &flags); + if ((flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { + return {}; + } + void* data{}; + if (vmaMapMemory(allocator, allocation, &data) != VK_SUCCESS || data == nullptr) { + return {}; + } + VmaAllocationInfo info{}; + vmaGetAllocationInfo(allocator, allocation, &info); + host_view = std::span{static_cast(data), static_cast(info.size)}; + host_view_coherent = (flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0; + return host_view; +} + +void Buffer::InvalidateHostView() const { + if (!host_view.empty() && !host_view_coherent) { + vmaInvalidateAllocation(allocator, allocation, 0, VK_WHOLE_SIZE); + } +} + void Buffer::Release() const noexcept { if (handle) { + if (!host_view.empty()) { + vmaUnmapMemory(allocator, allocation); + } vmaDestroyBuffer(allocator, handle, allocation); } } diff --git a/src/video_core/vulkan_common/vulkan_wrapper.h b/src/video_core/vulkan_common/vulkan_wrapper.h index f49e9518bb..57b3b30181 100644 --- a/src/video_core/vulkan_common/vulkan_wrapper.h +++ b/src/video_core/vulkan_common/vulkan_wrapper.h @@ -779,7 +779,8 @@ class Buffer { Buffer(Buffer&& rhs) noexcept : handle{std::exchange(rhs.handle, VkBuffer{})}, owner{rhs.owner}, allocator{rhs.allocator}, allocation{rhs.allocation}, mapped{rhs.mapped}, location{rhs.location}, - is_coherent{rhs.is_coherent}, dld{rhs.dld} {} + is_coherent{rhs.is_coherent}, dld{rhs.dld}, host_view{std::exchange(rhs.host_view, {})}, + host_view_coherent{rhs.host_view_coherent} {} Buffer& operator=(Buffer&& rhs) noexcept { Release(); @@ -791,6 +792,8 @@ class Buffer { location = rhs.location; is_coherent = rhs.is_coherent; dld = rhs.dld; + host_view = std::exchange(rhs.host_view, {}); + host_view_coherent = rhs.host_view_coherent; return *this; } @@ -834,6 +837,14 @@ class Buffer { void Invalidate() const; + /// The memory for the host to read, mapped on first use when its type is host visible (a + /// device-local buffer of an integrated GPU); empty otherwise. Unlike Mapped(), it does not + /// make IsHostVisible() true: writes still go through copies ordered with the GPU's work. + std::span HostView(); + + /// Makes what the device wrote visible to HostView() reads. + void InvalidateHostView() const; + void SetObjectNameEXT(const char* name) const; MemoryLocation Location() const noexcept { @@ -851,6 +862,8 @@ class Buffer { MemoryLocation location{}; bool is_coherent = false; const DeviceDispatch* dld = nullptr; + std::span host_view = {}; + bool host_view_coherent = false; }; class Queue { From eab768ef0b0ae330f20a239aed87efec8e98a6a0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:12:34 -0400 Subject: [PATCH 054/154] vulkan_device: cap an integrated GPU's memory budget at a quarter of RAM A phone GPU shares the RAM a Switch 2 program's 12 GiB of guest memory also takes. A Turnip build reported a ~9 GiB budget, and the texture cache, collecting only past it, kept 6.6 GB and more. Android killed Mario Kart World 20 minutes into play for want of memory. An integrated GPU's budget is now at most a quarter of the system's RAM (at least 4 GiB), about 6 GiB on a 24 GiB phone, with or without VK_EXT_memory_budget. On upstream: the quarter-of-RAM budget is Android's; other integrated GPUs keep upstream's device_access_memory cap. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 7dab7dec5aef823bee18c181b0888aeab1f10458) --- .../vulkan_common/vulkan_device.cpp | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index 18a60a9955..b8008e812f 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -1757,16 +1757,22 @@ u64 Device::GetDeviceMemorySlack() const { } u64 Device::GetDeviceMemoryBudget() const { +#ifdef ANDROID + // A phone GPU shares the system's RAM with the guest (12 GiB for a Switch 2 program): a + // quarter of it (at least 4 GiB), ~6 GiB on a 24 GiB phone. Without VK_EXT_memory_budget + // (Qualcomm) the integrated default of 4 GiB was under what Mario Kart World keeps in use; + // with it, a Turnip build reported ~9 GiB, and the textures it let in got the game killed + // for want of memory 20 minutes in. + const u64 integrated_budget = + (std::max)(Common::GetMemInfo().TotalPhysicalMemory / 4, u64{4} << 30); if (!extensions.memory_budget) { - // A phone GPU without the extension shares the system's RAM: a quarter of it (at least - // 4 GiB), ~6 GiB on a 24 GiB phone, what Turnip's budget gives on the same one. The - // integrated default of 4 GiB was under what Mario Kart World keeps in use. - if (!is_integrated) { - return 0; - } - const u64 quarter = Common::GetMemInfo().TotalPhysicalMemory / 4; - return (std::max)(quarter, u64{4} << 30); + return is_integrated ? integrated_budget : 0; + } +#else + if (!extensions.memory_budget) { + return 0; } +#endif VkPhysicalDeviceMemoryBudgetPropertiesEXT budget{}; budget.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; physical.GetMemoryProperties(&budget); @@ -1792,7 +1798,12 @@ u64 Device::GetDeviceMemoryBudget() const { // budget): following it let the texture cache fill that much RAM before collecting, and // kept ASTC uncompressed. Its budget is at most the share CollectPhysicalMemoryInfo // gives it (4 GiB, 6 GiB with aggressive VRAM use). +#ifdef ANDROID + // On a phone: a quarter of the RAM (see above). + result = (std::min)(result, integrated_budget); +#else result = (std::min)(result, device_access_memory); +#endif } return result; } From 53dc65be5254c3b7e4feea7c3212a8ce99ea66c1 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:35:19 -0400 Subject: [PATCH 055/154] vulkan: more pipeline workers, off the background cores, for Switch 2 Draws of Switch 2 programs now wait for their pipelines instead of being skipped. Pipelines built on 2 low-priority workers on the background cores, so a burst of new ones froze Mario Kart World for up to a second: 37 pipelines in 10 s made 262 ms frames. For Switch 2 programs, Android now uses at least 6 workers, and the workers run at normal priority on any core. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 26d34d413bee4baf6f0aed23e5d8961e393a329a) --- src/video_core/renderer_vulkan/vk_pipeline_cache.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index d39241294a..0e01916331 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -326,7 +326,12 @@ size_t GetTotalPipelineWorkers() { const size_t max_core_threads = std::max(static_cast(std::thread::hardware_concurrency()), 2ULL) - 1ULL; #ifdef __ANDROID__ - const int configured = AndroidSettings::values.pipeline_worker_count.GetValue(); + int configured = AndroidSettings::values.pipeline_worker_count.GetValue(); + // Switch 2 programs build pipelines dozens at a time, and their draws wait for them + // (BuiltPipeline): with 2 workers a burst of 37 froze Mario Kart World for up to a second. + if (Settings::CurrentProgramIsSwitch2()) { + configured = (std::max)(configured, 6); + } const int clamped = std::clamp(configured, 2, 8); const size_t desired = static_cast(clamped); if (desired == 0) { @@ -428,7 +433,10 @@ PipelineCache::PipelineCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, translation_wait_budget{GetTranslationWaitBudget()}, num_translation_threads{use_async_translation ? GetTotalTranslationWorkers() : 0}, workers(device.HasBrokenParallelShaderCompiling() ? 1ULL : GetTotalPipelineWorkers(), - "VkPipelineBuilder", {}, Common::ThreadPlacement::Background), + "VkPipelineBuilder", {}, + // Draws of Switch 2 programs wait for these builds: not on background cores. + Settings::CurrentProgramIsSwitch2() ? Common::ThreadPlacement::Default + : Common::ThreadPlacement::Background), serialization_thread(1, "VkPipelineSerialization", {}, Common::ThreadPlacement::Background), translation_workers(num_translation_threads, "VkPipelineTranslator", {}, From af9615b4d74becf7801f15c01549f9269bcbc963 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:54:23 -0400 Subject: [PATCH 056/154] vulkan: leave a phone more memory while Switch 2 races load Sampled every 10 s, Mario Kart World's process holds 12-13 GB while racing and 16-17 GB while a race loads: the guest's own peak plus 2 GB of native heap. That left a 24 GiB phone 2 GB, and loads got the game killed. An integrated GPU's budget is now a fifth of the system's RAM (at least 4 GiB), about 4.7 GiB, instead of a quarter. Switch 2 programs get at least 4 pipeline workers instead of 6, since each build holds the driver's compiler memory. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 4676872ac594d29b0fb42870f470f683957450e8) --- src/video_core/renderer_vulkan/vk_pipeline_cache.cpp | 4 +++- src/video_core/vulkan_common/vulkan_device.cpp | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 0e01916331..64a9d84345 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -329,8 +329,10 @@ size_t GetTotalPipelineWorkers() { int configured = AndroidSettings::values.pipeline_worker_count.GetValue(); // Switch 2 programs build pipelines dozens at a time, and their draws wait for them // (BuiltPipeline): with 2 workers a burst of 37 froze Mario Kart World for up to a second. + // 4, not more: each build holds the driver's compiler memory, and loading a race already + // takes the phone to its last gigabytes. if (Settings::CurrentProgramIsSwitch2()) { - configured = (std::max)(configured, 6); + configured = (std::max)(configured, 4); } const int clamped = std::clamp(configured, 2, 8); const size_t desired = static_cast(clamped); diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index b8008e812f..00f59f11a4 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -1759,12 +1759,13 @@ u64 Device::GetDeviceMemorySlack() const { u64 Device::GetDeviceMemoryBudget() const { #ifdef ANDROID // A phone GPU shares the system's RAM with the guest (12 GiB for a Switch 2 program): a - // quarter of it (at least 4 GiB), ~6 GiB on a 24 GiB phone. Without VK_EXT_memory_budget + // fifth of it (at least 4 GiB), ~4.7 GiB on a 24 GiB phone. Without VK_EXT_memory_budget // (Qualcomm) the integrated default of 4 GiB was under what Mario Kart World keeps in use; // with it, a Turnip build reported ~9 GiB, and the textures it let in got the game killed - // for want of memory 20 minutes in. + // for want of memory. Even with a quarter, loading a race (16-17 GB for the process, the + // guest's own peak) left the phone without memory. const u64 integrated_budget = - (std::max)(Common::GetMemInfo().TotalPhysicalMemory / 4, u64{4} << 30); + (std::max)(Common::GetMemInfo().TotalPhysicalMemory / 5, u64{4} << 30); if (!extensions.memory_budget) { return is_integrated ? integrated_budget : 0; } From 87a58ee755f44d9b3dac40d1e50e6748f0ac167e Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:19:47 -0500 Subject: [PATCH 057/154] Add two settings that save memory for Switch 2 titles Loading a Mario Kart World race takes its process to 19-20 GB, and a 24 GiB phone kills it. Both settings are off by default, switchable per game, and listed with the renderer hacks on Android. reduce_texture_resolution: a large compressed 2D texture (ASTC or BCn, 1024 pixels and up, with mip levels, at most 6 layers) keeps its levels from 1 on, a quarter of the video memory. This uses the existing TrimmedLevels support. Compressed formats are never render targets or storage images. GPU copies by guest level are kept off trimmed images (IsSafeGpuCopy, and AccelerateDMA falls back to copying through guest memory), so they upload the levels they keep from there. release_gpu_only_memory (experimental): Switch 2 textures in GPU-only memory were kept twice, in the guest's RAM and in host images. Once an image the host keeps byte for byte as the guest does is uploaded from there, it is marked GPU modified, so reads through the caches and eviction download it back first. Its guest backing is then given back to the system: HostMemory::ReleaseBackingRegion punches holes in the memfd, through DeviceMemoryManager::ReleaseMemory. Guest memory read without going through the caches can see zeros there. On upstream: releases through upstream's device_memory_ptr, before upstream's cold hint (released memory is GPU modified and not hinted). Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 0d095fe2bcc56b6e654ebea56a7cb0fa163a2a04) --- .../features/settings/model/BooleanSetting.kt | 2 ++ .../settings/model/view/SettingsItem.kt | 14 +++++++++ .../settings/ui/SettingsFragmentPresenter.kt | 2 ++ .../app/src/main/res/values/strings.xml | 4 +++ src/common/host_memory.cpp | 30 ++++++++++++++++++ src/common/host_memory.h | 4 +++ src/common/settings.h | 8 +++++ src/core/device_memory_manager.h | 4 +++ src/core/device_memory_manager.inc | 31 +++++++++++++++++++ .../renderer_vulkan/vk_rasterizer.cpp | 5 +++ .../renderer_vulkan/vk_texture_cache.cpp | 20 ++++++++++++ src/video_core/texture_cache/image_base.cpp | 4 +++ src/video_core/texture_cache/texture_cache.h | 23 ++++++++++++++ .../texture_cache/texture_cache_base.h | 9 ++++++ 14 files changed, 160 insertions(+) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index 6c33fc7812..a21d06b6ed 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -26,6 +26,8 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_FORCE_MAX_CLOCK("force_max_clock"), RENDERER_ASYNCHRONOUS_GPU_EMULATION("use_asynchronous_gpu_emulation"), RENDERER_ASYNCHRONOUS_SHADERS("use_asynchronous_shaders"), + RENDERER_REDUCE_TEXTURE_RESOLUTION("reduce_texture_resolution"), + RENDERER_RELEASE_GPU_ONLY_MEMORY("release_gpu_only_memory"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), ENABLE_BUFFER_HISTORY("enable_buffer_history"), USE_OPTIMIZED_VERTEX_BUFFERS("use_optimized_vertex_buffers"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index f968a0ae18..30c76c05d8 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -778,6 +778,20 @@ abstract class SettingsItem( descriptionId = R.string.renderer_asynchronous_shaders_description ) ) + put( + SwitchSetting( + BooleanSetting.RENDERER_REDUCE_TEXTURE_RESOLUTION, + titleId = R.string.renderer_reduce_texture_resolution, + descriptionId = R.string.renderer_reduce_texture_resolution_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_RELEASE_GPU_ONLY_MEMORY, + titleId = R.string.renderer_release_gpu_only_memory, + descriptionId = R.string.renderer_release_gpu_only_memory_description + ) + ) put( SingleChoiceSetting( IntSetting.FAST_GPU_TIME, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 99b45cf3f3..f3ccc1744b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -558,6 +558,8 @@ class SettingsFragmentPresenter( add(BooleanSetting.FIX_BLOOM_EFFECTS.key) add(BooleanSetting.EMULATE_BGR565.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) + add(BooleanSetting.RENDERER_REDUCE_TEXTURE_RESOLUTION.key) + add(BooleanSetting.RENDERER_RELEASE_GPU_ONLY_MEMORY.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_GPU_EMULATION.key) add(SettingsItem.GPU_UNSWIZZLE_COMBINED) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 3647efa5da..9f9b4cbec8 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -609,6 +609,10 @@ Enables a legacy handling for the rescale configuration pass for games by using a quick rescale path Use asynchronous shaders Compiles shaders asynchronously. This may reduce stutters but may also introduce glitches. + Reduce texture resolution + Keeps large compressed textures at half resolution, using about a quarter of their video memory. Helps games that run out of memory, such as Switch 2 titles. Textures look slightly blurrier up close. Takes effect when the game starts. + Release GPU-only memory (experimental) + Frees the RAM copy of Switch 2 GPU-only memory once its textures are on the GPU, saving RAM. Experimental: may cause missing or corrupted textures. GPU Unswizzle Settings Configure GPU-based texture unswizzling parameters or disable it entirely. Adjust these settings to balance performance and texture loading quality. Enable GPU Unswizzle diff --git a/src/common/host_memory.cpp b/src/common/host_memory.cpp index 2ef1982cbc..cb2713f15b 100644 --- a/src/common/host_memory.cpp +++ b/src/common/host_memory.cpp @@ -24,6 +24,7 @@ #include "common/scope_exit.h" #if defined(__linux__) +#include #include #elif defined(__APPLE__) #include @@ -244,6 +245,8 @@ class HostMemory::Impl { u8* backing_base{}; u8* virtual_base{}; + void ReleaseBackingRegion(size_t, size_t) const {} + private: /// Release all resources in the object void Release() { @@ -668,6 +671,27 @@ class HostMemory::Impl { u8* virtual_base{reinterpret_cast(MAP_FAILED)}; u8* virtual_map_base{reinterpret_cast(MAP_FAILED)}; + void ReleaseBackingRegion(size_t physical_offset, size_t length) const { + // Punching holes frees the memfd's pages for every mapping of it; without a memfd the + // backing is private anonymous memory, which MADV_DONTNEED frees. + constexpr size_t page = 4096; + const size_t begin = (physical_offset + page - 1) & ~(page - 1); + const size_t end = (physical_offset + length) & ~(page - 1); + if (end <= begin) { + return; + } +#ifdef __linux__ + if (fd > 0) { + fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, static_cast(begin), + static_cast(end - begin)); + return; + } +#endif + { + madvise(backing_base + begin, end - begin, MADV_DONTNEED); + } + } + private: /// Release all resources in the object void Release() { @@ -896,6 +920,12 @@ void HostMemory::ForgetContents() { } } +void HostMemory::ReleaseBackingRegion(size_t physical_offset, size_t length) const { + if (impl) { + impl->ReleaseBackingRegion(physical_offset, length); + } +} + void HostMemory::EnableDirectMappedAddress() { #if !(defined(__OPENORBIS__) || defined(__managarm__)) if (impl) { diff --git a/src/common/host_memory.h b/src/common/host_memory.h index 84315091c5..7865da6e29 100644 --- a/src/common/host_memory.h +++ b/src/common/host_memory.h @@ -71,6 +71,10 @@ class HostMemory { /// A new kernel on this memory: everything a previous one used may hold data. void ForgetContents(); + /// Gives the backing pages of [physical_offset, +length) (whole pages inside it) back to the + /// system: they read as zeros until written again. Does nothing where unsupported. + void ReleaseBackingRegion(size_t physical_offset, size_t length) const; + [[nodiscard]] u8* BackingBasePointer() noexcept { return backing_base; } diff --git a/src/common/settings.h b/src/common/settings.h index 5d6236c6bf..3fe4b9b8f1 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -678,6 +678,14 @@ struct Values { SwitchableSetting use_asynchronous_shaders{linkage, true, "use_asynchronous_shaders", Category::RendererHacks}; + /// Large compressed textures keep their mip levels from 1 on (half resolution): less video + /// memory, slightly blurrier textures up close. + SwitchableSetting reduce_texture_resolution{linkage, false, "reduce_texture_resolution", + Category::RendererHacks}; + /// GPU-only guest memory (Switch 2) a texture was uploaded from is released afterwards, the + /// host image holding the data: less RAM, experimental. + SwitchableSetting release_gpu_only_memory{linkage, false, "release_gpu_only_memory", + Category::RendererHacks}; SwitchableSetting gpu_unswizzle_texture_size{linkage, GpuUnswizzleSize::Large, diff --git a/src/core/device_memory_manager.h b/src/core/device_memory_manager.h index d275bdbf2d..4d476b315c 100644 --- a/src/core/device_memory_manager.h +++ b/src/core/device_memory_manager.h @@ -47,6 +47,10 @@ class DeviceMemoryManager { public: DeviceMemoryManager(const DeviceMemory& device_memory); + + /// Gives back the backing of the guest memory the device range maps (whole pages): it reads + /// as zeros until written again. + void ReleaseMemory(DAddr address, size_t size); ~DeviceMemoryManager(); static constexpr bool HAS_FLUSH_INVALIDATION = true; diff --git a/src/core/device_memory_manager.inc b/src/core/device_memory_manager.inc index 221a448bb3..65ea58edb9 100644 --- a/src/core/device_memory_manager.inc +++ b/src/core/device_memory_manager.inc @@ -776,4 +776,35 @@ void DeviceMemoryManager::ArmReadTraps(DAddr addr, size_t size) { release_run(); } +template +void DeviceMemoryManager::ReleaseMemory(DAddr address, size_t size) { + // Runs of device pages whose backing is contiguous, released a run at a time. + size_t run_begin = 0; + size_t run_size = 0; + const auto flush = [&] { + if (run_size != 0) { + device_memory_ptr->buffer.ReleaseBackingRegion(run_begin, run_size); + run_size = 0; + } + }; + const DAddr first = Common::AlignUp(address, Memory::YUZU_PAGESIZE); + const DAddr last = Common::AlignDown(address + size, Memory::YUZU_PAGESIZE); + for (DAddr page = first; page < last; page += Memory::YUZU_PAGESIZE) { + const u8* const pointer = GetPointer(page); + if (pointer == nullptr) { + flush(); + continue; + } + const size_t offset = static_cast(reinterpret_cast(pointer) - physical_base); + if (run_size != 0 && run_begin + run_size == offset) { + run_size += Memory::YUZU_PAGESIZE; + } else { + flush(); + run_begin = offset; + run_size = Memory::YUZU_PAGESIZE; + } + } + flush(); +} + } // namespace Core diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 2a77bdb9b6..5b9c6bd7dd 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1614,6 +1614,11 @@ bool AccelerateDMA::DmaBufferImageCopy(const Tegra::DMA::ImageCopy& copy_info, if (image_id == VideoCommon::NULL_IMAGE_ID) { return false; } + // A host image without its top levels takes no copy by guest level: the emulated copy goes + // through guest memory, and the image uploads the levels it keeps from there. + if (texture_cache.IsTrimmed(image_id)) { + return false; + } const u32 buffer_size = static_cast(buffer_operand.pitch * buffer_operand.height); static constexpr auto sync_info = VideoCommon::ObtainBufferSynchronize::FullSynchronize; const auto post_op = IS_IMAGE_UPLOAD ? VideoCommon::ObtainBufferOperation::DoNothing diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 66b8b1a2e4..595b0a9d92 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -407,11 +407,31 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM return limit; } +// reduce_texture_resolution: a large compressed texture keeps its levels from 1 on, a quarter of +// the memory. Compressed formats are never render targets or storage images, so the GPU only +// samples them; copies by guest level are kept off them (IsSafeGpuCopy, AccelerateDMA). +[[nodiscard]] bool ReducesResolution(const ImageInfo& info) { + using VideoCore::Surface::IsPixelFormatASTC; + using VideoCore::Surface::IsPixelFormatBCn; + return Settings::values.reduce_texture_resolution.GetValue() && info.type == ImageType::e2D && + info.resources.levels >= 2 && info.resources.layers <= 6 && info.num_samples == 1 && + (std::min)(info.size.width, info.size.height) >= 1024 && + (IsPixelFormatASTC(info.format) || IsPixelFormatBCn(info.format)); +} + [[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, u32& level_offset) { level_offset = 0; if (info.type != ImageType::e2D) { return info; } + if (ReducesResolution(info)) { + level_offset = 1; + ImageInfo host = info; + host.size.width = (std::max)(info.size.width >> 1, 1U); + host.size.height = (std::max)(info.size.height >> 1, 1U); + host.resources.levels -= 1; + return host; + } if (!WillUseAcceleratedAstcDecode(device, info)) { // Too big for the host to create at all (Mario Kart World's 8192x8192x68 BC6H virtual // texture is 6 GB, the Adreno limit 4 GB): keep its lower levels, as for ASTC below. diff --git a/src/video_core/texture_cache/image_base.cpp b/src/video_core/texture_cache/image_base.cpp index 3185dd8428..387a66259c 100644 --- a/src/video_core/texture_cache/image_base.cpp +++ b/src/video_core/texture_cache/image_base.cpp @@ -128,6 +128,10 @@ bool ImageBase::IsSafeGpuCopy() const noexcept { if (False(flags & ImageFlagBits::GpuModified)) { return false; } + // Their host levels are not the guest's (TrimmedLevels): copies by level would land wrong + if (True(flags & ImageFlagBits::TrimmedLevels)) { + return false; + } // Skip images that .are. modified from the CPU // We don't want to write sensitive data from the guest if (True(flags & ImageFlagBits::CpuModified)) { diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 93f1854d2b..c60cadb54d 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -1731,6 +1731,8 @@ void TextureCache

::RefreshContents(Image& image, ImageId image_id) { UploadImageContents(image, staging); runtime.InsertUploadMemoryBarrier(); } + // Before the cold hint: memory given back is not hinted (the image is GPU modified then). + ReleaseGpuOnlyMemory(image); // The host GPU has its own copy now and the guest rarely reads this memory again: TickFrame // marks it cold, so the OS pages it out before anything else when it needs RAM (title 1 // streams several GB of textures through it). @@ -1782,6 +1784,27 @@ bool TextureCache

::UploadInChunks(Image& image) { return true; } +template +void TextureCache

::ReleaseGpuOnlyMemory(Image& image) { + // release_gpu_only_memory: a Switch 2 texture in GPU-only memory is never read by the guest + // CPU, and once uploaded its host image holds the data. Marked GPU modified, so whatever + // reads that memory through the caches downloads it back first (flushes, eviction), its + // backing is given back: Mario Kart World kept textures there twice, in RAM and on the GPU. + // Only images the host keeps byte for byte as the guest does, so a download restores them. + if (!Settings::values.release_gpu_only_memory.GetValue() || + !Settings::CurrentProgramIsSwitch2() || image.info.num_samples != 1 || + True(image.flags & (ImageFlagBits::Converted | ImageFlagBits::AcceleratedUpload | + ImageFlagBits::TrimmedLevels | ImageFlagBits::SparseLayers | + ImageFlagBits::Unbacked | ImageFlagBits::Sparse | + ImageFlagBits::Remapped | ImageFlagBits::Rescaled | + ImageFlagBits::CpuModified)) || + !Tegra::Nx2InSparseRegion(image.gpu_addr, image.guest_size_bytes)) { + return; + } + MarkModification(image); + device_memory.ReleaseMemory(image.cpu_addr, image.guest_size_bytes); +} + template bool TextureCache

::UploadsByLayer(const ImageBase& image) const noexcept { // Title 1's terrain arrays (8192x8192x68 ASTC, 1.45 GB each) get a few layers streamed in at a diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h index 06fcc0de77..f6c8cf2524 100644 --- a/src/video_core/texture_cache/texture_cache_base.h +++ b/src/video_core/texture_cache/texture_cache_base.h @@ -195,6 +195,10 @@ class TextureCache : public VideoCommon::ChannelSetupCaches BindSparseLayers(Image& image, std::span layers); + /// release_gpu_only_memory: gives back the guest memory of an uploaded image in Switch 2 + /// GPU-only memory, the host image holding the data from then on. + void ReleaseGpuOnlyMemory(Image& image); + /// Uploads `layers` of the image from guest memory. void UploadLayers(Image& image, std::span layers); @@ -320,6 +324,11 @@ class TextureCache : public VideoCommon::ChannelSetupCaches DmaBufferImageCopy( const Tegra::DMA::ImageCopy& copy_info, const Tegra::DMA::BufferOperand& buffer_operand, const Tegra::DMA::ImageOperand& image_operand, ImageId image_id, bool modifies_image); From 95a00ff5e0f3731c07ff2149c35170f0d3494800 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:28:26 -0500 Subject: [PATCH 058/154] host1x: Bounds check syncpoint ids Syncpoint ids come from guest registers (16 bits in the 3D engine's sync_info, 24 in the puller's fence action) and indexed the 192 entry syncpoint and action arrays unchecked. Mario Kart World loading a race signalled one past them: the increment landed in other state and the action walk read a zeroed list, crashing GPUBack in SyncpointManager::Increment (fault address 0x10). Out of range ids are now reported a few times and treated as always signalled: increments and waits are ignored, actions run at once. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit bf0c4c66cc0c4f9a048f81349b8993f54b8c4cf2) --- src/video_core/host1x/syncpoint_manager.cpp | 31 +++++++++++++++++++++ src/video_core/host1x/syncpoint_manager.h | 23 ++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/video_core/host1x/syncpoint_manager.cpp b/src/video_core/host1x/syncpoint_manager.cpp index d2afc02f55..955c3c304e 100644 --- a/src/video_core/host1x/syncpoint_manager.cpp +++ b/src/video_core/host1x/syncpoint_manager.cpp @@ -4,12 +4,25 @@ // SPDX-FileCopyrightText: 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +#include "common/logging.h" #include "video_core/host1x/syncpoint_manager.h" namespace Tegra { namespace Host1x { +bool SyncpointManager::IsValid(u32 id) { + if (id < NUM_MAX_SYNCPOINTS) [[likely]] { + return true; + } + // Reported a few times: a bad id is usually a misread register, and it repeats every frame. + static std::atomic reports{}; + if (reports.fetch_add(1, std::memory_order_relaxed) < 16) { + LOG_ERROR(HW_GPU, "Syncpoint id {} out of range (max {}), ignored", id, NUM_MAX_SYNCPOINTS); + } + return false; +} + SyncpointManager::ActionHandle SyncpointManager::RegisterAction( std::atomic& syncpoint, std::list& action_storage, u32 expected_value, std::function&& action) { @@ -50,28 +63,46 @@ void SyncpointManager::DeregisterAction(std::list& action_stor } void SyncpointManager::DeregisterGuestAction(u32 syncpoint_id, const ActionHandle& handle) { + if (!IsValid(syncpoint_id)) { + return; + } DeregisterAction(guest_action_storage[syncpoint_id], handle); } void SyncpointManager::DeregisterHostAction(u32 syncpoint_id, const ActionHandle& handle) { + if (!IsValid(syncpoint_id)) { + return; + } DeregisterAction(host_action_storage[syncpoint_id], handle); } void SyncpointManager::IncrementGuest(u32 syncpoint_id) { + if (!IsValid(syncpoint_id)) { + return; + } Increment(syncpoints_guest[syncpoint_id], wait_guest_cv, guest_waiters, guest_action_storage[syncpoint_id]); } void SyncpointManager::IncrementHost(u32 syncpoint_id) { + if (!IsValid(syncpoint_id)) { + return; + } Increment(syncpoints_host[syncpoint_id], wait_host_cv, host_waiters, host_action_storage[syncpoint_id]); } void SyncpointManager::WaitGuest(u32 syncpoint_id, u32 expected_value) { + if (!IsValid(syncpoint_id)) { + return; + } Wait(syncpoints_guest[syncpoint_id], wait_guest_cv, guest_waiters, expected_value); } void SyncpointManager::WaitHost(u32 syncpoint_id, u32 expected_value) { + if (!IsValid(syncpoint_id)) { + return; + } Wait(syncpoints_host[syncpoint_id], wait_host_cv, host_waiters, expected_value); } diff --git a/src/video_core/host1x/syncpoint_manager.h b/src/video_core/host1x/syncpoint_manager.h index 607367dab2..27498e2604 100644 --- a/src/video_core/host1x/syncpoint_manager.h +++ b/src/video_core/host1x/syncpoint_manager.h @@ -20,12 +20,17 @@ class SyncpointManager { public: static constexpr size_t NUM_MAX_SYNCPOINTS = 192; + /// Whether `id` names a syncpoint. Ids come from guest registers (16 bits in the 3D engine's + /// sync_info, 24 in the puller's fence action) and index fixed arrays; out of range ones are + /// reported and treated as always signalled instead of reaching past them. + static bool IsValid(u32 id); + u32 GetGuestSyncpointValue(u32 id) const { - return syncpoints_guest[id].load(std::memory_order_acquire); + return IsValid(id) ? syncpoints_guest[id].load(std::memory_order_acquire) : 0; } u32 GetHostSyncpointValue(u32 id) const { - return syncpoints_host[id].load(std::memory_order_acquire); + return IsValid(id) ? syncpoints_host[id].load(std::memory_order_acquire) : 0; } struct RegisteredAction { @@ -38,12 +43,20 @@ class SyncpointManager { template ActionHandle RegisterGuestAction(u32 syncpoint_id, u32 expected_value, Func&& action) { + if (!IsValid(syncpoint_id)) { + action(); + return {}; + } return RegisterAction(syncpoints_guest[syncpoint_id], guest_action_storage[syncpoint_id], expected_value, std::move(action)); } template ActionHandle RegisterHostAction(u32 syncpoint_id, u32 expected_value, Func&& action) { + if (!IsValid(syncpoint_id)) { + action(); + return {}; + } return RegisterAction(syncpoints_host[syncpoint_id], host_action_storage[syncpoint_id], expected_value, std::move(action)); } @@ -61,11 +74,13 @@ class SyncpointManager { void WaitHost(u32 syncpoint_id, u32 expected_value); bool IsReadyGuest(u32 syncpoint_id, u32 expected_value) const { - return syncpoints_guest[syncpoint_id].load(std::memory_order_acquire) >= expected_value; + return !IsValid(syncpoint_id) || + syncpoints_guest[syncpoint_id].load(std::memory_order_acquire) >= expected_value; } bool IsReadyHost(u32 syncpoint_id, u32 expected_value) const { - return syncpoints_host[syncpoint_id].load(std::memory_order_acquire) >= expected_value; + return !IsValid(syncpoint_id) || + syncpoints_host[syncpoint_id].load(std::memory_order_acquire) >= expected_value; } private: From fdaa3943bdafaf25d37214913ed3370f9949dcf3 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:06:39 -0500 Subject: [PATCH 059/154] video_core: Fix two crashes in Mario Kart World texture_cache: Image to image copies by guest level (joining an image with a GPU-modified overlap, aliases) went to trimmed host images unchanged. With reduce_texture_resolution most large compressed textures keep only their levels from 1 on, so a copy of level 0 targeted a level the host image doesn't have, and Turnip crashed in the Vulkan worker. Levels are now renumbered to each side's host levels, and copies of levels a side doesn't keep are dropped. transform_feedback: the varying count comes from guest registers and indexed the 32 stream-out layout entries (128 attributes) unchecked; building a pipeline read past its key and crashed the GPU back thread. It is capped at 128. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 57ca296a29d7282f9247a5c413ffe24de2138a86) --- src/video_core/texture_cache/texture_cache.h | 38 +++++++++++++++++--- src/video_core/transform_feedback.cpp | 5 ++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index c60cadb54d..1de868ccd7 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -66,6 +66,29 @@ void TrimHostLevels(u32 host_level_offset, Copies& copies) { } } +// Image to image copies by guest level, either side possibly keeping only levels >= its +// host_level_offset: levels renumbered to each side's host levels (same extents), and copies of +// levels a side doesn't keep dropped. Copying a level a host image lacks is invalid Vulkan, and +// Turnip crashed on it once reduce_texture_resolution trimmed most compressed textures. +template +void TrimImageCopies(u32 dst_level_offset, u32 src_level_offset, Copies& copies) { + if (dst_level_offset == 0 && src_level_offset == 0) { + return; + } + const s32 dst_skip = static_cast(dst_level_offset); + const s32 src_skip = static_cast(src_level_offset); + copies.erase(std::remove_if(copies.begin(), copies.end(), + [dst_skip, src_skip](const auto& copy) { + return copy.dst_subresource.base_level < dst_skip || + copy.src_subresource.base_level < src_skip; + }), + copies.end()); + for (auto& copy : copies) { + copy.dst_subresource.base_level -= dst_skip; + copy.src_subresource.base_level -= src_skip; + } +} + template TextureCache

::TextureCache(Runtime& runtime_, Tegra::MaxwellDeviceMemoryManager& device_memory_) : runtime{runtime_}, device_memory{device_memory_} { @@ -2766,10 +2789,13 @@ ImageId TextureCache

::JoinImages(const ImageInfo& info, GPUVAddr gpu_addr, DA const u32 up_scale = can_rescale ? resolution.up_scale : 1; const u32 down_shift = can_rescale ? resolution.down_shift : 0; auto copies = MakeShrinkImageCopies(new_info, overlap.info, base, up_scale, down_shift); - if (overlap.info.num_samples != new_image.info.num_samples) { - runtime.CopyImageMSAA(new_image, overlap, copies); - } else { - runtime.CopyImage(new_image, overlap, copies); + TrimImageCopies(new_image.host_level_offset, overlap.host_level_offset, copies); + if (!copies.empty()) { + if (overlap.info.num_samples != new_image.info.num_samples) { + runtime.CopyImageMSAA(new_image, overlap, copies); + } else { + runtime.CopyImage(new_image, overlap, copies); + } } new_image.modification_tick = overlap.modification_tick; } @@ -3672,6 +3698,10 @@ void TextureCache

::CopyImage(ImageId dst_id, ImageId src_id, std::vector, u32> MakeTransformF for (size_t buffer = 0; buffer < state.layouts.size(); ++buffer) { const auto& locations = state.varyings[buffer]; const auto& layout = state.layouts[buffer]; - const u32 varying_count = layout.varying_count; + // From guest registers: at most the 128 attributes `locations` holds (Mario Kart World + // read past the pipeline key with more). + const u32 varying_count = + (std::min)(layout.varying_count, static_cast(locations.size() * 4)); u32 highest = 0; for (u32 offset = 0; offset < varying_count; ++offset) { const auto get_attribute = [&locations](u32 index) -> u32 { From 30e775a3b07fc769ccc77a5cebdf8b0281c819ae Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:50:30 -0500 Subject: [PATCH 060/154] kernel: Give freed guest memory back to the host Pages whose last reference went returned to the kernel's free lists but stayed resident on the host until reused. Mario Kart World's guest memory grew with every course it loaded, the previous courses' memory freed but resident, until a phone killed it moving on to the third. KMemoryManager::Close now releases the backing of runs of 16 or more freed pages (punching a hole in the memfd), under the pool lock before they can be allocated again. They read as zero, which freed memory may. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 9718e3b32da8486bfe03041c7b0c6ac0efa03b37) --- src/core/hle/kernel/k_memory_manager.cpp | 12 ++++++++++++ src/core/hle/kernel/k_memory_manager.h | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/core/hle/kernel/k_memory_manager.cpp b/src/core/hle/kernel/k_memory_manager.cpp index bed3dd819d..b6606fd29b 100644 --- a/src/core/hle/kernel/k_memory_manager.cpp +++ b/src/core/hle/kernel/k_memory_manager.cpp @@ -450,6 +450,18 @@ Result KMemoryManager::AllocateForProcess(KPageGroup* out, size_t num_pages, u32 R_SUCCEED(); } +void KMemoryManager::ReleaseFreedPages(KPhysicalAddress address, size_t num_pages) { + // Freed guest memory stayed resident until reused: Mario Kart World's memory grew with every + // course it loaded (the last ones' regions freed but resident) until a phone killed it on the + // third. Small runs (thread stacks and the like) are left, not worth the system call. + constexpr size_t MinPages = 16; + if (num_pages < MinPages) { + return; + } + m_system.DeviceMemory().buffer.ReleaseBackingRegion( + GetInteger(address) - Core::DramMemoryMap::Base, num_pages * PageSize); +} + size_t KMemoryManager::Impl::Initialize(KPhysicalAddress address, size_t size, KVirtualAddress management, KVirtualAddress management_end, Pool p) { diff --git a/src/core/hle/kernel/k_memory_manager.h b/src/core/hle/kernel/k_memory_manager.h index 66b97fef8a..42cc144323 100644 --- a/src/core/hle/kernel/k_memory_manager.h +++ b/src/core/hle/kernel/k_memory_manager.h @@ -110,7 +110,9 @@ class KMemoryManager { { KScopedLightLock lk(m_pool_locks[static_cast(manager.GetPool())]); - manager.Close(address, cur_pages); + manager.Close(address, cur_pages, [this](KPhysicalAddress freed, size_t count) { + ReleaseFreedPages(freed, count); + }); } num_pages -= cur_pages; @@ -291,12 +293,19 @@ class KMemoryManager { } } - void Close(KPhysicalAddress address, size_t num_pages) { + // `on_free` sees each run of pages whose last reference went, before they're free. + template + void Close(KPhysicalAddress address, size_t num_pages, OnFree&& on_free) { size_t index = this->GetPageOffset(address); const size_t end = index + num_pages; size_t free_start = 0; size_t free_count = 0; + const auto free_run = [&] { + const KPhysicalAddress run = m_heap.GetAddress() + free_start * PageSize; + on_free(run, free_count); + this->Free(run, free_count); + }; while (index < end) { ASSERT(m_page_reference_counts[index] > 0); const RefCount ref_count = (--m_page_reference_counts[index]); @@ -311,7 +320,7 @@ class KMemoryManager { } } else { if (free_count > 0) { - this->Free(m_heap.GetAddress() + free_start * PageSize, free_count); + free_run(); free_count = 0; } } @@ -320,7 +329,7 @@ class KMemoryManager { } if (free_count > 0) { - this->Free(m_heap.GetAddress() + free_start * PageSize, free_count); + free_run(); } } @@ -367,6 +376,10 @@ class KMemoryManager { template using PoolArray = std::array(Pool::Count)>; + // Gives the host memory behind pages whose last reference just went back to the system, + // under the pool lock before they can be allocated again. They read as zero from then on. + void ReleaseFreedPages(KPhysicalAddress address, size_t num_pages); + Core::System& m_system; const KMemoryLayout& m_memory_layout; PoolArray m_pool_locks; From bb42be1dfcad06717d66cee659ec088221298053 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:50:31 -0500 Subject: [PATCH 061/154] buffer_cache: Clamp oversized native vertex streams instead of throwing A native vertex stream size past 32 bits (a misread or stale register) threw on the GPU thread, which aborted Mario Kart World as it booted. The size is now clamped, bounded by the mapped range like any other large stream, and reported a few times. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 30e1f554647951cbf14125eb0cbe02ddceadb408) --- src/video_core/buffer_cache/buffer_cache.h | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 31013e56c1..d2a83be8e1 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -1629,9 +1629,16 @@ void BufferCache

::UpdateVertexBuffer(u32 index) { const auto& limit = maxwell3d->regs.vertex_stream_limits[index]; const GPUVAddr gpu_addr_begin = array.Address(); const bool native = maxwell3d->GetNativeMacroEngine().native_turing; - const u64 native_size = native ? maxwell3d->regs.NativeVertexStreamSize(index) : 0; + u64 native_size = native ? maxwell3d->regs.NativeVertexStreamSize(index) : 0; if (native && array.enable != 0 && native_size > (std::numeric_limits::max)()) { - throw std::runtime_error("Native vertex stream exceeds supported 32-bit buffer size"); + // A misread or stale size: bounded by what's mapped below instead. Thrown on the GPU + // thread, it aborted Mario Kart World as it booted. + static std::atomic reports{}; + if (reports.fetch_add(1, std::memory_order_relaxed) < 8) { + LOG_WARNING(HW_GPU, "Native vertex stream {} size {:#x} exceeds 32 bits, clamped", index, + native_size); + } + native_size = (std::numeric_limits::max)(); } const GPUVAddr gpu_addr_end = native ? gpu_addr_begin + native_size : limit.Address() + 1; const std::optional device_addr = gpu_memory->GpuToCpuAddress(gpu_addr_begin); From 4d84fc07fd0ffea73e30132bb6aa804f992f143a Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:06:32 -0500 Subject: [PATCH 062/154] vulkan: reduce_texture_resolution also halves texture arrays Arrays of more than six layers stayed at full size, and so did images the host limits already trimmed. Mario Kart World kept 5.5 GB of textures, over the texture cache's critical 4 GB, and phones killed it loading its third course. The limits' trimming now comes first, and reduce_texture_resolution halves whatever they kept whole, arrays included (trimmed arrays already exist for the terrain textures). The texture cache's largest images and memory by format, logged every 600 frames, are at info level now, to see what holds the memory. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit e8084e653b2397fd8b79a9ed8c6632179af3e419) --- .../renderer_vulkan/vk_texture_cache.cpp | 28 ++++++++++++------- src/video_core/texture_cache/texture_cache.h | 6 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 595b0a9d92..78e127c1e7 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -414,24 +414,18 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM using VideoCore::Surface::IsPixelFormatASTC; using VideoCore::Surface::IsPixelFormatBCn; return Settings::values.reduce_texture_resolution.GetValue() && info.type == ImageType::e2D && - info.resources.levels >= 2 && info.resources.layers <= 6 && info.num_samples == 1 && + info.resources.levels >= 2 && info.num_samples == 1 && (std::min)(info.size.width, info.size.height) >= 1024 && (IsPixelFormatASTC(info.format) || IsPixelFormatBCn(info.format)); } -[[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, u32& level_offset) { +// The host image within the host's limits: `info`, or its lower levels (level_offset > 0). +[[nodiscard]] ImageInfo HostInfoWithinLimits(const Device& device, const ImageInfo& info, + u32& level_offset) { level_offset = 0; if (info.type != ImageType::e2D) { return info; } - if (ReducesResolution(info)) { - level_offset = 1; - ImageInfo host = info; - host.size.width = (std::max)(info.size.width >> 1, 1U); - host.size.height = (std::max)(info.size.height >> 1, 1U); - host.resources.levels -= 1; - return host; - } if (!WillUseAcceleratedAstcDecode(device, info)) { // Too big for the host to create at all (Mario Kart World's 8192x8192x68 BC6H virtual // texture is 6 GB, the Adreno limit 4 GB): keep its lower levels, as for ASTC below. @@ -490,6 +484,20 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM return host; } +// The host image: within the host's limits, and at half resolution with reduce_texture_resolution +// where the limits kept every level (texture arrays too, which stayed full size before: Mario +// Kart World kept 5.5 GB of textures and phones killed it loading its third course). +[[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, u32& level_offset) { + ImageInfo host = HostInfoWithinLimits(device, info, level_offset); + if (level_offset == 0 && ReducesResolution(info)) { + level_offset = 1; + host.size.width = (std::max)(info.size.width >> 1, 1U); + host.size.height = (std::max)(info.size.height >> 1, 1U); + host.resources.levels -= 1; + } + return host; +} + [[nodiscard]] VkImageCreateInfo MakeImageCreateInfo(const Device& device, const ImageInfo& info, std::optional format_override = {}) { auto format_info = diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 1de868ccd7..a10859fcab 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -446,9 +446,9 @@ void TextureCache

::TickFrame() { image_bytes >> 20); std::partial_sort(largest.begin(), largest.begin() + std::min(12, largest.size()), largest.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); - for (size_t i = 0; i < std::min(12, largest.size()); ++i) { + for (size_t i = 0; i < std::min(8, largest.size()); ++i) { const Image& image = *largest[i].second; - LOG_DEBUG(HW_GPU, " {} MiB: {:#x} {}x{}x{} layers {} levels {} format {} type {} flags {:#x}", + LOG_INFO(HW_GPU, " {} MiB: {:#x} {}x{}x{} layers {} levels {} format {} type {} flags {:#x}", largest[i].first >> 20, image.gpu_addr, image.info.size.width, image.info.size.height, image.info.size.depth, image.info.resources.layers, image.info.resources.levels, static_cast(image.info.format), static_cast(image.info.type), @@ -458,7 +458,7 @@ void TextureCache

::TickFrame() { for (const auto& [format, total] : by_format) formats.emplace_back(total.first, format); std::sort(formats.rbegin(), formats.rend()); for (size_t i = 0; i < std::min(8, formats.size()); ++i) { - LOG_DEBUG(HW_GPU, " format {}: {} MiB in {} images", formats[i].second, formats[i].first >> 20, + LOG_INFO(HW_GPU, " format {}: {} MiB in {} images", formats[i].second, formats[i].first >> 20, by_format[formats[i].second].second); } if constexpr (requires { runtime.LogMemoryStatistics(); }) { From a383a8bdf2a06e9efa0a8b7b515c14a177f2bcd4 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:24:15 -0500 Subject: [PATCH 063/154] texture_cache: release_gpu_only_memory covers whole GPU-only regions The setting only looked at images in sparse regions, and those are mostly the terrain arrays it skips: it released next to nothing. The regions nx2:gpu backs whole (7 GB in Mario Kart World, textures and render targets) are now tracked for the texture cache too (Nx2InGpuOnlyRegion), and images uploaded from them give back their guest memory. Images with trimmed host levels (reduce_texture_resolution) give back only the guest levels the host doesn't keep, most of their bytes: nothing reads those, and the levels the image keeps stay in guest memory to upload again after an eviction. Other images are released whole and marked GPU modified as before. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 587fe1fb9ba4965f748ed04e3b726cc1d8b72c5b) --- src/core/hle/service/nx2_gsv.h | 2 ++ src/video_core/nx2.h | 25 +++++++++++++++ src/video_core/texture_cache/texture_cache.h | 33 ++++++++++++++------ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index e2060c90a7..88ec858e28 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -640,6 +640,7 @@ class INx2GpuSession final : public ServiceFramework { start, last); } else if (const auto address = BackGpuOnlyRegion(start, size)) { Nvidia::Devices::Nx2GpuMemoryRegistry::Get().MapAt(start, *address, size); + Tegra::Nx2GpuOnlyRegionCreated(start, size); LOG_INFO(Service, "nx2:gpu 214 GPU-only region [{:#x}, {:#x}] backed at {:#x} " "({} MiB of GPU-only memory in use, peak {} MiB)", start, last, *address, m_gpu_only_bytes >> 20, m_gpu_only_peak >> 20); @@ -678,6 +679,7 @@ class INx2GpuSession final : public ServiceFramework { Tegra::Nx2SparseRegionDestroyed(first); Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DestroySparse(first, last - first + 1); } else { + Tegra::Nx2GpuOnlyRegionDestroyed(first); Nvidia::Devices::Nx2GpuMemoryRegistry::Get().Unmap(first, last - first + 1); } // The region's backing: all of it, or the blocks a sparse region got as it was written. diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index 8588101c15..cc15d4a361 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -301,6 +301,31 @@ inline bool Nx2InSparseRegion(u64 gpu_address, u64 size) { return gpu_address + size <= it->first + it->second.size; } +// GPU-only regions nx2:gpu backs whole (214 without the sparse flag), start -> size: the texture +// cache gives back the guest memory of images it uploaded from them (release_gpu_only_memory). +inline std::mutex g_nx2_gpu_only_mutex; +inline std::map g_nx2_gpu_only_regions; + +inline void Nx2GpuOnlyRegionCreated(u64 gpu_address, u64 size) { + std::scoped_lock lk{g_nx2_gpu_only_mutex}; + g_nx2_gpu_only_regions[gpu_address] = size; +} + +inline void Nx2GpuOnlyRegionDestroyed(u64 gpu_address) { + std::scoped_lock lk{g_nx2_gpu_only_mutex}; + g_nx2_gpu_only_regions.erase(gpu_address); +} + +inline bool Nx2InGpuOnlyRegion(u64 gpu_address, u64 size) { + std::scoped_lock lk{g_nx2_gpu_only_mutex}; + auto it = g_nx2_gpu_only_regions.upper_bound(gpu_address); + if (size == 0 || it == g_nx2_gpu_only_regions.begin()) { + return false; + } + --it; + return gpu_address + size <= it->first + it->second; +} + /// The GPU renders into [gpu_address, +size): count its blocks as written, so images made there /// later get memory (their contents live in the host image, not in guest memory). inline void Nx2SparseMarkWritten(u64 gpu_address, u64 size) { diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index a10859fcab..7c684b5716 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -1809,21 +1809,34 @@ bool TextureCache

::UploadInChunks(Image& image) { template void TextureCache

::ReleaseGpuOnlyMemory(Image& image) { - // release_gpu_only_memory: a Switch 2 texture in GPU-only memory is never read by the guest - // CPU, and once uploaded its host image holds the data. Marked GPU modified, so whatever - // reads that memory through the caches downloads it back first (flushes, eviction), its - // backing is given back: Mario Kart World kept textures there twice, in RAM and on the GPU. - // Only images the host keeps byte for byte as the guest does, so a download restores them. + // release_gpu_only_memory: a Switch 2 texture in GPU-only memory (regions nx2:gpu backs, whole + // or sparse) is never read by the guest CPU, and once uploaded its host image holds the data. + // Mario Kart World kept textures and render targets there twice, in RAM and on the GPU. if (!Settings::values.release_gpu_only_memory.GetValue() || !Settings::CurrentProgramIsSwitch2() || image.info.num_samples != 1 || True(image.flags & (ImageFlagBits::Converted | ImageFlagBits::AcceleratedUpload | - ImageFlagBits::TrimmedLevels | ImageFlagBits::SparseLayers | - ImageFlagBits::Unbacked | ImageFlagBits::Sparse | - ImageFlagBits::Remapped | ImageFlagBits::Rescaled | - ImageFlagBits::CpuModified)) || - !Tegra::Nx2InSparseRegion(image.gpu_addr, image.guest_size_bytes)) { + ImageFlagBits::SparseLayers | ImageFlagBits::Unbacked | + ImageFlagBits::Sparse | ImageFlagBits::Remapped | + ImageFlagBits::Rescaled | ImageFlagBits::CpuModified)) || + !(Tegra::Nx2InGpuOnlyRegion(image.gpu_addr, image.guest_size_bytes) || + Tegra::Nx2InSparseRegion(image.gpu_addr, image.guest_size_bytes))) { return; } + if (True(image.flags & ImageFlagBits::TrimmedLevels)) { + // Only the levels the host doesn't keep (most of the bytes): nothing reads them, and the + // image uploads the levels it keeps from the guest memory left, after an eviction too. + const LevelArray offsets = CalculateMipLevelOffsets(image.info); + const u64 dropped = offsets[image.host_level_offset]; + const u64 layer_stride = + image.info.resources.layers > 1 ? image.info.layer_stride : image.guest_size_bytes; + for (s32 layer = 0; layer < image.info.resources.layers; ++layer) { + device_memory.ReleaseMemory(image.cpu_addr + layer * layer_stride, dropped); + } + return; + } + // The whole image, marked GPU modified: whatever reads that memory through the caches + // (flushes, eviction) downloads it back first. Only images the host keeps byte for byte as + // the guest does, so a download restores them. MarkModification(image); device_memory.ReleaseMemory(image.cpu_addr, image.guest_size_bytes); } From dce1c356694a0285dd689dc20729074387309831 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:06:04 -0500 Subject: [PATCH 064/154] texture_cache: Count what host images really take The collector's memory total counted each image at its guest size: a trimmed image (reduce_texture_resolution) at four times what it takes, an unbacked one in full though it takes nothing. In Mario Kart World the total sat over the critical limit for whole races, and the collector evicted and re-created thousands of textures every 10 seconds, re-uploading them. Images now count their host image's memory requirements where the runtime reports them (Vulkan; none while unbacked), recorded at registration so unregistering takes off the same amount. The texture cache's periodic breakdown reports the same sizes. On upstream: the host size goes through upstream's ImageMemoryBytes instead of a size recorded at registration. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit a7547ab08abb5db1ddcc5e5939076483e97a3645) --- src/video_core/renderer_vulkan/vk_texture_cache.h | 12 ++++++++++++ src/video_core/texture_cache/texture_cache.h | 11 ++++++++++- src/video_core/vulkan_common/vulkan_wrapper.h | 7 +++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.h b/src/video_core/renderer_vulkan/vk_texture_cache.h index 26081a4c72..df3d3d79eb 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.h +++ b/src/video_core/renderer_vulkan/vk_texture_cache.h @@ -478,6 +478,18 @@ class Image : public VideoCommon::ImageBase { /// layers that have memory afterwards (a cap per image can leave some out). std::vector BindLayers(std::span layers); + /// Host memory the image takes, for the texture cache's total (none while unbacked): a + /// trimmed or converted image takes nothing like its guest size. + [[nodiscard]] std::optional HostMemoryBytes() const noexcept { + if (!original_image) { + return std::nullopt; + } + if (True(flags & VideoCommon::ImageFlagBits::Unbacked)) { + return 0; + } + return original_image.MemorySize(); + } + /// SparseTiles images: gives the host sparse blocks under `rects` (and the mip tail, for /// levels in it) device memory where they have none yet. False if that failed. bool BindTiles(std::span rects); diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 7c684b5716..90c6d5666d 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -431,7 +431,7 @@ void TextureCache

::TickFrame() { std::vector> largest; std::map> by_format; for (const auto [image_id, image] : slot_images) { - const u64 bytes = GetScaledImageSizeBytes(*image); + const u64 bytes = ImageMemoryBytes(*image); image_bytes += bytes; ++images; if (debug_stats) { @@ -2311,6 +2311,15 @@ u64 TextureCache

::ImageMemoryBytes(const Image& image) const { } return 0; } + // What the host image really takes where the runtime knows it: a trimmed image + // (reduce_texture_resolution) takes a quarter of its guest size. Counted at guest size, Mario + // Kart World's trimmed terrain arrays kept the total over the critical limit for whole races, + // and the collector evicted (and re-uploaded) thousands of textures every few seconds. + if constexpr (requires { image.HostMemoryBytes(); }) { + if (const std::optional host_bytes = image.HostMemoryBytes()) { + return Common::AlignUp(*host_bytes, 1024); + } + } u64 tentative_size = (std::max)(image.guest_size_bytes, image.unswizzled_size_bytes); if ((IsPixelFormatASTC(image.info.format) && True(image.flags & ImageFlagBits::AcceleratedUpload)) || diff --git a/src/video_core/vulkan_common/vulkan_wrapper.h b/src/video_core/vulkan_common/vulkan_wrapper.h index 57b3b30181..cd50153c4d 100644 --- a/src/video_core/vulkan_common/vulkan_wrapper.h +++ b/src/video_core/vulkan_common/vulkan_wrapper.h @@ -696,6 +696,13 @@ class Image { return handle != VK_NULL_HANDLE && allocation == VK_NULL_HANDLE; } + /// The memory the image needs, as the driver reports it. + [[nodiscard]] VkDeviceSize MemorySize() const noexcept { + VkMemoryRequirements requirements{}; + dld->vkGetImageMemoryRequirements(owner, handle, &requirements); + return requirements.size; + } + Image(const Image&) = delete; Image& operator=(const Image&) = delete; From 67c6d551520b8b5c035448d73107948266374e92 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:31:30 -0500 Subject: [PATCH 065/154] vulkan: Give staging buffers back sooner The staging pool visited one of its 64 size classes a frame and freed 16 buffers there at most: the buffers a burst of uploads made, loading a course in Mario Kart World, stayed for many seconds, on a phone whose GPU memory is its RAM. Of 4.8 GB of Vulkan memory at its fourth course, textures took 1.1 GB and buffers 1 GB. Every size class is trimmed every frame now: a few idle buffers of each size are kept for reuse (one of 16 MiB or more), and the rest go once the GPU is done with them and they sat unused for 64 submissions. The texture cache's periodic statistics report the pool's size. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit e6f27d4b5daa7d5fe513eb4ba040c29758ffccca) --- .../vk_staging_buffer_pool.cpp | 51 ++++++++++++++----- .../renderer_vulkan/vk_staging_buffer_pool.h | 3 ++ .../renderer_vulkan/vk_texture_cache.cpp | 4 ++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp index 98787b9add..9605787552 100644 --- a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp +++ b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp @@ -274,26 +274,49 @@ StagingBufferPool::StagingBuffersCache& StagingBufferPool::GetCache(MemoryUsage } void StagingBufferPool::ReleaseCache(MemoryUsage usage) { - ReleaseLevel(GetCache(usage), current_delete_level); + // Every size each frame: one of 64 per frame, 16 buffers at most, kept the buffers a burst of + // uploads made (Mario Kart World loading a course) for many seconds, on a phone whose GPU + // memory is its RAM. + auto& cache = GetCache(usage); + for (size_t log2 = 0; log2 < NUM_LEVELS; ++log2) { + if (!cache[log2].entries.empty()) { + ReleaseLevel(cache, log2); + } + } +} + +u64 StagingBufferPool::CachedBytes(MemoryUsage usage) const { + const auto& cache = usage == MemoryUsage::DeviceLocal ? device_local_cache + : usage == MemoryUsage::Upload ? upload_cache + : download_cache; + u64 bytes = 0; + for (size_t log2 = 0; log2 < NUM_LEVELS; ++log2) { + bytes += static_cast(cache[log2].entries.size()) << log2; + } + return bytes; } void StagingBufferPool::ReleaseLevel(StagingBuffersCache& cache, size_t log2) { - constexpr size_t deletions_per_tick = 16; + // A few idle buffers of each size stay for reuse (one of the big ones); the rest go once the + // GPU is done with them and they sat unused for a while (so steady uploads don't recreate + // buffers every frame). + constexpr u64 idle_ticks = 64; + const size_t keep = log2 >= 24 ? 1 : 4; auto& staging = cache[log2]; auto& entries = staging.entries; - const size_t old_size = entries.size(); - - const auto is_deletable = [this](const StagingBuffer& entry) { - return scheduler.IsFree(entry.tick); - }; - const size_t begin_offset = staging.delete_index; - const size_t end_offset = (std::min)(begin_offset + deletions_per_tick, old_size); - const auto begin = entries.begin() + begin_offset; - const auto end = entries.begin() + end_offset; - entries.erase(std::remove_if(begin, end, is_deletable), end); - + const u64 current_tick = scheduler.CurrentTick(); + size_t kept = 0; + std::erase_if(entries, [&](const StagingBuffer& entry) { + if (entry.deferred || !scheduler.IsFree(entry.tick)) { + return false; + } + if (current_tick - entry.tick < idle_ticks || kept < keep) { + ++kept; + return false; + } + return true; + }); const size_t new_size = entries.size(); - staging.delete_index += deletions_per_tick; if (staging.delete_index >= new_size) { staging.delete_index = 0; } diff --git a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.h b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.h index 6d967abb41..1446cd04cb 100644 --- a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.h +++ b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.h @@ -46,6 +46,9 @@ class StagingBufferPool { void TickFrame(); + /// Bytes of the staging buffers kept for `usage` (in use or cached for reuse). + [[nodiscard]] u64 CachedBytes(MemoryUsage usage) const; + private: struct StreamBufferCommit { size_t upper_bound; diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 78e127c1e7..3e8c3e31d9 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -2641,6 +2641,10 @@ bool TextureCacheRuntime::CanReportMemoryUsage() const { void TextureCacheRuntime::LogMemoryStatistics() const { Vulkan::LogMemoryStatistics(device); + LOG_INFO(Render_Vulkan, "Staging buffers: upload {} MiB, download {} MiB, device local {} MiB", + staging_buffer_pool.CachedBytes(MemoryUsage::Upload) >> 20, + staging_buffer_pool.CachedBytes(MemoryUsage::Download) >> 20, + staging_buffer_pool.CachedBytes(MemoryUsage::DeviceLocal) >> 20); } bool TextureCacheRuntime::UseOpaqueAstcFormat() const { From 08ba5d75e4c7b5ea8f02a6215e6cd23d992bb5b4 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:41:52 -0500 Subject: [PATCH 066/154] buffer_cache: Download only the buffer's own part of a range DownloadBufferRanges walked the whole range it was given, but callers pass the range of the request, which can reach past the buffer. The memory tracker and gpu_modified_ranges are shared by every buffer, so the first buffer got copies outside itself (offsets past its size, or underflowed below its start: on the direct-read path, reads past its host view), and the next buffer's GPU-written state was cleared without being downloaded. Each range is now clamped to the buffer, and each copy too. Found reviewing Djihads80/Eglantine PR #163 by eIBart0. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit b3c34a9bb3fb7e2bedb961c83a7344feb830db1a) --- src/video_core/buffer_cache/buffer_cache.h | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index d2a83be8e1..346e663a8c 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -2493,11 +2493,25 @@ void BufferCache

::DownloadBufferRanges(Buffer& buffer, boost::container::small_vector copies; u64 total_size_bytes = 0; u64 largest_copy = 0; - for (const auto& [device_addr, size] : ranges) { + const DAddr buffer_addr = buffer.CpuAddr(); + const DAddr buffer_end = buffer_addr + buffer.SizeBytes(); + for (const auto& [range_addr, range_size_in] : ranges) { + // Only this buffer's part: the tracker and gpu_modified_ranges are shared by every buffer, + // so a range past the buffer gave copies outside it (offsets past its size, or below its + // start) and cleared other buffers' GPU-written state without downloading it. + const DAddr range_begin = (std::max)(range_addr, buffer_addr); + const DAddr range_end = (std::min)(range_addr + range_size_in, buffer_end); + if (range_begin >= range_end) { + continue; + } memory_tracker.ForEachDownloadRangeAndClear( - device_addr, size, [&](u64 device_addr_out, u64 range_size) { - const DAddr buffer_addr = buffer.CpuAddr(); + range_begin, range_end - range_begin, [&](u64 device_addr_out, u64 range_size) { const auto add_download = [&](DAddr start, DAddr end) { + start = (std::max)(start, buffer_addr); + end = (std::min)(end, buffer_end); + if (start >= end) { + return; + } const u64 new_offset = start - buffer_addr; const u64 new_size = end - start; copies.push_back(BufferCopy{ From e5b543d8b8c7d096327ea3894d6dc56415a02449 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:26:51 -0500 Subject: [PATCH 067/154] vulkan: Keep no idle staging buffers of 16 MiB or more e6f27d4b5d kept one idle staging buffer of each size of 16 MiB or more for reuse. Sizes are powers of two, so one large image's upload made a 1 or 2 GiB buffer, and keeping one of each such size held the upload pool at 4 GiB for the rest of the session: on the Odin 3, Mario Kart World's Vulkan memory reached 10 GB a few courses in (6.2-7 GB before). Large buffers now go once the GPU is done with them and they sat unused for 64 submissions; up to four idle small ones are kept. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit d78ee37e74178a5d3a3d44d091186d9cfcd3abc9) --- .../renderer_vulkan/vk_staging_buffer_pool.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp index 9605787552..4864e8a1ee 100644 --- a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp +++ b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp @@ -297,11 +297,12 @@ u64 StagingBufferPool::CachedBytes(MemoryUsage usage) const { } void StagingBufferPool::ReleaseLevel(StagingBuffersCache& cache, size_t log2) { - // A few idle buffers of each size stay for reuse (one of the big ones); the rest go once the - // GPU is done with them and they sat unused for a while (so steady uploads don't recreate - // buffers every frame). + // A few idle buffers of each small size stay for reuse; the rest go once the GPU is done with + // them and they sat unused for a while (so steady uploads don't recreate buffers every frame). + // None of 16 MiB or more: power-of-two sizes made one image's upload a 1-2 GiB buffer, and + // keeping one of each such size held 4 GiB on a phone through Mario Kart World's races. constexpr u64 idle_ticks = 64; - const size_t keep = log2 >= 24 ? 1 : 4; + const size_t keep = log2 >= 24 ? 0 : 4; auto& staging = cache[log2]; auto& entries = staging.entries; const u64 current_tick = scheduler.CurrentTick(); From 56801154e72112eb97fddbeb4be54e1baf78968d Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:53:35 -0500 Subject: [PATCH 068/154] nvdrv: clear the GPU-only region registry with the sparse state A new session started with the previous game's GPU-only regions still registered, so the texture cache could release guest memory for images in address ranges the new game uses differently. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 125a69a88342d908fc5a7da30366a78e0f97073c) --- src/core/hle/service/nvdrv/nvdrv.cpp | 1 + src/video_core/nx2.h | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/core/hle/service/nvdrv/nvdrv.cpp b/src/core/hle/service/nvdrv/nvdrv.cpp index 7853d17235..1bc493ae56 100644 --- a/src/core/hle/service/nvdrv/nvdrv.cpp +++ b/src/core/hle/service/nvdrv/nvdrv.cpp @@ -134,6 +134,7 @@ Module::Module(Core::System& system) Tegra::Nx2ClearSemaphoreRegions(); Tegra::Nx2ClearSparseRegions(); Tegra::Nx2ClearGpuWrittenPages(); + Tegra::Nx2ClearGpuOnlyRegions(); Devices::g_nx2_driver = false; Tegra::g_nx2_ampere = false; Core::g_nx2_present_count = 0; diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index cc15d4a361..87a6ae1436 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -316,6 +316,12 @@ inline void Nx2GpuOnlyRegionDestroyed(u64 gpu_address) { g_nx2_gpu_only_regions.erase(gpu_address); } +/// A new emulation session: the previous game's GPU-only regions are gone too. +inline void Nx2ClearGpuOnlyRegions() { + std::scoped_lock lk{g_nx2_gpu_only_mutex}; + g_nx2_gpu_only_regions.clear(); +} + inline bool Nx2InGpuOnlyRegion(u64 gpu_address, u64 size) { std::scoped_lock lk{g_nx2_gpu_only_mutex}; auto it = g_nx2_gpu_only_regions.upper_bound(gpu_address); From 5d554bc65857dc22ef89f6a18917540299114039 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:59:10 -0500 Subject: [PATCH 069/154] vulkan: build task/mesh pipelines only with VK_EXT_mesh_shader task and mesh support The task/mesh series only skipped those draws in the rasterizer, after the pipeline cache had already handed the driver a pipeline with task and mesh stages. Without both features, CreateGraphicsPipeline now returns no pipeline for them (logged once), so the draw is skipped before the driver sees it. The device logs one line with the extension and its two features at boot. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit a4a5be2831fe1e5f7cb5c74336e6da05bb056da6) --- .../renderer_vulkan/vk_pipeline_cache.cpp | 16 ++++++++++++++++ src/video_core/vulkan_common/vulkan_device.cpp | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 64a9d84345..3e8be4d74b 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1252,6 +1253,21 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( layer_source_program = &programs[index]; } } + // Task and mesh programs need VK_EXT_mesh_shader with both stages: without it, don't hand the + // driver a pipeline it doesn't support (the rasterizer skips the draw). + if (!device.IsExtMeshShaderSupported() && + std::ranges::any_of(programs, [](const Shader::IR::Program& program) { + return program.info.is_task || program.info.is_mesh; + })) { + static bool reported{}; + if (!std::exchange(reported, true)) { + LOG_WARNING(Render_Vulkan, + "Task/mesh pipeline {:016x} skipped: no VK_EXT_mesh_shader task and mesh " + "support", + hash); + } + return nullptr; + } std::array infos{}; std::array modules; std::array, Maxwell::MaxShaderStage> spirv; // For a pipeline the driver rejects diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index 00f59f11a4..2506563ebf 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -1599,9 +1599,17 @@ void Device::RemoveUnsuitableExtensions() { features.mesh_shader.multiviewMeshShader = VK_FALSE; features.mesh_shader.primitiveFragmentShadingRateMeshShader = VK_FALSE; features.mesh_shader.meshShaderQueries = VK_FALSE; + const bool has_mesh_extension = extensions.mesh_shader; + const bool task_shader = features.mesh_shader.taskShader != VK_FALSE; + const bool mesh_shader = features.mesh_shader.meshShader != VK_FALSE; extensions.mesh_shader = features.mesh_shader.taskShader && features.mesh_shader.meshShader; RemoveExtensionFeatureIfUnsuitable(extensions.mesh_shader, features.mesh_shader, VK_EXT_MESH_SHADER_EXTENSION_NAME); + LOG_INFO(Render_Vulkan, + "VK_EXT_mesh_shader: extension {}, taskShader {}, meshShader {}: Switch 2 task/mesh " + "draws {}", + has_mesh_extension ? "present" : "absent", task_shader, mesh_shader, + extensions.mesh_shader ? "on" : "skipped"); // VK_KHR_shader_quad_control extensions.shader_quad_control = features.shader_quad_control.shaderQuadControl; From 5c262008154103e83eb257b6885a39af481eab81 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:07:51 -0500 Subject: [PATCH 070/154] buffer_cache: don't bind multi-range storage segments of deleted buffers Port of the parts of xDarkWindx/eden-fefw 8c6f0522b6 (Duck912, "Fix buffer-cache crash from stale multi-range storage segments") that apply to this branch's multi-range storage (it has no grouped storage): Resolving a later segment can join, and so delete, the buffer an earlier segment points at, but DeleteBuffer only cleared the regular bindings. The draw then touched the dead slot, which put its freed LRU item back in the list; the collector later deleted the slot a second time, wiping another buffer's page entries, and finally freed the LRU id of a buffer that was never registered (SIZE_MAX), crashing in LeastRecentlyUsedCache. - DeleteBuffer clears matching segment ids; the update passes resolve them again. - BindMultiRangeStorage refuses segments whose buffer is gone. - Unregistering frees the LRU item only for registered buffers, resets the id, and clears only the page entries the buffer owns. - TouchBuffer skips unregistered buffers. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 7ab039028e4689cb3479257c96b2dd5fe392d751) --- src/video_core/buffer_cache/buffer_cache.h | 51 +++++++++++++++++-- .../buffer_cache/buffer_cache_base.h | 2 + 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 346e663a8c..b0e45655a4 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -1299,6 +1299,15 @@ bool BufferCache

::BindMultiRangeStorage(const Binding& binding, bool is_writt if (binding.segment_first + binding.segment_count > pool.size()) { return false; } + // A segment whose buffer was joined into another (deleted) since it was resolved: binding + // the dead slot put its freed LRU item back in the list, and the collector later deleted + // the slot again, wiping another buffer's page entries. + for (u32 index = 0; index < binding.segment_count; ++index) { + const BufferId id = pool[binding.segment_first + index].buffer_id; + if (!id || !slot_buffers.contains(id)) [[unlikely]] { + return false; + } + } const u64 key = (static_cast(gpu_memory->GetID()) << 48) ^ binding.gpu_addr; runtime.ResetMultiRange(); for (u32 index = 0; index < binding.segment_count; ++index) { @@ -1544,6 +1553,7 @@ void BufferCache

::DoUpdateGraphicsBuffers(bool is_indexed) { if (current_draw_indirect) { UpdateDrawIndirect(); } + RefreshMultiRangeSegments(graphics_segments); }); } @@ -1554,9 +1564,31 @@ void BufferCache

::DoUpdateComputeBuffers() { UpdateComputeUniformBuffers(); UpdateComputeStorageBuffers(); UpdateComputeTextureBuffers(); + RefreshMultiRangeSegments(compute_segments); }); } +template +void BufferCache

::RefreshMultiRangeSegments(std::vector& pool) { + if constexpr (requires { runtime.BindMultiRangeStorageBuffer(u64{}, bool{}); }) { + // Resolving one segment can join, and so delete, the buffer of another (DeleteBuffer + // clears its id): repeat until every segment points at a live buffer. + for (int pass = 0; pass < 16; ++pass) { + bool resolved = false; + for (MultiRangeSegment& segment : pool) { + if (!segment.buffer_id) { + segment.buffer_id = FindBuffer(segment.device_addr, segment.size, + runtime.PrefersSparseSources()); + resolved = true; + } + } + if (!resolved) { + return; + } + } + } +} + template void BufferCache

::UpdateIndexBuffer() { // We have to check for the dirty flags and index count @@ -2217,7 +2249,11 @@ void BufferCache

::ChangeRegister(BufferId buffer_id) { } else { buffer_bytes -= Common::AlignUp(size, 1024); total_used_memory -= Common::AlignUp(size, 1024); - lru_cache.Free(buffer.getLRUID()); + // Only a registered buffer has an LRU item (freeing SIZE_MAX crashed in Free). + if (buffer.getLRUID() != SIZE_MAX) { + lru_cache.Free(buffer.getLRUID()); + buffer.setLRUID(SIZE_MAX); + } } const DAddr device_addr_begin = buffer.CpuAddr(); const DAddr device_addr_end = device_addr_begin + size; @@ -2226,7 +2262,8 @@ void BufferCache

::ChangeRegister(BufferId buffer_id) { for (u64 page = page_begin; page != page_end; ++page) { if constexpr (insert) { page_table[page] = buffer_id; - } else { + } else if (page_table[page] == buffer_id) { + // Only its own entries: another buffer's would go stale. page_table[page] = BufferId{}; } } @@ -2234,7 +2271,7 @@ void BufferCache

::ChangeRegister(BufferId buffer_id) { template void BufferCache

::TouchBuffer(Buffer& buffer, BufferId buffer_id) noexcept { - if (buffer_id != NULL_BUFFER_ID) { + if (buffer_id != NULL_BUFFER_ID && buffer.getLRUID() != SIZE_MAX) { lru_cache.Touch(buffer.getLRUID(), frame_tick); } } @@ -2608,6 +2645,14 @@ void BufferCache

::DeleteBuffer(BufferId buffer_id, bool do_not_mark) { channel_state->index_buffer.buffer_id = BufferId{}; dirty_index = true; } + // Multi-range storage segments too: the update pass resolves them again. + for (auto* const pool : {&graphics_segments, &compute_segments}) { + for (MultiRangeSegment& segment : *pool) { + if (segment.buffer_id == buffer_id) { + segment.buffer_id = BufferId{}; + } + } + } for (u32 index = 0; index < channel_state->vertex_buffers.size(); index++) { auto& binding = channel_state->vertex_buffers[index]; diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index e2e53b2c2b..23fbeeb7ab 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -492,6 +492,8 @@ class BufferCache : public VideoCommon::ChannelSetupCaches& pool); + bool SynchronizeBuffer(Buffer& buffer, DAddr device_addr, u32 size); void UploadMemory(Buffer& buffer, u64 total_size_bytes, u64 largest_copy, From 63401e8cfadc7bb5f54f4ce7392834465c17c5bc Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:07:58 -0500 Subject: [PATCH 071/154] vulkan: report the skipped task/mesh pipeline once across build threads Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit c5942422fdd9acd163ce703e0303603e936aae6a) --- src/video_core/renderer_vulkan/vk_pipeline_cache.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 3e8be4d74b..babb0ba2ec 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1259,8 +1259,9 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( std::ranges::any_of(programs, [](const Shader::IR::Program& program) { return program.info.is_task || program.info.is_mesh; })) { - static bool reported{}; - if (!std::exchange(reported, true)) { + // Pipelines are built on worker threads too. + static std::atomic_bool reported{}; + if (!reported.exchange(true, std::memory_order_relaxed)) { LOG_WARNING(Render_Vulkan, "Task/mesh pipeline {:016x} skipped: no VK_EXT_mesh_shader task and mesh " "support", From adfd57adddc3fa3c9c838af35afbc67dd29f72ac Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:17:32 -0500 Subject: [PATCH 072/154] nx2: decode AV1 movies with libgav1 (Fortune's Weave) Fire Emblem: Fortune's Weave plays its character select clips as AV1, which neither the bundled FFmpeg packages nor our decoder service handled: it crashed at character select and ran at 30 FPS. Ported from fefw's native video decoder (src/core/hle/service/am/service/native_video_decoder.cpp, fefw 0b1ba0db76 and 2106415948, db1bbf8782, e75ce0c202, 141643e8e8, dd31b32b74, c960d20ef3). Our tree already serves that IPC interface (gsv 902/906/1002 -> TIPC 1 -> nx2:vdecsvc, INx2VideoSession), which Mario Kart World's VP9 movies use, so fefw's service is not added next to it; its AV1 parts go into ours instead: - externals/libgav1: libgav1 c1deec657b by FetchContent, as fefw does, with std::mutex threading (no Abseil) and 8/10-bit only. Linked into video_core; only nx2_video_decoder.cpp sees its headers. - Nx2VideoDecoder decodes "video/av1" with libgav1 (up to 4 threads) and hands frames to the existing swscale Render, so NV12, RGBA and BGRA images and the stream's matrix and range work as for VP9. Decoding stays on the session's decode thread, ahead of the images (fefw c960d20ef3). - AV1 sessions only: Flush/Stop drops frames written but not fetched and gives their images back at the end of the sync sequence (also a frame being written during the flush); releasing an image that still waits for a frame answers with its existing sync value; submit replies the bytes taken; 213 words 1-2 carry the configured size. - Block-linear decoder images are swizzled in four bands of whole block rows on threads (fefw dd31b32b74). Only images the client maps on the GPU take this path. VP9, VP8 and H.264 sessions behave as before. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit efa2a538b9da6283a3320b28ff03263f48d5114c) --- externals/CMakeLists.txt | 3 + externals/libgav1/CMakeLists.txt | 22 ++++ src/core/hle/service/nx2_gsv.h | 109 ++++++++++++++++- src/video_core/CMakeLists.txt | 5 + src/video_core/host1x/nx2_video_decoder.cpp | 129 +++++++++++++++++++- src/video_core/host1x/nx2_video_decoder.h | 8 +- 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 externals/libgav1/CMakeLists.txt diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index cb1deceb96..a02e4b9215 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -282,6 +282,9 @@ if (ENABLE_WEB_SERVICE OR ENABLE_UPDATE_CHECKER) AddJsonPackage(cpp-jwt) endif() +# libgav1 (AV1 video decoding) +add_subdirectory(libgav1) + # FFMpeg if (YUZU_USE_EXTERNAL_FFMPEG OR YUZU_USE_BUNDLED_FFMPEG) add_subdirectory(ffmpeg) diff --git a/externals/libgav1/CMakeLists.txt b/externals/libgav1/CMakeLists.txt new file mode 100644 index 0000000000..9189c348e8 --- /dev/null +++ b/externals/libgav1/CMakeLists.txt @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +# libgav1 decodes the AV1 movies of Switch 2 titles (nx2 video decoder). The bundled FFmpeg +# packages have no AV1 decoder. + +include(FetchContent) + +set(LIBGAV1_ENABLE_TESTS OFF) +set(LIBGAV1_ENABLE_EXAMPLES OFF) +# std::mutex in its thread pool: no Abseil (it is only needed for absl::Mutex, tests and examples) +set(LIBGAV1_THREADPOOL_USE_STD_MUTEX 1) +# 8 and 10-bit streams only, which leaves the 12-bit DSP code out +set(LIBGAV1_MAX_BITDEPTH 10) +FetchContent_Declare(libgav1 + GIT_REPOSITORY https://chromium.googlesource.com/codecs/libgav1 + GIT_TAG c1deec657b32b911920c78e078cfd089faa77200 +) +FetchContent_MakeAvailable(libgav1) + +set_property(GLOBAL APPEND PROPERTY CPM_PACKAGE_NAMES libgav1) +set_property(GLOBAL APPEND PROPERTY CPM_PACKAGE_URLS https://chromium.googlesource.com/codecs/libgav1) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 88ec858e28..8c9c8bcc47 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2524,6 +2524,7 @@ class INx2VideoSession final : public ServiceFramework { w[6], w[7]); { std::scoped_lock lk{m_mutex}; + m_av1 = Tegra::Host1x::Nx2IsAv1Mime(codec); m_codec = std::move(codec); m_width = w[5]; m_height = w[6]; @@ -2576,6 +2577,17 @@ class INx2VideoSession final : public ServiceFramework { u32 value; { std::scoped_lock lk{m_mutex}; + // AV1 (fefw e75ce0c202): Fortune's Weave gives back images that still wait for a + // frame, in a loop after Flush/Start. Frames advance the syncpoint by one each in + // hand-out order, so a new value would leave a hole in the sequence and put every + // later fence out of reach: answer with the value the image already has. + if (m_av1 && release_fence && IsWaiting(image)) { + if ((++m_idle_releases & 0xfff) == 1) { + LOG_WARNING(Service, "{} release of waiting image {} (x{})", + GetServiceName(), image, m_idle_releases); + } + return {~m_syncpoint, 0, m_values[image], 0}; + } m_free.push_back(image); if (release_fence) { m_release_fences[image] = *release_fence; @@ -2598,11 +2610,27 @@ class INx2VideoSession final : public ServiceFramework { } else { value = m_sync_base + ++m_handed; } + m_values[image] = value; } m_cv.notify_one(); return {~m_syncpoint, 0, value, 0}; } + // With m_mutex held: the image waits for a frame. + bool IsWaiting(u32 image) const { + return m_writing == image || std::ranges::find(m_free, image) != m_free.end(); + } + + // With m_mutex held: an image that holds a frame waits for one again, at the end of the sync + // sequence (its old value was reached when the frame was written). + void Requeue(u32 image) { + if (IsWaiting(image)) { + return; + } + m_free.push_back(image); + m_values[image] = m_sync_base + ++m_handed; + } + void RegisterBuffer(HLERequestContext& ctx) { Log(ctx, "register output"); const auto handle = HandOut(ctx.CommandBuffer()[2], std::nullopt); @@ -2623,6 +2651,7 @@ class INx2VideoSession final : public ServiceFramework { std::scoped_lock lk{m_mutex}; std::erase(m_free, image); m_release_fences.erase(image); + m_values.erase(image); } TipcReplyWords(ctx, {}); } @@ -2640,6 +2669,7 @@ class INx2VideoSession final : public ServiceFramework { const auto data = ctx.ReadBufferA(0); input.data.assign(data.begin(), data.end()); } + const u64 size = input.data.size(); { std::scoped_lock lk{m_mutex}; m_inputs.push_back(std::move(input)); @@ -2648,7 +2678,9 @@ class INx2VideoSession final : public ServiceFramework { } m_cv.notify_one(); const u64 id = ++m_submitted; - TipcReplyWords(ctx, {static_cast(id), static_cast(id >> 32)}); + // AV1: the bytes taken, as fefw's decoder replies (Fortune's Weave plays with it). + const u64 reply = m_av1 ? size : id; + TipcReplyWords(ctx, {static_cast(reply), static_cast(reply >> 32)}); } // Flush (8) and Stop (207): drop queued input and frames not yet in an image. Images already @@ -2666,6 +2698,18 @@ class INx2VideoSession final : public ServiceFramework { if (ctx.GetCommand() == 207) { CompleteHandedFences(); } + if (m_av1) { + // fefw 2106415948/141643e8e8: Fortune's Weave flushes its character select clips + // when one ends or the character changes, then calls Start. Like + // MediaCodec.flush(), drop everything in flight: frames written but not fetched + // (the old clip's end of stream among them) made it take the new clip as + // finished. Their images wait for frames again. + ++m_flushes; + for (const auto& output : m_outputs) { + Requeue(output.image); + } + m_outputs.clear(); + } } m_cv.notify_one(); TipcReplyWords(ctx, {}); @@ -2697,11 +2741,16 @@ class INx2VideoSession final : public ServiceFramework { void GetOutputInfo(HLERequestContext& ctx) { Log(ctx, "output info"); std::optional output; + u32 width{}; + u32 height{}; { std::scoped_lock lk{m_mutex}; if (!m_outputs.empty()) { output = m_outputs.front(); m_outputs.pop_front(); + // AV1: words 1-2 hold the configured size, as fefw's decoder replies. + width = m_av1 ? m_width : output->width; + height = m_av1 ? m_height : output->height; } } if (!output) { @@ -2711,7 +2760,7 @@ class INx2VideoSession final : public ServiceFramework { return; } const u64 pts_ns = output->pts * 1000; - TipcReplyWords(ctx, {output->image, output->width, output->height, output->width, + TipcReplyWords(ctx, {output->image, width, height, output->width, output->height, 0, static_cast(pts_ns), static_cast(pts_ns >> 32), output->end ? 1U : 0U, 0, 0, 0, 0, 0, 0, 0}); @@ -2748,6 +2797,8 @@ class INx2VideoSession final : public ServiceFramework { m_decoded.pop_front(); const u32 image = m_free.front(); m_free.pop_front(); + m_writing = image; + const u64 flushes = m_flushes; std::optional fence; if (const auto it = m_release_fences.find(image); it != m_release_fences.end()) { fence = it->second; @@ -2759,7 +2810,14 @@ class INx2VideoSession final : public ServiceFramework { } const auto [width, height] = WriteImage(decoder.get(), image, frame, bytes); lk.lock(); + m_writing.reset(); // Even an image written during a flush counts: the client waits for its value. + // An AV1 flush drops the frame, though (see Flush): the image waits again. + if (m_av1 && m_flushes != flushes) { + Requeue(image); + system.Host1x().GetSyncpointManager().IncrementGuest(m_syncpoint); + continue; + } m_outputs.push_back({image, width, height, frame.pts, frame.end}); if (frame.end) { m_stream_ended = true; @@ -2863,8 +2921,7 @@ class INx2VideoSession final : public ServiceFramework { decoder->Render(*frame.picture, Tegra::Host1x::Nx2PictureFormat::Rgba8, width, height, layout.pitch, 0, bytes); m_tiled.resize(layout.size); - Tegra::Texture::SwizzleTexture(m_tiled, bytes, 4, static_cast(layout.pitch / 4), - layout.height, 1, 4, 0, 1, true); + SwizzleBlockLinear(m_tiled, bytes, layout.pitch, layout.height); memory.WriteBlock(address, m_tiled.data(), m_tiled.size()); if (const auto device = Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DeviceAddressOf(address)) { @@ -2887,6 +2944,45 @@ class INx2VideoSession final : public ServiceFramework { return {width, height}; } + // Swizzles 4-byte pixel rows `pitch` bytes apart into 16-GOB (128-row) blocks. Each row of + // blocks is contiguous in the tiled image, so bands of whole block rows are swizzled on threads + // of their own, with the same result (fefw dd31b32b74: a 1440p frame took several ms here). + static void SwizzleBlockLinear(std::span tiled, std::span linear, u64 pitch, + u32 height) { + constexpr u32 BlockHeightLog2 = 4; + constexpr u32 RowsPerBlock = 8U << BlockHeightLog2; + constexpr u32 Bands = 4; + const u32 block_rows = Common::DivCeil(height, RowsPerBlock); + const u64 block_row_bytes = pitch * RowsPerBlock; + const u32 width = static_cast(pitch / 4); + const auto swizzle = [&](u32 first_block, u32 last_block) { + if (first_block >= last_block) { + return; + } + const u32 first = first_block * RowsPerBlock; + const u32 last = std::min(last_block * RowsPerBlock, height); + Tegra::Texture::SwizzleTexture(tiled.subspan(first_block * block_row_bytes), + linear.subspan(first * pitch, (last - first) * pitch), + 4, width, last - first, 1, BlockHeightLog2, 0, 1, true); + }; + if (pitch % Tegra::Texture::GOB_SIZE_X != 0 || + tiled.size() < block_rows * block_row_bytes || + linear.size() < u64{height} * pitch) { + Tegra::Texture::SwizzleTexture(tiled, linear, 4, width, height, 1, BlockHeightLog2, 0, + 1, true); + return; + } + const u32 per_band = Common::DivCeil(block_rows, Bands); + { + std::array workers; + for (u32 i = 1; i < Bands; ++i) { + workers[i - 1] = std::jthread{swizzle, std::min(i * per_band, block_rows), + std::min((i + 1) * per_band, block_rows)}; + } + swizzle(0, std::min(per_band, block_rows)); + } + } + // EDEN_NX2_VDEC_DUMP=

: save the luma of every 120th decoded frame, a quarter of its size, // as nx2-vdec-.pgm (to check what the decoder wrote, apart from how the game shows it). void DumpFrame(const std::vector& bytes, const Nx2DecoderImageLayout& layout, u32 width, @@ -2940,6 +3036,11 @@ class INx2VideoSession final : public ServiceFramework { std::map m_release_fences; std::deque m_outputs; ///< Finished, for 213 bool m_stream_ended{}; ///< End-of-stream frame handed out, nothing submitted since + std::map m_values; ///< Sync value each image last handed out completes at + std::optional m_writing; ///< Image the decode thread is writing + bool m_av1{}; ///< AV1 stream: fefw's flush and release rules apply + u64 m_flushes{}; ///< AV1 flushes so far + u64 m_idle_releases{}; bool m_flush{}; bool m_quit{}; std::thread m_worker; diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index ae1aa84d0a..2170cd4124 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -442,6 +442,11 @@ target_include_directories(video_core PUBLIC ${FFmpeg_INCLUDE_DIR}) target_link_libraries(video_core PRIVATE ${FFmpeg_LIBRARIES}) target_link_options(video_core PRIVATE ${FFmpeg_LDFLAGS}) +# libgav1 decodes Switch 2 AV1 movies; only the nx2 video decoder sees its headers. +set_source_files_properties(host1x/nx2_video_decoder.cpp PROPERTIES + INCLUDE_DIRECTORIES "$") +target_link_libraries(video_core PRIVATE "$") + add_dependencies(video_core host_shaders) target_include_directories(video_core PRIVATE ${HOST_SHADERS_INCLUDE}) diff --git a/src/video_core/host1x/nx2_video_decoder.cpp b/src/video_core/host1x/nx2_video_decoder.cpp index 6163ef3d98..d3aa54eaff 100644 --- a/src/video_core/host1x/nx2_video_decoder.cpp +++ b/src/video_core/host1x/nx2_video_decoder.cpp @@ -1,10 +1,14 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +#include #include #include #include #include +#include + +#include #include "common/logging.h" #include "common/settings.h" @@ -46,6 +50,64 @@ std::optional CodecFromMime(std::string_view mime) { return std::nullopt; } +AVPixelFormat Av1PixelFormat(const libgav1::DecoderBuffer& buffer) { + if (buffer.bitdepth != 8 && buffer.bitdepth != 10) { + return AV_PIX_FMT_NONE; + } + const bool ten_bit = buffer.bitdepth == 10; + switch (buffer.image_format) { + case kLibgav1ImageFormatYuv420: + return ten_bit ? AV_PIX_FMT_YUV420P10LE : AV_PIX_FMT_YUV420P; + case kLibgav1ImageFormatYuv422: + return ten_bit ? AV_PIX_FMT_YUV422P10LE : AV_PIX_FMT_YUV422P; + case kLibgav1ImageFormatYuv444: + return ten_bit ? AV_PIX_FMT_YUV444P10LE : AV_PIX_FMT_YUV444P; + case kLibgav1ImageFormatMonochrome400: + return ten_bit ? AV_PIX_FMT_GRAY10LE : AV_PIX_FMT_GRAY8; + } + return AV_PIX_FMT_NONE; +} + +// Copies a picture libgav1 decoded into a frame of its own (libgav1 reuses its buffers), tagged +// with the stream's matrix and range for Render. +std::shared_ptr CopyAv1Picture(const libgav1::DecoderBuffer& buffer) { + const AVPixelFormat format = Av1PixelFormat(buffer); + const AVPixFmtDescriptor* descriptor = av_pix_fmt_desc_get(format); + if (descriptor == nullptr || buffer.displayed_width[0] <= 0 || + buffer.displayed_height[0] <= 0) { + return nullptr; + } + auto frame = std::make_shared(); + AVFrame* f = frame->GetFrame(); + f->format = format; + f->width = buffer.displayed_width[0]; + f->height = buffer.displayed_height[0]; + if (av_frame_get_buffer(f, 0) < 0) { + return nullptr; + } + const int sample_bytes = buffer.bitdepth > 8 ? 2 : 1; + for (int plane = 0; plane < buffer.NumPlanes(); ++plane) { + const int width = + plane == 0 ? f->width : AV_CEIL_RSHIFT(f->width, descriptor->log2_chroma_w); + const int height = + plane == 0 ? f->height : AV_CEIL_RSHIFT(f->height, descriptor->log2_chroma_h); + const size_t row_bytes = + static_cast(std::min(width, buffer.displayed_width[plane]) * sample_bytes); + for (int y = 0; y < std::min(height, buffer.displayed_height[plane]); ++y) { + std::memcpy(f->data[plane] + static_cast(y) * f->linesize[plane], + buffer.plane[plane] + static_cast(y) * buffer.stride[plane], + row_bytes); + } + } + // AV1 numbers its matrices as FFmpeg does (ITU-T H.273). + const int matrix = static_cast(buffer.matrix_coefficients); + f->colorspace = matrix <= static_cast(AVCOL_SPC_ICTCP) ? static_cast(matrix) + : AVCOL_SPC_UNSPECIFIED; + f->color_range = + buffer.color_range == kLibgav1ColorRangeFull ? AVCOL_RANGE_JPEG : AVCOL_RANGE_MPEG; + return frame; +} + } // namespace bool IsH264Stream(std::span data) { @@ -87,11 +149,18 @@ bool IsH264Stream(std::span data) { return false; } +bool Nx2IsAv1Mime(std::string_view mime) { + return mime.find("av01") != std::string_view::npos || + mime.find("av1") != std::string_view::npos; +} + struct Nx2VideoDecoder::Impl { std::string name; std::optional decoder; std::optional hardware; std::optional context; + std::unique_ptr av1; ///< Instead of FFmpeg, for AV1 + u64 av1_errors{}; SwsContext* scaler{}; bool open{}; H264PacketConverter h264_packets; @@ -125,10 +194,57 @@ struct Nx2VideoDecoder::Impl { std::move(frame)}); } } + + // Not in frame parallel mode, libgav1 decodes one temporal unit per DequeueFrame and holds + // no picture back, so each one is decoded right away (EnqueueFrame keeps a pointer to it). + void DecodeAv1(std::span data, u64 pts, std::vector& pictures) { + auto status = + av1->EnqueueFrame(data.data(), data.size(), static_cast(pts), nullptr); + const libgav1::DecoderBuffer* buffer{}; + if (status == libgav1::kStatusOk) { + status = av1->DequeueFrame(&buffer); + } + if (status != libgav1::kStatusOk) { + if (av1_errors++ < 8 || (av1_errors % 600) == 0) { + LOG_ERROR(HW_GPU, "nx2 video: AV1 frame of {} bytes not decoded ({}), {} so far", + data.size(), libgav1::GetErrorString(status), av1_errors); + } + return; + } + if (buffer == nullptr) { + return; // nothing shown by this temporal unit + } + auto frame = CopyAv1Picture(*buffer); + if (!frame) { + if (av1_errors++ < 8) { + LOG_ERROR(HW_GPU, "nx2 video: unsupported AV1 picture: {}-bit, format {}, {}x{}", + buffer->bitdepth, static_cast(buffer->image_format), + buffer->displayed_width[0], buffer->displayed_height[0]); + } + return; + } + pictures.push_back({static_cast(buffer->displayed_width[0]), + static_cast(buffer->displayed_height[0]), + static_cast(buffer->user_private_data), std::move(frame)}); + } }; Nx2VideoDecoder::Nx2VideoDecoder(std::string_view mime) : impl{std::make_unique()} { impl->name = std::string{mime}; + if (Nx2IsAv1Mime(mime)) { + auto av1 = std::make_unique(); + libgav1::DecoderSettings settings; + settings.threads = + static_cast(std::clamp(std::thread::hardware_concurrency(), 1U, 4U)); + const auto status = av1->Init(&settings); + impl->open = status == libgav1::kStatusOk; + if (impl->open) { + impl->av1 = std::move(av1); + } + LOG_INFO(HW_GPU, "nx2 video: libgav1 decoder for \"{}\" {} ({} threads)", mime, + impl->open ? "open" : libgav1::GetErrorString(status), settings.threads); + return; + } const auto codec = CodecFromMime(mime); if (!codec) { LOG_ERROR(HW_GPU, "nx2 video: no decoder for codec \"{}\"", mime); @@ -162,6 +278,10 @@ void Nx2VideoDecoder::Decode(std::span data, u64 pts, std::vectoropen || data.empty()) { return; } + if (impl->av1) { + impl->DecodeAv1(data, pts, pictures); + return; + } impl->packet.assign(data.size() + AV_INPUT_BUFFER_PADDING_SIZE, 0); std::memcpy(impl->packet.data(), data.data(), data.size()); @@ -185,13 +305,20 @@ void Nx2VideoDecoder::Drain(std::vector& pictures) { if (!impl->open) { return; } + if (impl->av1) { + Reset(); // libgav1 holds no picture back (see DecodeAv1) + return; + } avcodec_send_packet(impl->context->GetCodecContext(), nullptr); impl->Receive(pictures); Reset(); } void Nx2VideoDecoder::Reset() { - if (impl->open) { + if (impl->av1) { + // Releases its frames; the next frame may start a new sequence. + impl->av1->SignalEOS(); + } else if (impl->open) { avcodec_flush_buffers(impl->context->GetCodecContext()); } impl->pending_pts.clear(); diff --git a/src/video_core/host1x/nx2_video_decoder.h b/src/video_core/host1x/nx2_video_decoder.h index 347b0e6525..f8f66ecd04 100644 --- a/src/video_core/host1x/nx2_video_decoder.h +++ b/src/video_core/host1x/nx2_video_decoder.h @@ -35,9 +35,13 @@ enum class Nx2PictureFormat : u32 { /// Checks if stream data begins with an H.264 Annex-B start code or avcC/MP4 length-prefixed NALUs. bool IsH264Stream(std::span data); +/// Whether `mime` names AV1 ("video/av1", "video/av01"). +bool Nx2IsAv1Mime(std::string_view mime); + /// The decoder behind the Switch 2 video decoder service (nn::codec::video): FFmpeg decodes the -/// elementary stream the game demuxed, in software or on the host GPU (NVDEC emulation setting). -/// Used from one thread at a time. +/// elementary stream the game demuxed, in software or on the host GPU (NVDEC emulation setting), +/// and libgav1 decodes AV1, which the bundled FFmpeg packages lack. Used from one thread at a +/// time. class Nx2VideoDecoder { public: /// mime: the codec name nn::codec sends with Configure ("video/vp9", "video/vp8", ...). From 5fd21c1ff32022f762ce6cc6782ba2d5c9aec64b Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:22:01 -0500 Subject: [PATCH 073/154] nx2: convert video frames to RGB on four threads fefw's native video decoder converts each AV1 frame from YUV to RGBA on four threads (fefw 0b1ba0db76, native_video_decoder.cpp); a 1440p frame on one thread took most of the frame time. Render now does the same for RGBA and BGRA images at the frame's own size: four bands of rows, each with a swscale context of its own, starting at rows the chroma subsampling divides. Fortune's Weave's clips take this path. NV12 images (Mario Kart World's movies) and scaled frames keep the single-context conversion. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 033730a1992d08eabdbb88fcfa33f59ed5a14bc0) --- src/video_core/host1x/nx2_video_decoder.cpp | 91 ++++++++++++++++++--- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/src/video_core/host1x/nx2_video_decoder.cpp b/src/video_core/host1x/nx2_video_decoder.cpp index d3aa54eaff..d4ebc4ea0f 100644 --- a/src/video_core/host1x/nx2_video_decoder.cpp +++ b/src/video_core/host1x/nx2_video_decoder.cpp @@ -108,6 +108,20 @@ std::shared_ptr CopyAv1Picture(const libgav1::DecoderBuffer& buff return frame; } +// The stream's matrix and range for a conversion to RGB; untagged HD video is BT.709. +void SetRgbColorspace(SwsContext* scaler, const AVFrame* f) { + int space = SWS_CS_ITU601; + if (f->colorspace == AVCOL_SPC_BT709 || + (f->colorspace == AVCOL_SPC_UNSPECIFIED && f->height >= 720)) { + space = SWS_CS_ITU709; + } else if (f->colorspace == AVCOL_SPC_BT2020_NCL || f->colorspace == AVCOL_SPC_BT2020_CL) { + space = SWS_CS_BT2020; + } + const int* coefficients = sws_getCoefficients(space); + sws_setColorspaceDetails(scaler, coefficients, f->color_range == AVCOL_RANGE_JPEG, coefficients, + 1, 0, 1 << 16, 1 << 16); +} + } // namespace bool IsH264Stream(std::span data) { @@ -162,6 +176,8 @@ struct Nx2VideoDecoder::Impl { std::unique_ptr av1; ///< Instead of FFmpeg, for AV1 u64 av1_errors{}; SwsContext* scaler{}; + static constexpr size_t Bands = 4; + std::array band_scalers{}; ///< RenderBands bool open{}; H264PacketConverter h264_packets; /// Timestamps of submitted frames not yet output. Frames copied back from a host GPU decoder @@ -171,6 +187,9 @@ struct Nx2VideoDecoder::Impl { ~Impl() { sws_freeContext(scaler); + for (SwsContext* band_scaler : band_scalers) { + sws_freeContext(band_scaler); + } context.reset(); hardware.reset(); } @@ -195,6 +214,60 @@ struct Nx2VideoDecoder::Impl { } } + // Converts a frame to RGB at its own size in bands of rows, each with a context of its own, on + // threads (fefw converts its AV1 frames to RGBA on four). Bands start at rows the chroma + // subsampling divides, so no chroma row is split. Returns false if it can't. + bool RenderBands(const AVFrame* f, AVPixelFormat target, u32 height, u64 pitch, + std::span out) { + const auto source = static_cast(f->format); + const AVPixFmtDescriptor* descriptor = av_pix_fmt_desc_get(source); + const int plane_count = av_pix_fmt_count_planes(source); + if (descriptor == nullptr || (descriptor->flags & AV_PIX_FMT_FLAG_HWACCEL) != 0 || + plane_count <= 0 || plane_count > 4) { + return false; + } + const u32 row_alignment = 1U << std::max(1, descriptor->log2_chroma_h); + std::array rows{}; + for (size_t band = 0; band < Bands; ++band) { + rows[band] = static_cast(u64{height} * band / Bands) & ~(row_alignment - 1); + } + rows[Bands] = height; + for (size_t band = 0; band < Bands; ++band) { + const int band_height = static_cast(rows[band + 1] - rows[band]); + band_scalers[band] = sws_getCachedContext(band_scalers[band], f->width, band_height, + source, f->width, band_height, target, + SWS_BILINEAR, nullptr, nullptr, nullptr); + if (band_scalers[band] == nullptr) { + return false; + } + SetRgbColorspace(band_scalers[band], f); + } + const auto convert = [&](size_t index) { + std::array source_planes{}; + for (int plane = 0; plane < plane_count; ++plane) { + const bool chroma = plane == 1 || plane == 2; + const u32 first_row = + chroma ? rows[index] >> descriptor->log2_chroma_h : rows[index]; + source_planes[plane] = + f->data[plane] + static_cast(first_row) * f->linesize[plane]; + } + const std::array target_planes{out.data() + rows[index] * pitch, nullptr, + nullptr, nullptr}; + const std::array target_strides{static_cast(pitch), 0, 0, 0}; + sws_scale(band_scalers[index], source_planes.data(), f->linesize, 0, + static_cast(rows[index + 1] - rows[index]), target_planes.data(), + target_strides.data()); + }; + { + std::array workers; + for (size_t band = 1; band < Bands; ++band) { + workers[band - 1] = std::jthread{convert, band}; + } + convert(0); + } + return true; + } + // Not in frame parallel mode, libgav1 decodes one temporal unit per DequeueFrame and holds // no picture back, so each one is decoded right away (EnqueueFrame keeps a pointer to it). void DecodeAv1(std::span data, u64 pts, std::vector& pictures) { @@ -350,6 +423,12 @@ void Nx2VideoDecoder::Render(const Nx2Picture& picture, Nx2PictureFormat format, } return; } + // Large RGB images at the frame's own size convert on several threads (NV12 doesn't). + if (format != Nx2PictureFormat::Nv12 && static_cast(f->width) == width && + static_cast(f->height) == height && height >= 256 && + impl->RenderBands(f, target, height, pitch, out)) { + return; + } impl->scaler = sws_getCachedContext(impl->scaler, f->width, f->height, static_cast(f->format), static_cast(width), static_cast(height), target, @@ -361,17 +440,7 @@ void Nx2VideoDecoder::Render(const Nx2Picture& picture, Nx2PictureFormat format, return; } if (format != Nx2PictureFormat::Nv12) { - // The stream's matrix and range; untagged HD video is BT.709. - int space = SWS_CS_ITU601; - if (f->colorspace == AVCOL_SPC_BT709 || - (f->colorspace == AVCOL_SPC_UNSPECIFIED && f->height >= 720)) { - space = SWS_CS_ITU709; - } else if (f->colorspace == AVCOL_SPC_BT2020_NCL || f->colorspace == AVCOL_SPC_BT2020_CL) { - space = SWS_CS_BT2020; - } - const int* coefficients = sws_getCoefficients(space); - sws_setColorspaceDetails(impl->scaler, coefficients, f->color_range == AVCOL_RANGE_JPEG, - coefficients, 1, 0, 1 << 16, 1 << 16); + SetRgbColorspace(impl->scaler, f); } std::array planes{out.data(), nullptr, nullptr, nullptr}; std::array strides{static_cast(pitch), 0, 0, 0}; From bc234e54aac538117c9ac4814d361ea1f114e72c Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:32:14 -0500 Subject: [PATCH 074/154] vulkan: sample point-light cubes from 2D array views Ported from fefw a1b23ac3a2 (the texture cache part). Fortune's Weave keeps point-light cubes as six layers of a 2D array atlas (TIC type 2D, depth 6), which its battle floor shader samples as a cube. e2DArray views had no cube handle, so those lights sampled the null view. e2DArray views of at least six layers of a single-sample, cube-compatible image now also get ColorCube (and, for whole multiples of six layers, ColorArrayCube) handles. IsCubeCompatible is the condition the image already used for VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT. Not ported: fefw's change to buffer-backed texture tables in pipeline_helper.h and vk_graphics_pipeline.cpp. Our tree has no buffer_source descriptors, and it already binds the null view when a view lacks the sampled type instead of dropping the draw. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit ce25f676ec5f18e3aec0ac1f95aec8b1323f4f9f) --- .../renderer_vulkan/vk_texture_cache.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 3e8c3e31d9..09ff5f5d9f 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -498,6 +498,11 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM return host; } +[[nodiscard]] bool IsCubeCompatible(const Device& device, const ImageInfo& info) { + return info.type == ImageType::e2D && info.resources.layers >= 6 && + info.size.width == info.size.height && !device.HasBrokenCubeImageCompatibility(); +} + [[nodiscard]] VkImageCreateInfo MakeImageCreateInfo(const Device& device, const ImageInfo& info, std::optional format_override = {}) { auto format_info = @@ -508,8 +513,7 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM format_info.storage = true; } VkImageCreateFlags flags{}; - if (info.type == ImageType::e2D && info.resources.layers >= 6 && - info.size.width == info.size.height && !device.HasBrokenCubeImageCompatibility()) { + if (IsCubeCompatible(device, info)) { flags |= VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT; } if (info.type == ImageType::e3D) { @@ -4154,6 +4158,16 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI create(TextureType::Color2D, 1); create(TextureType::ColorArray2D, std::nullopt); render_target = Handle(Shader::TextureType::ColorArray2D); + // The sampling instruction, not the header, picks the dimension: native shaders sample + // six layers of a 2D array as a cube (Fortune's Weave keeps point-light cubes in a + // layered atlas; without a cube handle they sampled the null view). + if (info.type == VideoCommon::ImageViewType::e2DArray && info.range.extent.layers >= 6 && + image.info.num_samples == 1 && IsCubeCompatible(*device, image.info)) { + create(TextureType::ColorCube, 6); + if (info.range.extent.layers % 6 == 0) { + create(TextureType::ColorArrayCube, std::nullopt); + } + } break; case VideoCommon::ImageViewType::e3D: create(TextureType::Color3D, std::nullopt); From 7f2fd9718ee6ec4152e1c721e83268b7de719b41 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:34:05 -0500 Subject: [PATCH 075/154] nx2:gsv: destroy decoder image pools on 912 gsv 912 was auto-stubbed, so a destroyed pool's images stayed listed and the decode thread could still write frames into memory the game reuses. Ported from fefw's DestroyVideoImagePool / NativeVideoService::DestroyImagePool (fefw 0b1ba0db76): 912(u32 first image id) removes that image and the ones after it in the same pool memory, dropping any GPU mapping 426 made for them. Titles that free a pool's images one at a time find the later ones already gone. On upstream: 912 was upstream's single-image DestroyNativeImage; it now destroys the pool, which also covers titles that free the images one by one. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 55f0e4dbce5cd0641148a6152a9266ec98cde1fa) --- src/core/hle/service/nx2_gsv.h | 42 ++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 8c9c8bcc47..9aef732cac 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -3092,10 +3092,11 @@ class INx2GraphicsSystemSession final : public ServiceFramework>> unmaps; + u32 count{}; + { + std::scoped_lock lk{m_objects->mutex}; + const auto pool = m_objects->images.find(first); + if (pool != m_objects->images.end() && pool->second.decoder) { + const u32 memory = pool->second.memory; + const u64 size = pool->second.size; + u64 offset = pool->second.offset; + for (auto image = pool; image != m_objects->images.end() && + image->first == first + count && image->second.decoder && + image->second.memory == memory && + image->second.offset == offset;) { + unmaps.push_back(TakeDecoderMapping(image->second)); + image = m_objects->images.erase(image); + ++count; + offset += size; + } + } + } + for (const auto& range : unmaps) { + DropDecoderMapping(range); + } + LOG_INFO(Service, "nx2:gsv 912 decoder images {}..+{} destroyed", first, count); + TipcReplyWords(ctx, {}); + } + // 315(u32 image, u32 count) / 314(u32 image): add/remove a window alias image pool. void ConfigureWindowImagePool(HLERequestContext& ctx) { const u32 id = ctx.CommandBuffer()[2]; From 3e6d7bf06f34af0d4941e29898e5f74daf467d13 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:47:25 -0500 Subject: [PATCH 076/154] nx2:vdecsvc: answer AV1 calls as fefw does, and trace them Fortune's Weave's AV1 intro froze and the game aborted (2162-0001) in one run and played to its end in the next, on the same build. Both runs started the same way. The decoder kept up (about 30 re-uploads of the 15 MiB decoder images a second in each), so what the game reads back is the suspect, and some of it changed from run to run: - 311/314 replied the NativeSyncHandle only; the two u64s after it held words the request left in the buffer (word 10: 0x88768f64 in the run that aborted, 0x095ae416 in the one that played). fefw replies zeros. - 101 replied the result only; its u64 read as the codec name's address. fefw replies zero. - 213 with nothing finished now carries fefw's 16 zero words. - 109 took every input: the game pushed the whole 110 s movie (3,300 inputs, 68 MB) in 8 s. AV1 now holds three inputs as fefw does and answers 0x1c266f with nothing taken; the game waits for the submission event and submits again. Also for AV1: RGBA images are written block-linear from the first frame (the first frames came before the game's 426 and were written pitch-linear), and 201/202/210 are registered (201 fell to the auto stub). VP9, Mario Kart World's codec, is unchanged. For the next run: EDEN_NX2_VDEC_TRACE=1 also logs each reply (213 image and pts, 109 accepted or busy, 311/314 sync values) and each written frame. AV1 logs a state line every 150 frames (queues, sync value, decode and write ms, stream time against wall time), and one when no frame comes for 2 s, saying what it waits on. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit f514822fded461d08c1ac475647558cb4ce42aac) --- src/core/hle/service/nx2_gsv.h | 223 ++++++++++++++++++++++++++++++--- 1 file changed, 206 insertions(+), 17 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 9aef732cac..4da3184999 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2453,15 +2453,66 @@ class INx2VideoSession final : public ServiceFramework { static constexpr u32 NoSyncpoint = ~0U; static constexpr size_t MaxQueuedInputs = 8; + static constexpr size_t MaxQueuedAv1Inputs = 3; // fefw's MaximumPendingInputs static constexpr size_t MaxDecodedAhead = 2; // decoded frames held while no image is free static constexpr u32 HostSyncpoints = 192; // Tegra::Host1x::SyncpointManager + static constexpr u64 SummaryFrames = 150; // AV1: a state line every 150 frames (5 s) + static constexpr std::chrono::seconds StallReport{2}; + static constexpr u32 EmptyOutputResult = 0x1c286f; // 213: nothing finished + static constexpr u32 InputBusyResult = 0x1c266f; // 109: input queue full (fefw) + + // EDEN_NX2_VDEC_TRACE=1: every video decoder call, reply and written frame at Info level. + static bool Tracing() { + static const bool trace = std::getenv("EDEN_NX2_VDEC_TRACE") != nullptr; + return trace; + } + + // With m_mutex held. + size_t InputLimit() const { + return m_av1 ? MaxQueuedAv1Inputs : MaxQueuedInputs; + } + + // With m_mutex held: where every queue stands, to tell from the log which side a movie that + // stops waits on (the client's input or releases, or the decoder). + void LogState(std::string_view what) { + using Ms = std::chrono::duration; + using S = std::chrono::duration; + const u32 reached = + m_syncpoint == NoSyncpoint + ? 0 + : static_cast( + system.Host1x().GetSyncpointManager().GetGuestSyncpointValue(m_syncpoint)); + const double decode_ms = + m_timed_decodes ? Ms(m_decode_time).count() / static_cast(m_timed_decodes) : 0; + const double write_ms = + m_timed_writes ? Ms(m_write_time).count() / static_cast(m_timed_writes) : 0; + const double stream_s = + static_cast(m_last_fetch_pts - m_first_fetch_pts) / 1'000'000.0; + const double wall_s = + m_first_fetch_time ? S(std::chrono::steady_clock::now() - *m_first_fetch_time).count() + : 0; + LOG_INFO(Service, + "{} {} frame {}: {} inputs ({} KiB, {} busy, {} without a picture), {} fetched, " + "{} empty polls, {} releases ({} idle); waiting {} inputs, {} decoded, {} free " + "images, {} unfetched; sync {} of {} handed; decode {:.1f} ms, write {:.1f} ms; " + "pts in {} us, stream {:.2f} s fetched in {:.2f} s", + GetServiceName(), what, m_frames_out, m_submitted, m_submitted_bytes >> 10, + m_busy_submits, m_no_picture, m_fetched, m_empty_polls, m_releases, + m_idle_releases, m_inputs.size(), m_decoded.size(), m_free.size(), + m_outputs.size(), reached, m_sync_base + m_handed, decode_ms, write_ms, + m_last_pts_in, stream_s, wall_s); + m_decode_time = {}; + m_timed_decodes = 0; + m_write_time = {}; + m_timed_writes = 0; + } void Log(HLERequestContext& ctx, std::string_view what) { const auto* w = ctx.CommandBuffer(); if (Nx2FirstVideoCommand(GetServiceName(), ctx.GetCommand())) { LOG_INFO(Service, "{} TIPC {} {} (first use): words {:08x}", GetServiceName(), ctx.GetCommand(), what, fmt::join(std::span{w, 12}, " ")); - } else if (static const bool trace = std::getenv("EDEN_NX2_VDEC_TRACE") != nullptr; trace) { + } else if (Tracing()) { // EDEN_NX2_VDEC_TRACE=1: every video decoder call at Info level. LOG_INFO(Service, "{} TIPC {} {}: [2]={:#x} [3]={:#x} [4]={:#x} [5]={:#x}", GetServiceName(), ctx.GetCommand(), what, w[2], w[3], w[4], w[5]); @@ -2522,9 +2573,10 @@ class INx2VideoSession final : public ServiceFramework { Log(ctx, "configure"); LOG_INFO(Service, "{} configure codec \"{}\" {}x{} rate {}", GetServiceName(), codec, w[5], w[6], w[7]); + const bool av1 = Tegra::Host1x::Nx2IsAv1Mime(codec); { std::scoped_lock lk{m_mutex}; - m_av1 = Tegra::Host1x::Nx2IsAv1Mime(codec); + m_av1 = av1; m_codec = std::move(codec); m_width = w[5]; m_height = w[6]; @@ -2532,6 +2584,12 @@ class INx2VideoSession final : public ServiceFramework { if (!m_worker.joinable()) { m_worker = std::thread{[this] { DecodeLoop(); }}; } + if (av1) { + // fefw replies a u64 of zero. With the result alone the client read the words its + // request left in the buffer (the codec name's address) as that value. + TipcReplyWords(ctx, {0, 0}); + return; + } TipcReplyWords(ctx, {}); } @@ -2586,8 +2644,15 @@ class INx2VideoSession final : public ServiceFramework { LOG_WARNING(Service, "{} release of waiting image {} (x{})", GetServiceName(), image, m_idle_releases); } + if (Tracing() && (m_idle_releases <= 16 || (m_idle_releases & 0xff) == 0)) { + LOG_INFO(Service, "{} release of waiting image {}: sync value {} kept", + GetServiceName(), image, m_values[image]); + } return {~m_syncpoint, 0, m_values[image], 0}; } + if (release_fence) { + ++m_releases; + } m_free.push_back(image); if (release_fence) { m_release_fences[image] = *release_fence; @@ -2611,11 +2676,26 @@ class INx2VideoSession final : public ServiceFramework { value = m_sync_base + ++m_handed; } m_values[image] = value; + if (Tracing()) { + LOG_INFO(Service, "{} {} image {}: sync value {} ({} images wait)", + GetServiceName(), release_fence ? "release" : "register", image, value, + m_free.size()); + } } m_cv.notify_one(); return {~m_syncpoint, 0, value, 0}; } + // 311/314 reply: the NativeSyncHandle. AV1: then two u64s of zero, as fefw replies; with the + // handle alone the client read the words its request left in the buffer there. + void ReplySyncHandle(HLERequestContext& ctx, const std::array& handle) { + if (m_av1) { + TipcReplyWords(ctx, {handle[0], handle[1], handle[2], handle[3], 0, 0, 0, 0}); + return; + } + TipcReplyWords(ctx, {handle[0], handle[1], handle[2], handle[3]}); + } + // With m_mutex held: the image waits for a frame. bool IsWaiting(u32 image) const { return m_writing == image || std::ranges::find(m_free, image) != m_free.end(); @@ -2634,14 +2714,14 @@ class INx2VideoSession final : public ServiceFramework { void RegisterBuffer(HLERequestContext& ctx) { Log(ctx, "register output"); const auto handle = HandOut(ctx.CommandBuffer()[2], std::nullopt); - TipcReplyWords(ctx, {handle[0], handle[1], handle[2], handle[3]}); + ReplySyncHandle(ctx, handle); } void ReleaseBuffer(HLERequestContext& ctx) { const auto* w = ctx.CommandBuffer(); Log(ctx, "release output"); const auto handle = HandOut(w[2], Fence{~w[4], w[6]}); - TipcReplyWords(ctx, {handle[0], handle[1], handle[2], handle[3]}); + ReplySyncHandle(ctx, handle); } void UnregisterBuffer(HLERequestContext& ctx) { @@ -2670,14 +2750,43 @@ class INx2VideoSession final : public ServiceFramework { input.data.assign(data.begin(), data.end()); } const u64 size = input.data.size(); + u64 id{}; + bool busy{}; + size_t queued{}; { std::scoped_lock lk{m_mutex}; - m_inputs.push_back(std::move(input)); - m_stream_ended = false; - m_ready->room.store(!end && m_inputs.size() < MaxQueuedInputs, std::memory_order_relaxed); + if (m_av1 && m_inputs.size() >= MaxQueuedAv1Inputs) { + // AV1: at most three inputs wait, as in fefw, which tells the client to come back + // (0x1c266f, nothing taken); it waits for the submission event and submits the + // same frame again. + busy = true; + ++m_busy_submits; + m_ready->room.store(false, std::memory_order_relaxed); + } else { + m_inputs.push_back(std::move(input)); + m_stream_ended = false; + m_ready->room.store(!end && m_inputs.size() < InputLimit(), + std::memory_order_relaxed); + id = ++m_submitted; + m_submitted_bytes += size; + m_last_pts_in = pts; + } + queued = m_inputs.size(); + } + // Busy replies only now and then: a client may come back in a loop. + if (Tracing() && (!busy || (m_busy_submits & (m_busy_submits - 1)) == 0)) { + LOG_INFO(Service, "{} submit {} bytes, pts {} us{}: {} ({} inputs wait)", + GetServiceName(), size, pts, end ? ", end of stream" : "", + busy ? std::string{"busy"} : fmt::format("input {}", id), queued); + } + if (busy) { + IPC::ResponseBuilder rb{ctx, 4}; + rb.PushRaw(InputBusyResult); + rb.Push(0); + rb.Push(0); + return; } m_cv.notify_one(); - const u64 id = ++m_submitted; // AV1: the bytes taken, as fefw's decoder replies (Fortune's Weave plays with it). const u64 reply = m_av1 ? size : id; TipcReplyWords(ctx, {static_cast(reply), static_cast(reply >> 32)}); @@ -2743,20 +2852,50 @@ class INx2VideoSession final : public ServiceFramework { std::optional output; u32 width{}; u32 height{}; + bool av1{}; { std::scoped_lock lk{m_mutex}; + av1 = m_av1; if (!m_outputs.empty()) { output = m_outputs.front(); m_outputs.pop_front(); // AV1: words 1-2 hold the configured size, as fefw's decoder replies. width = m_av1 ? m_width : output->width; height = m_av1 ? m_height : output->height; + ++m_fetched; + if (!m_first_fetch_time) { + m_first_fetch_time = std::chrono::steady_clock::now(); + m_first_fetch_pts = output->pts; + } + if (!output->end) { + m_last_fetch_pts = std::max(m_last_fetch_pts, output->pts); + } + if (Tracing()) { + LOG_INFO(Service, "{} 213: image {}, pts {} us{} after {} empty polls ({} more " + "finished)", + GetServiceName(), output->image, output->pts, + output->end ? ", end of stream" : "", m_empty_streak, + m_outputs.size()); + } + m_empty_streak = 0; + } else { + ++m_empty_polls; + if (Tracing() && m_empty_streak++ == 0) { + LOG_INFO(Service, "{} 213: none ({} decoded, {} free images, {} inputs wait)", + GetServiceName(), m_decoded.size(), m_free.size(), m_inputs.size()); + } } } if (!output) { - // Nothing finished: the code the client's own Acquire uses for an empty ring. - IPC::ResponseBuilder rb{ctx, 2}; - rb.PushRaw(0x1c286f); + // Nothing finished: the code the client's own Acquire uses for an empty ring. AV1: + // with the 16 words of zero fefw replies. + IPC::ResponseBuilder rb{ctx, av1 ? 18U : 2U}; + rb.PushRaw(EmptyOutputResult); + if (av1) { + for (int i = 0; i < 16; ++i) { + rb.Push(0); + } + } return; } const u64 pts_ns = output->pts * 1000; @@ -2773,12 +2912,22 @@ class INx2VideoSession final : public ServiceFramework { std::unique_ptr decoder; std::vector pictures; std::vector bytes; + u64 stall_reported = ~0ULL; std::unique_lock lk{m_mutex}; while (true) { - m_cv.wait(lk, [&] { + const auto ready = [&] { return m_quit || m_flush || (!m_decoded.empty() && !m_free.empty()) || (!m_inputs.empty() && m_decoded.size() < MaxDecodedAhead); - }); + }; + if (!m_cv.wait_for(lk, StallReport, ready)) { + // A movie that stops: say once what it waits on (input, a release or a frame). + if ((m_av1 || Tracing()) && m_frames_out != 0 && !m_stream_ended && + stall_reported != m_frames_out) { + stall_reported = m_frames_out; + LogState("no frame for 2 s after"); + } + continue; + } if (m_quit) { return; } @@ -2799,6 +2948,7 @@ class INx2VideoSession final : public ServiceFramework { m_free.pop_front(); m_writing = image; const u64 flushes = m_flushes; + const bool av1 = m_av1; std::optional fence; if (const auto it = m_release_fences.find(image); it != m_release_fences.end()) { fence = it->second; @@ -2808,9 +2958,15 @@ class INx2VideoSession final : public ServiceFramework { if (fence) { WaitForRelease(*fence); } - const auto [width, height] = WriteImage(decoder.get(), image, frame, bytes); + const auto write_start = std::chrono::steady_clock::now(); + const auto [width, height] = WriteImage(decoder.get(), image, frame, bytes, av1); + const auto write_time = std::chrono::steady_clock::now() - write_start; lk.lock(); m_writing.reset(); + if (frame.picture) { + m_write_time += write_time; + ++m_timed_writes; + } // Even an image written during a flush counts: the client waits for its value. // An AV1 flush drops the frame, though (see Flush): the image waits again. if (m_av1 && m_flushes != flushes) { @@ -2829,13 +2985,19 @@ class INx2VideoSession final : public ServiceFramework { LOG_INFO(Service, "{} frame {} ({}x{}, pts {} us{}) in image {}", GetServiceName(), m_frames_out, width, height, frame.pts, frame.end ? ", end of stream" : "", image); + } else if (Tracing()) { + LOG_INFO(Service, "{} frame {} (pts {} us) in image {}, sync value {}", + GetServiceName(), m_frames_out, frame.pts, image, m_values[image]); + } + if ((av1 || Tracing()) && (m_frames_out % SummaryFrames) == 0) { + LogState("at"); } continue; } Input input = std::move(m_inputs.front()); m_inputs.pop_front(); if (!m_stream_ended) { - m_ready->room.store(m_inputs.size() < MaxQueuedInputs, std::memory_order_relaxed); + m_ready->room.store(m_inputs.size() < InputLimit(), std::memory_order_relaxed); } std::string codec = m_codec; if (codec.empty() && Tegra::Host1x::IsH264Stream(input.data)) { @@ -2849,14 +3011,23 @@ class INx2VideoSession final : public ServiceFramework { decoder = std::make_unique(codec); } pictures.clear(); + const auto decode_start = std::chrono::steady_clock::now(); decoder->Decode(input.data, input.pts, pictures); if (input.end) { decoder->Drain(pictures); } + const auto decode_time = std::chrono::steady_clock::now() - decode_start; lk.lock(); if (m_flush) { continue; } + if (!input.end) { + m_decode_time += decode_time; + ++m_timed_decodes; + if (pictures.empty()) { + ++m_no_picture; + } + } for (auto& picture : pictures) { const u64 pts = picture.pts; m_decoded.push_back({std::move(picture), pts, false}); @@ -2888,7 +3059,7 @@ class INx2VideoSession final : public ServiceFramework { // Writes a decoded frame into an output image; returns the size the frame reports. std::pair WriteImage(Tegra::Host1x::Nx2VideoDecoder* decoder, u32 image_id, - const Decoded& frame, std::vector& bytes) { + const Decoded& frame, std::vector& bytes, bool av1) { u64 address{}; Nx2DecoderImageLayout layout{}; bool block_linear{}; @@ -2899,7 +3070,9 @@ class INx2VideoSession final : public ServiceFramework { return {m_width, m_height}; } layout = Nx2CalculateDecoderImageLayout(image->second.preference.data()); - block_linear = image->second.gpu_read && + // AV1 (Fortune's Weave): every RGBA image is read on the GPU, as fefw writes them, + // also the first frames, which come before the client's 428/426 for the image. + block_linear = (image->second.gpu_read || av1) && layout.format != Tegra::Host1x::Nx2PictureFormat::Nv12; const auto memory = m_objects->memories.find(image->second.memory); if (memory != m_objects->memories.end() && memory->second.address != 0) { @@ -3041,6 +3214,22 @@ class INx2VideoSession final : public ServiceFramework { bool m_av1{}; ///< AV1 stream: fefw's flush and release rules apply u64 m_flushes{}; ///< AV1 flushes so far u64 m_idle_releases{}; + // Counters for LogState. + u64 m_submitted_bytes{}; + u64 m_busy_submits{}; + u64 m_no_picture{}; ///< Inputs that showed no frame (libgav1: a hidden frame) + u64 m_fetched{}; + u64 m_empty_polls{}; + u64 m_empty_streak{}; + u64 m_releases{}; + u64 m_last_pts_in{}; + u64 m_first_fetch_pts{}; + u64 m_last_fetch_pts{}; + std::optional m_first_fetch_time; + std::chrono::steady_clock::duration m_decode_time{}; + u64 m_timed_decodes{}; + std::chrono::steady_clock::duration m_write_time{}; + u64 m_timed_writes{}; bool m_flush{}; bool m_quit{}; std::thread m_worker; From cacdf1bfc377f5c9f118d77c3b90c16ca044bb52 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:47:35 -0500 Subject: [PATCH 077/154] nx2:gsv: drop destroyed decoder images' GPU mappings a second later Fortune's Weave ends its intro movie with 314 releases and, 2 ms later, 912 for its four decoder pools, then 115, 903 and 1003. 912 dropped the GPU mapping 426 had made for each image at once, while GPU work that read the images could still be queued or running. 0.7 s later Eden died with a SIGSEGV in VulkanWorker inside Turnip (garbage pointer). fefw keeps such a pool alive through its mapping and frees it only when the mapping goes. Here a mapping whose image went while the client still had it mapped (912, 112 for its memory, 430) is now retired. It is dropped a second later through the GPU thread, after the work queued before it, and the texture cache frees its images once the GPU is past them. A pool made again at the same address in the meantime keeps the mapping instead. 427, the client's own unmap, still drops it at once, as fefw does. The drop also leaves the range alone if a mapping made since has replaced it (Nx2GpuMemoryRegistry::UnregisterIfUnchanged), and keeps the backing if another mapping of the range shares it. 903/1003 (VideoFinalize, after 901/1001) are registered: the result alone, as fefw replies. There is nothing else to tear down for them. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 9bf9a8e0d8b5a798e308d3be37b1b5b79ca6bad4) --- .../service/nvdrv/devices/nx2_stub_device.h | 28 ++++ src/core/hle/service/nx2_gsv.h | 129 ++++++++++++++++-- 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h index 8dee9ccfb7..a1dc8d0342 100644 --- a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h +++ b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h @@ -76,6 +76,34 @@ class Nx2GpuMemoryRegistry { } } + // Undoes Register(address, size) if that mapping is still in place: unmaps it (on the GPU + // thread, after the command lists already submitted) and releases its backing unless another + // mapping of the same range shares it. A mapping made since over the range replaced it, and + // its GPU range and backing belong to that one now: then nothing is done (false). + bool UnregisterIfUnchanged(u64 address, u64 size) { + bool shared{}; + { + std::scoped_lock lk{mutex}; + const auto it = std::ranges::find_if(mappings, [&](const Mapping& m) { + return m.gpu_address == address && m.cpu_address == address && m.size == size; + }); + if (it == mappings.end()) { + return false; + } + mappings.erase(it); + shared = std::ranges::any_of(mappings, [&](const Mapping& m) { + return m.cpu_address == address && m.size == size; + }); + for (auto& [owner, listener] : listeners) { + listener.unmap(address, size); + } + } + if (!shared) { + ReleaseBacking(address, size); + } + return true; + } + // Backing memory [cpu_address, +size) is about to be freed (its GPU mappings are gone): each // address space drops its SMMU mapping of it, in GPU-thread order. void ReleaseBacking(u64 cpu_address, u64 size) { diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 4da3184999..805e91594a 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1465,9 +1465,16 @@ struct Nx2NativeObjects { u32 gpu_maps{}; // outstanding 426 maps bool mapped_here{}; // 426 added its GPU mapping (no other mapping covered it) }; + // Mappings 426 made for decoder images that went while the client still had them mapped, + // dropped a while later (INx2GraphicsSystemSession::RetireDecoderMapping). + struct RetiredMapping { + u64 address; + u64 size; + }; std::mutex mutex; std::map memories; std::map images; + std::deque retired; u32 next_memory{1}; u32 next_image{1}; }; @@ -3271,6 +3278,10 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmutex}; + if (m_objects->retired.empty()) { + break; + } + } + DropRetiredMapping(*m_objects); + } + } for (auto* event : m_syncpt_events) { if (event != nullptr) { m_context.CloseEvent(event); @@ -3666,13 +3690,13 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmutex}; for (auto& [image_id, image] : m_objects->images) { if (image.memory == id && image.mapped_here) { - unmaps.push_back(TakeDecoderMapping(image)); + unmaps.push_back(TakeDecoderMapping(image, id)); } } m_objects->memories.erase(id); } for (const auto& range : unmaps) { - DropDecoderMapping(range); + RetireDecoderMapping(range); } LOG_DEBUG(Service, "nx2:gsv 112 DestroyNativeMemory id {}", id); TipcReplyWords(ctx, {}); @@ -3873,13 +3897,18 @@ class INx2GraphicsSystemSession final : public ServiceFrameworksecond); } } + if (unmap) { + LOG_INFO(Service, "nx2:gsv 427 decoder image {} unmapped", handle); + } LOG_DEBUG(Service, "nx2:gsv 427 image {} [{:#x}, {:#x}]", handle, Pair(w, 4), Pair(w, 6)); DropDecoderMapping(unmap); TipcReplyWords(ctx, {}); } // The range 426 mapped for an image, if it did; called with m_objects->mutex held. - std::optional> TakeDecoderMapping(Nx2NativeObjects::Image& image) { + // `freed`: the memory being destroyed (112), whose images can't keep the mapping. + std::optional> TakeDecoderMapping(Nx2NativeObjects::Image& image, + std::optional freed = {}) { image.gpu_maps = 0; if (!std::exchange(image.mapped_here, false)) { return std::nullopt; @@ -3888,15 +3917,91 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmemories.end() || memory->second.address == 0) { return std::nullopt; } - return std::pair{memory->second.address + image.offset, image.size}; + const u64 address = memory->second.address + image.offset; + // An image of a pool made again at the same address maps through this mapping (its 426 + // found it in place): it keeps the mapping. + for (auto& [id, other] : m_objects->images) { + if (&other == &image || !other.decoder || other.gpu_maps == 0 || other.mapped_here || + other.size != image.size || other.memory == freed) { + continue; + } + const auto other_memory = m_objects->memories.find(other.memory); + if (other_memory != m_objects->memories.end() && + other_memory->second.address + other.offset == address) { + other.mapped_here = true; + return std::nullopt; + } + } + return std::pair{address, image.size}; } + // Drops a mapping 426 made. The GPU thread unmaps it in order, after the command lists + // already submitted, and the texture cache frees its images once the GPU is past them. A + // mapping made since over the range owns it now and is left alone. static void DropDecoderMapping(const std::optional>& range) { - if (range) { - auto& registry = Nvidia::Devices::Nx2GpuMemoryRegistry::Get(); - registry.Unmap(range->first, range->second); - registry.ReleaseBacking(range->first, range->second); + if (range && + !Nvidia::Devices::Nx2GpuMemoryRegistry::Get().UnregisterIfUnchanged(range->first, + range->second)) { + LOG_INFO(Service, "nx2:gsv decoder image mapping {:#x}+{:#x} was replaced, kept", + range->first, range->second); + } + } + + // A mapping 426 made for an image whose pool (912), memory (112) or image (430) went while the + // client still had it mapped: GPU work that reads it may still be queued or running (Fortune's + // Weave destroys its movie's pools right after its last releases, and Turnip crashed in + // VulkanWorker 0.7 s later when they went at once). fefw keeps such a pool until its mapping + // goes. Here it is dropped RetireDelay later, by then long done on the GPU, unless a pool + // made again at the address took it over meanwhile. + void RetireDecoderMapping(const std::optional>& range) { + if (!range) { + return; + } + { + std::scoped_lock lk{m_objects->mutex}; + m_objects->retired.push_back({range->first, range->second}); + } + if (!m_retire_event) { + m_retire_event = Core::Timing::CreateEvent( + "nx2:gsv retire", + [objects = m_objects](s64, std::chrono::nanoseconds) + -> std::optional { + DropRetiredMapping(*objects); + return std::nullopt; + }); + } + // One event per mapping, in order: each drops the oldest. + system.CoreTiming().ScheduleEvent(RetireDelay, m_retire_event); + LOG_INFO(Service, "nx2:gsv decoder image mapping {:#x}+{:#x} goes in {} ms", range->first, + range->second, RetireDelay.count()); + } + + static void DropRetiredMapping(Nx2NativeObjects& objects) { + std::optional> drop; + { + std::scoped_lock lk{objects.mutex}; + if (objects.retired.empty()) { + return; + } + const auto retired = objects.retired.front(); + objects.retired.pop_front(); + drop = std::pair{retired.address, retired.size}; + // A pool made again at the address maps through it (its 426 found it in place). + for (auto& [id, image] : objects.images) { + if (!image.decoder || image.gpu_maps == 0 || image.mapped_here || + image.size != retired.size) { + continue; + } + const auto memory = objects.memories.find(image.memory); + if (memory != objects.memories.end() && + memory->second.address + image.offset == retired.address) { + image.mapped_here = true; + drop.reset(); + break; + } + } } + DropDecoderMapping(drop); } // 911(preference[16], u32 count, u32 memory id) (nn::codec::InitializeDecoderImagePool): @@ -3965,7 +4070,7 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkimages.erase(image); } } - DropDecoderMapping(unmap); + RetireDecoderMapping(unmap); TipcReplyWords(ctx, {}); } @@ -3973,6 +4078,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework>> unmaps; @@ -3996,7 +4102,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework m_retire_event; std::shared_ptr m_objects{std::make_shared()}; KernelHelpers::ServiceContext m_context; From d41318578b2a7ffb0fe4516bc07c302cd000e249 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:11:17 -0500 Subject: [PATCH 078/154] nx2: decode AV1 with FFmpeg where it has libdav1d Upstream's Windows and Linux x86-64 FFmpeg packages decode AV1 with libdav1d. libgav1 now takes the AV1 movies only where FFmpeg has no libdav1d (Android's package), so those platforms keep upstream's path. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- externals/libgav1/CMakeLists.txt | 4 ++-- src/video_core/host1x/nx2_video_decoder.cpp | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/externals/libgav1/CMakeLists.txt b/externals/libgav1/CMakeLists.txt index 9189c348e8..f5e0739b57 100644 --- a/externals/libgav1/CMakeLists.txt +++ b/externals/libgav1/CMakeLists.txt @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project # SPDX-License-Identifier: GPL-3.0-or-later -# libgav1 decodes the AV1 movies of Switch 2 titles (nx2 video decoder). The bundled FFmpeg -# packages have no AV1 decoder. +# libgav1 decodes the AV1 movies of Switch 2 titles (nx2 video decoder) where the bundled FFmpeg +# has no libdav1d (Android's package); with libdav1d, FFmpeg decodes them. include(FetchContent) diff --git a/src/video_core/host1x/nx2_video_decoder.cpp b/src/video_core/host1x/nx2_video_decoder.cpp index d4ebc4ea0f..22926d7129 100644 --- a/src/video_core/host1x/nx2_video_decoder.cpp +++ b/src/video_core/host1x/nx2_video_decoder.cpp @@ -304,7 +304,9 @@ struct Nx2VideoDecoder::Impl { Nx2VideoDecoder::Nx2VideoDecoder(std::string_view mime) : impl{std::make_unique()} { impl->name = std::string{mime}; - if (Nx2IsAv1Mime(mime)) { + // AV1 goes to libgav1 where FFmpeg has no libdav1d (Android's FFmpeg package); FFmpeg's own + // "av1" decoder only drives hardware decoders. + if (Nx2IsAv1Mime(mime) && avcodec_find_decoder_by_name("libdav1d") == nullptr) { auto av1 = std::make_unique(); libgav1::DecoderSettings settings; settings.threads = From 4132b95d9fc5aca7f33163119e12cdbc4108c102 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:36:50 -0500 Subject: [PATCH 079/154] nx2: build libgav1 on Android only Desktop builds keep upstream's FFmpeg (libdav1d where the package has it) and no longer fetch and build libgav1. The libgav1 path in the nx2 video decoder is compiled with EDEN_NX2_LIBGAV1, set on Android. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- externals/CMakeLists.txt | 6 ++++-- src/video_core/CMakeLists.txt | 12 ++++++++---- src/video_core/host1x/nx2_video_decoder.cpp | 21 ++++++++++++++++++++- src/video_core/host1x/nx2_video_decoder.h | 2 +- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index a02e4b9215..23ed09a2ce 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -282,8 +282,10 @@ if (ENABLE_WEB_SERVICE OR ENABLE_UPDATE_CHECKER) AddJsonPackage(cpp-jwt) endif() -# libgav1 (AV1 video decoding) -add_subdirectory(libgav1) +# libgav1 (AV1 video decoding where FFmpeg has no libdav1d: Android) +if (ANDROID) + add_subdirectory(libgav1) +endif() # FFMpeg if (YUZU_USE_EXTERNAL_FFMPEG OR YUZU_USE_BUNDLED_FFMPEG) diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 2170cd4124..a83bebcf8f 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -442,10 +442,14 @@ target_include_directories(video_core PUBLIC ${FFmpeg_INCLUDE_DIR}) target_link_libraries(video_core PRIVATE ${FFmpeg_LIBRARIES}) target_link_options(video_core PRIVATE ${FFmpeg_LDFLAGS}) -# libgav1 decodes Switch 2 AV1 movies; only the nx2 video decoder sees its headers. -set_source_files_properties(host1x/nx2_video_decoder.cpp PROPERTIES - INCLUDE_DIRECTORIES "$") -target_link_libraries(video_core PRIVATE "$") +# libgav1 decodes Switch 2 AV1 movies on Android, whose FFmpeg has no libdav1d; only the nx2 video +# decoder sees its headers. +if (ANDROID) + set_source_files_properties(host1x/nx2_video_decoder.cpp PROPERTIES + INCLUDE_DIRECTORIES "$" + COMPILE_DEFINITIONS EDEN_NX2_LIBGAV1) + target_link_libraries(video_core PRIVATE "$") +endif() add_dependencies(video_core host_shaders) target_include_directories(video_core PRIVATE ${HOST_SHADERS_INCLUDE}) diff --git a/src/video_core/host1x/nx2_video_decoder.cpp b/src/video_core/host1x/nx2_video_decoder.cpp index 22926d7129..3a2c3fd2ef 100644 --- a/src/video_core/host1x/nx2_video_decoder.cpp +++ b/src/video_core/host1x/nx2_video_decoder.cpp @@ -8,7 +8,9 @@ #include #include +#ifdef EDEN_NX2_LIBGAV1 #include +#endif #include "common/logging.h" #include "common/settings.h" @@ -50,6 +52,7 @@ std::optional CodecFromMime(std::string_view mime) { return std::nullopt; } +#ifdef EDEN_NX2_LIBGAV1 AVPixelFormat Av1PixelFormat(const libgav1::DecoderBuffer& buffer) { if (buffer.bitdepth != 8 && buffer.bitdepth != 10) { return AV_PIX_FMT_NONE; @@ -107,6 +110,7 @@ std::shared_ptr CopyAv1Picture(const libgav1::DecoderBuffer& buff buffer.color_range == kLibgav1ColorRangeFull ? AVCOL_RANGE_JPEG : AVCOL_RANGE_MPEG; return frame; } +#endif // The stream's matrix and range for a conversion to RGB; untagged HD video is BT.709. void SetRgbColorspace(SwsContext* scaler, const AVFrame* f) { @@ -173,8 +177,10 @@ struct Nx2VideoDecoder::Impl { std::optional decoder; std::optional hardware; std::optional context; +#ifdef EDEN_NX2_LIBGAV1 std::unique_ptr av1; ///< Instead of FFmpeg, for AV1 u64 av1_errors{}; +#endif SwsContext* scaler{}; static constexpr size_t Bands = 4; std::array band_scalers{}; ///< RenderBands @@ -268,6 +274,7 @@ struct Nx2VideoDecoder::Impl { return true; } +#ifdef EDEN_NX2_LIBGAV1 // Not in frame parallel mode, libgav1 decodes one temporal unit per DequeueFrame and holds // no picture back, so each one is decoded right away (EnqueueFrame keeps a pointer to it). void DecodeAv1(std::span data, u64 pts, std::vector& pictures) { @@ -300,10 +307,12 @@ struct Nx2VideoDecoder::Impl { static_cast(buffer->displayed_height[0]), static_cast(buffer->user_private_data), std::move(frame)}); } +#endif }; Nx2VideoDecoder::Nx2VideoDecoder(std::string_view mime) : impl{std::make_unique()} { impl->name = std::string{mime}; +#ifdef EDEN_NX2_LIBGAV1 // AV1 goes to libgav1 where FFmpeg has no libdav1d (Android's FFmpeg package); FFmpeg's own // "av1" decoder only drives hardware decoders. if (Nx2IsAv1Mime(mime) && avcodec_find_decoder_by_name("libdav1d") == nullptr) { @@ -320,6 +329,7 @@ Nx2VideoDecoder::Nx2VideoDecoder(std::string_view mime) : impl{std::make_unique< impl->open ? "open" : libgav1::GetErrorString(status), settings.threads); return; } +#endif const auto codec = CodecFromMime(mime); if (!codec) { LOG_ERROR(HW_GPU, "nx2 video: no decoder for codec \"{}\"", mime); @@ -353,10 +363,12 @@ void Nx2VideoDecoder::Decode(std::span data, u64 pts, std::vectoropen || data.empty()) { return; } +#ifdef EDEN_NX2_LIBGAV1 if (impl->av1) { impl->DecodeAv1(data, pts, pictures); return; } +#endif impl->packet.assign(data.size() + AV_INPUT_BUFFER_PADDING_SIZE, 0); std::memcpy(impl->packet.data(), data.data(), data.size()); @@ -380,20 +392,27 @@ void Nx2VideoDecoder::Drain(std::vector& pictures) { if (!impl->open) { return; } +#ifdef EDEN_NX2_LIBGAV1 if (impl->av1) { Reset(); // libgav1 holds no picture back (see DecodeAv1) return; } +#endif avcodec_send_packet(impl->context->GetCodecContext(), nullptr); impl->Receive(pictures); Reset(); } void Nx2VideoDecoder::Reset() { +#ifdef EDEN_NX2_LIBGAV1 if (impl->av1) { // Releases its frames; the next frame may start a new sequence. impl->av1->SignalEOS(); - } else if (impl->open) { + impl->pending_pts.clear(); + return; + } +#endif + if (impl->open) { avcodec_flush_buffers(impl->context->GetCodecContext()); } impl->pending_pts.clear(); diff --git a/src/video_core/host1x/nx2_video_decoder.h b/src/video_core/host1x/nx2_video_decoder.h index f8f66ecd04..dd5b867211 100644 --- a/src/video_core/host1x/nx2_video_decoder.h +++ b/src/video_core/host1x/nx2_video_decoder.h @@ -40,7 +40,7 @@ bool Nx2IsAv1Mime(std::string_view mime); /// The decoder behind the Switch 2 video decoder service (nn::codec::video): FFmpeg decodes the /// elementary stream the game demuxed, in software or on the host GPU (NVDEC emulation setting), -/// and libgav1 decodes AV1, which the bundled FFmpeg packages lack. Used from one thread at a +/// and on Android libgav1 decodes AV1, which its FFmpeg package lacks. Used from one thread at a /// time. class Nx2VideoDecoder { public: From b38bb2fae08193848c9292c495e8ce6f175d920b Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:36:58 -0500 Subject: [PATCH 080/154] video_core: run the ARM64 macro JIT on Android only Other ARM64 builds (Linux, macOS, Windows on ARM) keep upstream's interpreted macros on the split GPU thread: the JIT was written and tested for phones, and its W^X handling (mprotect) has not been checked against macOS's MAP_JIT rules. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/video_core/CMakeLists.txt | 4 ++-- src/video_core/engines/kepler_compute.cpp | 6 +++--- src/video_core/engines/maxwell_3d.cpp | 4 ++-- src/video_core/gpu_split.cpp | 2 +- src/video_core/macro.cpp | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index a83bebcf8f..46c7318a11 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -509,8 +509,8 @@ if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64) target_link_libraries(video_core PRIVATE dynarmic::dynarmic) endif() -if (ARCHITECTURE_arm64) - # The Turing macro JIT's ARM64 emitter (macro_turing_jit_arm64.h). +if (ARCHITECTURE_arm64 AND ANDROID) + # The Turing macro JIT's ARM64 emitter (macro_turing_jit_arm64.h), used on Android. target_sources(video_core PRIVATE macro_turing_jit_arm64.cpp macro_turing_jit_arm64.h) target_link_libraries(video_core PRIVATE merry::oaknut) endif() diff --git a/src/video_core/engines/kepler_compute.cpp b/src/video_core/engines/kepler_compute.cpp index 59799ba316..8571ff7296 100644 --- a/src/video_core/engines/kepler_compute.cpp +++ b/src/video_core/engines/kepler_compute.cpp @@ -31,7 +31,7 @@ #ifdef ARCHITECTURE_x86_64 #include "video_core/macro_turing_jit.h" #endif -#ifdef ARCHITECTURE_arm64 +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) #include "video_core/macro_turing_jit_arm64.h" #endif #include "video_core/rasterizer_interface.h" @@ -183,7 +183,7 @@ bool KeplerCompute::ProcessNativeMethod(Core::System& system, u32 method, u32 ar CallMethod(system, address, value, true); return true; }; -#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) +#if defined(ARCHITECTURE_x86_64) || (defined(ARCHITECTURE_arm64) && defined(__ANDROID__)) // Compiled macro code, as for 3D macros (macro_turing_jit.h, macro_turing_jit_arm64.h); // EDEN_NX2_MME_JIT=0 off. static const bool use_jit = [] { @@ -1258,7 +1258,7 @@ void KeplerCompute::FrontCallMultiMethod(Core::System& system, VideoCommon::GpuS void KeplerCompute::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split, FrontState& fs) { -#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) +#if defined(ARCHITECTURE_x86_64) || (defined(ARCHITECTURE_arm64) && defined(__ANDROID__)) const auto positions = FrontMacroPositions(fs); const u32 start = positions[((*fs.executing_macro - 0xe00) / 2) % positions.size()]; fs.executing_macro.reset(); diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index dd29d16f9b..9cd01089e1 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -28,7 +28,7 @@ #ifdef ARCHITECTURE_x86_64 #include "video_core/macro_turing_jit.h" #endif -#ifdef ARCHITECTURE_arm64 +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) #include "video_core/macro_turing_jit_arm64.h" #endif @@ -1113,7 +1113,7 @@ void Maxwell3D::FrontCallMultiMethod(Core::System& system, VideoCommon::GpuSplit void Maxwell3D::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split, FrontState& fs) { -#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) +#if defined(ARCHITECTURE_x86_64) || (defined(ARCHITECTURE_arm64) && defined(__ANDROID__)) const u32 method = fs.executing_macro; fs.executing_macro = 0; auto& engine = GetNativeMacroEngine(); diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index a9bd10c0f1..4b9c16473f 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -201,7 +201,7 @@ void GpuSplit::MaybeStart(Core::System& system_) { ring = std::make_unique(RingWords); back_thread = std::jthread([this](std::stop_token stop_token) { BackLoop(stop_token); }); active = true; -#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) +#if defined(ARCHITECTURE_x86_64) || (defined(ARCHITECTURE_arm64) && defined(__ANDROID__)) // Front macros run compiled (macro_turing_jit.h, macro_turing_jit_arm64.h), or interpreted // where the ARM64 code is not used. const char* jit = std::getenv("EDEN_NX2_MME_JIT"); diff --git a/src/video_core/macro.cpp b/src/video_core/macro.cpp index d7eedb7a94..8f314bf121 100644 --- a/src/video_core/macro.cpp +++ b/src/video_core/macro.cpp @@ -19,7 +19,7 @@ #include "common/x64/xbyak.h" #include "video_core/macro_turing_jit.h" #endif -#ifdef ARCHITECTURE_arm64 +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) #include "video_core/macro_turing_jit_arm64.h" #endif @@ -1469,7 +1469,7 @@ void MacroEngine::Execute(Core::System& system, Engines::Maxwell3D& maxwell3d, u } return true; }; -#if defined(ARCHITECTURE_x86_64) || defined(ARCHITECTURE_arm64) +#if defined(ARCHITECTURE_x86_64) || (defined(ARCHITECTURE_arm64) && defined(__ANDROID__)) // Macro code runs compiled to x86-64 or ARM64 (macro_turing_jit.h, // macro_turing_jit_arm64.h) instead of through the memo and the interpreter; // EDEN_NX2_MME_JIT=0 goes back to those, as does ARM64 code that did not match the From 98b9347eb35cdca45732f93f691f643345f946e5 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:08 -0500 Subject: [PATCH 081/154] gpu_split: sleep in the split's waits on Android only The front sleeping on a futex past its spins, and the back parking on a raw futex instead of std::atomic::wait, were for phones (heat, and libc++'s atomic waits missing notifies). Desktop keeps the spin, yield and std::atomic waits, with the lost wake-up re-wake. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/video_core/gpu_split.cpp | 46 ++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 4b9c16473f..532932341a 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -86,6 +86,24 @@ void WakeWord(std::atomic& word) { #endif } +// Phones sleep in the waits below on futexes: spinning or yielding, the front kept a big core +// busy and the phone hot. The back parks on a raw futex too (libc++'s std::atomic::wait can miss +// a notify). Desktop keeps the spin, yield and std::atomic waits. +#ifdef __ANDROID__ +constexpr bool SleepingWaits = true; +#else +constexpr bool SleepingWaits = false; +#endif + +// Wakes the back thread parked on `wake`. +void WakeBack(std::atomic& wake) { + if constexpr (SleepingWaits) { + WakeWord(wake); + } else { + wake.notify_all(); + } +} + [[maybe_unused]] std::atomic g_back_thread_id{}; std::atomic g_back_busy{}; @@ -217,7 +235,7 @@ void GpuSplit::Stop() { } back_thread.request_stop(); wake.fetch_add(1, std::memory_order_seq_cst); - WakeWord(wake); + WakeBack(wake); back_thread.join(); active = false; // The split is static and outlives the emulation session. The next session's back thread @@ -275,8 +293,7 @@ void GpuSplit::WaitForSpace(size_t words) { void GpuSplit::RequestIdleHook() { hook_requested.store(true, std::memory_order_seq_cst); wake.fetch_add(1, std::memory_order_seq_cst); - // The back parks on a raw futex (BackLoop): a std::atomic notify may not reach it. - WakeWord(wake); + WakeBack(wake); } void GpuSplit::SleepOnBack() { @@ -284,7 +301,11 @@ void GpuSplit::SleepOnBack() { // front waiting here and the back parked). if (back_waiting.load(std::memory_order_seq_cst)) { wake.fetch_add(1, std::memory_order_seq_cst); - WakeWord(wake); + WakeBack(wake); + } + if constexpr (!SleepingWaits) { + std::this_thread::yield(); + return; } // The back bumps `drained` after storing `consumed` when it sees front_waiting: read it // before the last look at `consumed`, so a store in between ends the wait at once. @@ -305,7 +326,7 @@ void GpuSplit::Publish() { published.store(write_position, std::memory_order_seq_cst); if (back_waiting.load(std::memory_order_seq_cst)) { wake.fetch_add(1, std::memory_order_seq_cst); - WakeWord(wake); + WakeBack(wake); } } @@ -323,13 +344,14 @@ void GpuSplit::Drain() { publish_every = 8; return; } - if (spins < 8192) { + if (spins < (SleepingWaits ? 8192U : 16384U)) { Pause(); } else { // Sleep instead of spinning on: the front spent most of each frame here, keeping a big // core busy and the phone hot. SleepOnBack(); - if (std::chrono::steady_clock::now() >= next_report) { + if ((SleepingWaits || (spins & 1023) == 0) && + std::chrono::steady_clock::now() >= next_report) { next_report += std::chrono::seconds{5}; LOG_WARNING(HW_GPU, "GPU split drain waiting on the back thread: {} of {} words consumed, " @@ -504,9 +526,13 @@ void GpuSplit::BackLoop(std::stop_token stop_token) { // A request made after the hook ran (RequestIdleHook) runs it before sleeping. if (available == read_position && !stop_token.stop_requested() && !hook_requested.load(std::memory_order_seq_cst)) { - // A futex, not std::atomic::wait: libc++ spins with sched_yield first, - // and parks waiters in a shared table where a notify could miss this one. - WaitWord(wake, seen, std::chrono::milliseconds{2}); + if constexpr (SleepingWaits) { + // A futex, not std::atomic::wait: libc++ spins with sched_yield first, + // and parks waiters in a shared table where a notify could miss this one. + WaitWord(wake, seen, std::chrono::milliseconds{2}); + } else { + wake.wait(seen, std::memory_order_seq_cst); + } } back_waiting.store(false, std::memory_order_relaxed); continue; From 2fd12be59f82cc99da649ad0b96cf1556ae9e82b Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:08 -0500 Subject: [PATCH 082/154] core, settings: Android-only defaults stay on Android Asynchronous shaders are on by default, and on for Switch 2 titles, on Android only; desktop keeps them off as upstream. env.txt and the gpu_profile file are read on Android only, which has no other way to set the EDEN_NX2_* switches; desktop uses the environment. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/common/settings.h | 10 +++++++++- src/core/core.cpp | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/common/settings.h b/src/common/settings.h index 3fe4b9b8f1..6fddf6b56d 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -676,7 +676,15 @@ struct Values { SwitchableSetting emulate_bgr565{linkage, false, "emulate_bgr565", Category::RendererHacks}; - SwitchableSetting use_asynchronous_shaders{linkage, true, "use_asynchronous_shaders", + // On by default on Android (pipelines building on the GPU thread stall a phone for 100s of + // ms); desktop keeps it off. + SwitchableSetting use_asynchronous_shaders{linkage, +#ifdef __ANDROID__ + true, +#else + false, +#endif + "use_asynchronous_shaders", Category::RendererHacks}; /// Large compressed textures keep their mip levels from 1 on (half resolution): less video /// memory, slightly blurrier textures up close. diff --git a/src/core/core.cpp b/src/core/core.cpp index 0efed6e353..67812abadc 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -371,6 +371,8 @@ struct System::Impl { Settings::values.memory_layout_mode.SetGlobal(false); Settings::values.memory_layout_mode.SetValue(Settings::MemoryLayout::Memory_12Gb); } +#ifdef __ANDROID__ + // Android only: desktop users set the environment, and desktop keeps upstream's defaults. // The KEY=VALUE lines of an env.txt in the user directory become environment variables: // the EDEN_NX2_* switches, which Android has no other way to set. Each is read the first // time it is used, so a change takes a restart of the app. @@ -408,6 +410,7 @@ struct System::Impl { Settings::values.use_asynchronous_shaders.SetGlobal(false); Settings::values.use_asynchronous_shaders.SetValue(true); } +#endif // The Switch 2 counter runs at 31.25 MHz and its SDK converts ticks with that constant // (nn::os::GetSystemTickFrequency, TickManager::ConvertToTimeSpan/ConvertToTick). At 19.2 MHz From 5f18af7d839bf81146681992e6e7357131d7b079 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:09 -0500 Subject: [PATCH 083/154] vulkan: phone memory changes on Android only Desktop integrated GPUs keep upstream's behaviour: no memory usage report from VMA without VK_EXT_memory_budget, staging copies for buffer downloads instead of direct reads (and no host read barrier per submit), and the staging pool releasing one size a frame and keeping one idle buffer of the big sizes. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../renderer_vulkan/vk_buffer_cache.cpp | 5 ++++ .../renderer_vulkan/vk_scheduler.cpp | 2 ++ .../vk_staging_buffer_pool.cpp | 27 ++++++++++++++++--- .../vulkan_common/vulkan_device.cpp | 4 ++- src/video_core/vulkan_common/vulkan_device.h | 4 +++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp index 1709a0f4ab..d91745bfc1 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp @@ -449,12 +449,17 @@ u64 BufferCacheRuntime::CurrentTick() { } bool BufferCacheRuntime::CanReadBuffersDirectly() const noexcept { + // Phones only: desktop integrated GPUs keep the staging copy. +#ifdef __ANDROID__ // EDEN_NX2_DIRECT_DOWNLOADS=0 copies every download. static const bool enabled = [] { const char* value = std::getenv("EDEN_NX2_DIRECT_DOWNLOADS"); return value == nullptr || value[0] != '0'; }(); return enabled && device.IsIntegrated(); +#else + return false; +#endif } void BufferCacheRuntime::WaitTick(u64 tick) { diff --git a/src/video_core/renderer_vulkan/vk_scheduler.cpp b/src/video_core/renderer_vulkan/vk_scheduler.cpp index 22be7823e8..ccb34ace48 100644 --- a/src/video_core/renderer_vulkan/vk_scheduler.cpp +++ b/src/video_core/renderer_vulkan/vk_scheduler.cpp @@ -431,6 +431,7 @@ u64 Scheduler::SubmitExecution(VkSemaphore signal_semaphore, VkSemaphore wait_se .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT, }; upload_cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, WRITE_BARRIER); +#ifdef __ANDROID__ if (device.IsIntegrated()) { // Buffer downloads read buffer memory directly once their tick completes // (BufferCacheRuntime::CanReadBuffersDirectly): make the GPU's writes visible. @@ -443,6 +444,7 @@ u64 Scheduler::SubmitExecution(VkSemaphore signal_semaphore, VkSemaphore wait_se cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, HOST_READ_BARRIER); } +#endif upload_cmdbuf.End(); cmdbuf.End(); diff --git a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp index 4864e8a1ee..aafebb4b18 100644 --- a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp +++ b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp @@ -274,15 +274,19 @@ StagingBufferPool::StagingBuffersCache& StagingBufferPool::GetCache(MemoryUsage } void StagingBufferPool::ReleaseCache(MemoryUsage usage) { +#ifdef __ANDROID__ // Every size each frame: one of 64 per frame, 16 buffers at most, kept the buffers a burst of // uploads made (Mario Kart World loading a course) for many seconds, on a phone whose GPU - // memory is its RAM. + // memory is its RAM. Desktop keeps the one size a frame. auto& cache = GetCache(usage); for (size_t log2 = 0; log2 < NUM_LEVELS; ++log2) { if (!cache[log2].entries.empty()) { ReleaseLevel(cache, log2); } } +#else + ReleaseLevel(GetCache(usage), current_delete_level); +#endif } u64 StagingBufferPool::CachedBytes(MemoryUsage usage) const { @@ -297,14 +301,15 @@ u64 StagingBufferPool::CachedBytes(MemoryUsage usage) const { } void StagingBufferPool::ReleaseLevel(StagingBuffersCache& cache, size_t log2) { + auto& staging = cache[log2]; + auto& entries = staging.entries; +#ifdef __ANDROID__ // A few idle buffers of each small size stay for reuse; the rest go once the GPU is done with // them and they sat unused for a while (so steady uploads don't recreate buffers every frame). // None of 16 MiB or more: power-of-two sizes made one image's upload a 1-2 GiB buffer, and // keeping one of each such size held 4 GiB on a phone through Mario Kart World's races. constexpr u64 idle_ticks = 64; const size_t keep = log2 >= 24 ? 0 : 4; - auto& staging = cache[log2]; - auto& entries = staging.entries; const u64 current_tick = scheduler.CurrentTick(); size_t kept = 0; std::erase_if(entries, [&](const StagingBuffer& entry) { @@ -318,6 +323,22 @@ void StagingBufferPool::ReleaseLevel(StagingBuffersCache& cache, size_t log2) { return true; }); const size_t new_size = entries.size(); +#else + constexpr size_t deletions_per_tick = 16; + const size_t old_size = entries.size(); + + const auto is_deletable = [this](const StagingBuffer& entry) { + return scheduler.IsFree(entry.tick); + }; + const size_t begin_offset = staging.delete_index; + const size_t end_offset = (std::min)(begin_offset + deletions_per_tick, old_size); + const auto begin = entries.begin() + begin_offset; + const auto end = entries.begin() + end_offset; + entries.erase(std::remove_if(begin, end, is_deletable), end); + + const size_t new_size = entries.size(); + staging.delete_index += deletions_per_tick; +#endif if (staging.delete_index >= new_size) { staging.delete_index = 0; } diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index 2506563ebf..75e3cc3db0 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -1728,7 +1728,8 @@ u64 Device::GetDeviceMemoryUsage() const { // Without VK_EXT_memory_budget (Qualcomm's driver), what VMA's allocations use. The caches' // own estimate counted sparse and unbacked images at their full size: Mario Kart World // "used" 8.5 GB with 4.7 GB allocated, and evicted textures nonstop until the phone ran out of - // memory. + // memory. Android only (CanReportMemoryUsage). +#ifdef __ANDROID__ if (!extensions.memory_budget) { u64 used{}; if (allocator != nullptr) { @@ -1740,6 +1741,7 @@ u64 Device::GetDeviceMemoryUsage() const { } return used; } +#endif VkPhysicalDeviceMemoryBudgetPropertiesEXT budget{}; budget.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; physical.GetMemoryProperties(&budget); diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h index dd735285c0..0f207c5059 100644 --- a/src/video_core/vulkan_common/vulkan_device.h +++ b/src/video_core/vulkan_common/vulkan_device.h @@ -1028,7 +1028,11 @@ FN_MAX_LIMIT_LIST /// With VK_EXT_memory_budget from the driver, or VMA's allocations without it. bool CanReportMemoryUsage() const { +#ifdef __ANDROID__ return extensions.memory_budget || allocator != nullptr; +#else + return extensions.memory_budget; +#endif } u64 GetDeviceMemoryUsage() const; From f105d87a718e5bb88dc5ef723653977ce8216786 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:09 -0500 Subject: [PATCH 084/154] kernel: give freed guest memory back to the host on Android only Desktop keeps freed pages resident for reuse, as before. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/core/hle/kernel/k_memory_manager.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/hle/kernel/k_memory_manager.cpp b/src/core/hle/kernel/k_memory_manager.cpp index b6606fd29b..5e0a8b8378 100644 --- a/src/core/hle/kernel/k_memory_manager.cpp +++ b/src/core/hle/kernel/k_memory_manager.cpp @@ -451,15 +451,21 @@ Result KMemoryManager::AllocateForProcess(KPageGroup* out, size_t num_pages, u32 } void KMemoryManager::ReleaseFreedPages(KPhysicalAddress address, size_t num_pages) { +#ifdef __ANDROID__ // Freed guest memory stayed resident until reused: Mario Kart World's memory grew with every // course it loaded (the last ones' regions freed but resident) until a phone killed it on the - // third. Small runs (thread stacks and the like) are left, not worth the system call. + // third. Small runs (thread stacks and the like) are left, not worth the system call. Phones + // only: desktop keeps freed memory for reuse as before. constexpr size_t MinPages = 16; if (num_pages < MinPages) { return; } m_system.DeviceMemory().buffer.ReleaseBackingRegion( GetInteger(address) - Core::DramMemoryMap::Base, num_pages * PageSize); +#else + static_cast(address); + static_cast(num_pages); +#endif } size_t KMemoryManager::Impl::Initialize(KPhysicalAddress address, size_t size, From e3acf0b85fcd43c88ff1329cf23624086c4cf050 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:22 -0500 Subject: [PATCH 085/154] vulkan: dump rejected SPIR-V on desktop only when asked On Android the SPIR-V of pipelines the driver rejects, and of emulated shared memory shaders, is still dumped (EDEN_NX2_DUMP_SPIRV=0 stops it). Elsewhere nothing is written, and the stages' SPIR-V isn't kept while a pipeline builds, unless EDEN_NX2_DUMP_SPIRV=1. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../renderer_vulkan/pipeline_helper.h | 21 ++++++++++++++++++- .../renderer_vulkan/vk_graphics_pipeline.cpp | 3 +++ .../renderer_vulkan/vk_pipeline_cache.cpp | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/pipeline_helper.h b/src/video_core/renderer_vulkan/pipeline_helper.h index a1553b0c6c..f7b54aad3d 100644 --- a/src/video_core/renderer_vulkan/pipeline_helper.h +++ b/src/video_core/renderer_vulkan/pipeline_helper.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -143,8 +144,26 @@ inline void WriteDescriptorBuffer(const Device& device, const DescriptorBufferLa } } -/// Writes SPIR-V to dump//.spv, for spirv-val and spirv-dis. +/// Whether SPIR-V the driver rejects, and that of emulated shared memory shaders, is dumped: on +/// Android (no other way to get at it) unless EDEN_NX2_DUMP_SPIRV=0, elsewhere with +/// EDEN_NX2_DUMP_SPIRV=1. +[[nodiscard]] inline bool DumpsSpirv() { + static const bool dumps = [] { + const char* value = std::getenv("EDEN_NX2_DUMP_SPIRV"); +#ifdef __ANDROID__ + return value == nullptr || value[0] != '0'; +#else + return value != nullptr && value[0] != '0'; +#endif + }(); + return dumps; +} + +/// Writes SPIR-V to dump//.spv, for spirv-val and spirv-dis (see DumpsSpirv). inline void DumpSpirv(std::string_view subdir, std::string_view name, std::span code) { + if (!DumpsSpirv()) { + return; + } const auto dir = Common::FS::GetEdenPath(Common::FS::EdenPath::DumpDir) / subdir; if (code.empty() || !Common::FS::CreateDirs(dir)) { return; diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index 84a8da34ec..d1792bce77 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -265,6 +265,9 @@ ConfigureFuncPtr ConfigureFunc(const std::array& m // Writes the stages of a pipeline the driver rejected to dump/failed_pipelines/_.spv. void DumpFailedPipeline(const GraphicsPipelineCacheKey& key, const std::array, NUM_STAGES>& spirv) { + if (!DumpsSpirv()) { + return; + } static constexpr std::array stage_names{"vertex", "tess_control", "tess_eval", "geometry", "fragment"}; const auto dir = diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index babb0ba2ec..62527e1cc7 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1293,7 +1293,9 @@ std::unique_ptr PipelineCache::CreateGraphicsPipeline( const std::vector code{EmitSPIRV(profile, runtime_info, program, binding)}; device.SaveShader(code); modules[stage_index] = BuildShader(device, code); - spirv[stage_index] = code; + if (DumpsSpirv()) { + spirv[stage_index] = code; // For DumpFailedPipeline + } // Text log + .spv dump. Text log is gated by gpu_log_level != Off; .spv dump // is independent and gated only by gpu_log_shader_dumps. From d52d978d9827028e632df0b5e62ccce57a3dfcd4 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:22 -0500 Subject: [PATCH 086/154] video_core: keep phone rendering changes off desktop - Draws that write memory wait for their pipeline on Android only (phone GPUs faulted on the stale data); Switch 2 programs' draws wait everywhere, as before. - Pipeline workers leave the background cores for Switch 2 programs on Android only. - Natively sampled ASTC arrays upload layer by layer on phone GPUs only (Intel's desktop GPUs sample ASTC too). - 2D arrays get cube views for Switch 2 programs only, whose native shaders sample them as cubes. - Only trimmed images count their host image's size in the texture cache's total; other images count as before. - Buffer bindings skip synchronizing unwritten ranges on Android only. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/video_core/buffer_cache/buffer_cache.h | 7 ++++++- src/video_core/renderer_vulkan/vk_pipeline_cache.cpp | 11 +++++++++-- src/video_core/renderer_vulkan/vk_texture_cache.cpp | 10 +++++++--- src/video_core/texture_cache/texture_cache.h | 12 +++++++----- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index b0e45655a4..32006be03a 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -2281,11 +2281,16 @@ bool BufferCache

::SynchronizeBuffer(Buffer& buffer, DAddr device_addr, u32 si // Most binds come back to a range nothing was written to since it was synchronized: skip // walking its pages while its region saw no CPU writes (they took 5% of Mario Kart World's // GPU back thread). - // EDEN_NX2_SYNC_CACHE=0 synchronizes every range every time. + // EDEN_NX2_SYNC_CACHE=0 synchronizes every range every time. Android only: desktop + // synchronizes every range every time as before. +#ifdef __ANDROID__ static const bool sync_cache = [] { const char* value = std::getenv("EDEN_NX2_SYNC_CACHE"); return value == nullptr || value[0] != '0'; }(); +#else + constexpr bool sync_cache = false; +#endif const u64 generation = sync_cache ? memory_tracker.RangeGeneration(device_addr, size) : 0; SyncedRange& synced = synced_ranges[((device_addr >> 8) ^ size) % synced_ranges.size()]; if (generation != 0 && synced.generation == generation && synced.device_addr == device_addr && diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 62527e1cc7..4dc5dfb4ff 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -437,9 +437,14 @@ PipelineCache::PipelineCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, num_translation_threads{use_async_translation ? GetTotalTranslationWorkers() : 0}, workers(device.HasBrokenParallelShaderCompiling() ? 1ULL : GetTotalPipelineWorkers(), "VkPipelineBuilder", {}, - // Draws of Switch 2 programs wait for these builds: not on background cores. +#ifdef __ANDROID__ + // Draws of Switch 2 programs wait for these builds: not on a phone's background + // cores. Settings::CurrentProgramIsSwitch2() ? Common::ThreadPlacement::Default : Common::ThreadPlacement::Background), +#else + Common::ThreadPlacement::Background), +#endif serialization_thread(1, "VkPipelineSerialization", {}, Common::ThreadPlacement::Background), translation_workers(num_translation_threads, "VkPipelineTranslator", {}, @@ -1146,11 +1151,13 @@ GraphicsPipeline* PipelineCache::BuiltPipeline(GraphicsPipeline* pipeline) const if (Settings::CurrentProgramIsSwitch2()) { return pipeline; } +#ifdef __ANDROID__ // Draws that write memory wait too: skipping one leaves what later passes read (culling - // results, indirect arguments, counters) stale. + // results, indirect arguments, counters) stale, which faulted phone GPUs. if (pipeline->WritesMemory()) { return pipeline; } +#endif return CanSkipDraw() ? nullptr : pipeline; } diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 09ff5f5d9f..246623e171 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -186,6 +186,8 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { info.type != ImageType::Linear && Tegra::Nx2SparseUnwritten(gpu_addr, size); } +[[nodiscard]] bool IsMobileGpu(const Device& device); + // A large color array in a Switch 2 sparse region (title 1's terrain arrays: 13-26% of their // blocks written) gets memory by layer, as its layers are written: unbacked at first, then // Image::BindLayers. Only formats uploaded layer by layer: huge ASTC arrays (GPU decode) and @@ -197,12 +199,12 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { VideoCore::Surface::GetFormatType(info.format) != VideoCore::Surface::SurfaceType::ColorTexture) { return false; } - // ASTC the host samples as it is (Adreno) uploads layer by layer like other plain formats: + // ASTC a phone GPU samples as it is (Adreno) uploads layer by layer like other plain formats: // without this, Mario Kart World's ASTC 8x8 terrain arrays stayed unbacked (black) on phones. const bool layer_uploads = IsHugeAstcArray(device, info) || (IsPixelFormatASTC(info.format) - ? device.IsOptimalAstcSupported() + ? device.IsOptimalAstcSupported() && IsMobileGpu(device) : (!IsPixelFormatBCn(info.format) || device.IsOptimalBcnSupported())); const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); return layer_uploads && size >= (32U << 20) && Tegra::Nx2InSparseRegion(gpu_addr, size); @@ -4161,7 +4163,9 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI // The sampling instruction, not the header, picks the dimension: native shaders sample // six layers of a 2D array as a cube (Fortune's Weave keeps point-light cubes in a // layered atlas; without a cube handle they sampled the null view). - if (info.type == VideoCommon::ImageViewType::e2DArray && info.range.extent.layers >= 6 && + // Switch 2 programs only, whose native shaders do this. + if (Settings::CurrentProgramIsSwitch2() && + info.type == VideoCommon::ImageViewType::e2DArray && info.range.extent.layers >= 6 && image.info.num_samples == 1 && IsCubeCompatible(*device, image.info)) { create(TextureType::ColorCube, 6); if (info.range.extent.layers % 6 == 0) { diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 90c6d5666d..5979e2cf62 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -2311,12 +2311,14 @@ u64 TextureCache

::ImageMemoryBytes(const Image& image) const { } return 0; } - // What the host image really takes where the runtime knows it: a trimmed image - // (reduce_texture_resolution) takes a quarter of its guest size. Counted at guest size, Mario - // Kart World's trimmed terrain arrays kept the total over the critical limit for whole races, - // and the collector evicted (and re-uploaded) thousands of textures every few seconds. + // A trimmed image (reduce_texture_resolution, or too large for the host) counts what its host + // image really takes, where the runtime knows it: a quarter of its guest size or less. + // Counted at guest size, Mario Kart World's trimmed terrain arrays kept the total over the + // critical limit for whole races, and the collector evicted (and re-uploaded) thousands of + // textures every few seconds. Other images count as before. if constexpr (requires { image.HostMemoryBytes(); }) { - if (const std::optional host_bytes = image.HostMemoryBytes()) { + if (const std::optional host_bytes = image.HostMemoryBytes(); + host_bytes && True(image.flags & ImageFlagBits::TrimmedLevels)) { return Common::AlignUp(*host_bytes, 1024); } } From b29495b14119dd2adfd44cd120d061e8646a540a Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:37:23 -0500 Subject: [PATCH 087/154] qt: name the reduce_texture_resolution and release_gpu_only_memory settings They appeared under their config names in the graphics hacks tab. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/qt_common/config/shared_translation.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 4ed7884b28..007c7bf569 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -299,6 +299,13 @@ std::unique_ptr InitializeTranslations(QObject* parent) { tr("Enables access to previous buffer states.\nThis option may improve rendering " "quality and performance consistency in some games.")); INSERT(Settings, fix_bloom_effects, tr("Fix bloom effects"), tr("Removes bloom in Burnout.")); + INSERT(Settings, reduce_texture_resolution, tr("Reduce texture resolution"), + tr("Large compressed textures keep their mip levels from the second one on: a quarter " + "of the video memory, slightly blurrier up close.")); + INSERT(Settings, release_gpu_only_memory, tr("Release GPU-only memory (experimental)"), + tr("Switch 2 textures in GPU-only memory give back their guest memory once uploaded, " + "the host image holding the data. Saves RAM; may break titles that read that " + "memory directly.")); // Renderer (Extensions) INSERT(Settings, dyna_state, tr("Extended Dynamic State"), From cdcfd25fa9a8b9ce66551d9cf7ba742bb9193bbe Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:21 -0400 Subject: [PATCH 088/154] Add .nx2: a loose Switch 2 dump packed into one file A .nx2 is an extracted-type NSP: a PFS0 holding the ExeFS files directly, the RomFS as one image (game.romfs) and any control.nacp/icon sidecars, so the NSP loader boots it like the loose dump. One file is far cheaper than 200k loose ones, on Android above all (one file handle instead of a content URI per file). - Tools > Pack Switch 2 Dump to .nx2: packs on a background thread with progress and an ETA, reading the RomFS files with parallel readers (each small file waits on its open; the VFS serialises its reads). - Switch 2 detection (12 GiB layout) looks inside a package. - Extracted NSPs count as bootable for the game lists, and take their title, icon and display version from the package's sidecars and RomFS, else from ".png/.nacp" next to it and the file name. Those are looked up when the loader is made: guest service calls must not reach Android's content URI lookups (JNI). - A loose dump's directory, given as a path, boots its main or exefs/main through the VFS. On nx2: .nx2 sits next to upstream's .nup, which it does not replace (a .nup is a different container). Upstream's Switch 2 detection already reads main.npdm inside an extracted NSP, so only the loader, game list and packer parts are added; the packed dump's metadata goes through upstream's lazily found RomFS, read at load only for its version. Assisted-by: Claude Opus 5.5 (implementation) (cherry picked from commit 66f504409b11f1cc74e5d7dccd9e8ed4347b89ea) --- .../main/java/org/yuzu/yuzu_emu/model/Game.kt | 2 +- src/core/CMakeLists.txt | 2 + src/core/core.cpp | 12 +- src/core/file_sys/nx2_package.cpp | 295 ++++++++++++++++++ src/core/file_sys/nx2_package.h | 30 ++ .../loader/deconstructed_rom_directory.cpp | 34 +- src/core/loader/deconstructed_rom_directory.h | 9 +- src/core/loader/loader.cpp | 7 + src/core/loader/nsp.cpp | 74 ++++- src/core/loader/nsp.h | 1 + src/qt_common/qt_common.cpp | 2 +- src/yuzu/main.ui | 6 + src/yuzu/main_window.cpp | 123 ++++++++ src/yuzu/main_window.h | 1 + 14 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 src/core/file_sys/nx2_package.cpp create mode 100644 src/core/file_sys/nx2_package.h diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt index f5f0cda0e9..502fab0de9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -100,7 +100,7 @@ class Game( companion object { val extensions: Set = HashSet( - listOf("xci", "nsp", "nca", "nro", "nsz", "xcz") + listOf("xci", "nsp", "nca", "nro", "nsz", "xcz", "nx2") ) } } diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 1ff9f271d2..507a5e6561 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -124,6 +124,8 @@ add_library(core STATIC file_sys/kernel_executable.h file_sys/nca_metadata.cpp file_sys/nca_metadata.h + file_sys/nx2_package.cpp + file_sys/nx2_package.h file_sys/partition_filesystem.cpp file_sys/partition_filesystem.h file_sys/patch_manager.cpp diff --git a/src/core/core.cpp b/src/core/core.cpp index 67812abadc..d6b8843924 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -113,7 +113,17 @@ FileSys::VirtualFile GetGameFileFromPath(const FileSys::VirtualFilesystem& vfs, } if (Common::FS::IsDir(path)) { - return vfs->OpenFile(path + "/main", FileSys::OpenMode::Read); + // A loose ExeFS: main in the directory, else in its exefs/ subdirectory. Looked up through + // the directory, since a content URI on Android cannot have "/main" appended. + const auto dir = vfs->OpenDirectory(path, FileSys::OpenMode::Read); + if (dir == nullptr) { + return nullptr; + } + if (auto main = dir->GetFile("main")) { + return main; + } + const auto exefs = dir->GetSubdirectory("exefs"); + return exefs != nullptr ? exefs->GetFile("main") : nullptr; } return vfs->OpenFile(path, FileSys::OpenMode::Read); diff --git a/src/core/file_sys/nx2_package.cpp b/src/core/file_sys/nx2_package.cpp new file mode 100644 index 0000000000..6f6ecbfdfa --- /dev/null +++ b/src/core/file_sys/nx2_package.cpp @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/alignment.h" +#include "common/common_funcs.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/fs_util.h" +#include "common/fs/path_util.h" +#include "common/literals.h" +#include "common/logging.h" +#include "common/swap.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/fsmitm_romfsbuild.h" +#include "core/file_sys/nx2_package.h" +#include "core/file_sys/vfs/vfs.h" + +namespace FileSys { + +namespace { + +using namespace Common::Literals; + +// The PFS0 layout PartitionFilesystem reads: header, entries, string table, then the data, with +// entry offsets relative to the end of the string table. +struct PfsHeader { + u32_le magic; + u32_le num_entries; + u32_le strtab_size; + u32_le reserved; +}; +static_assert(sizeof(PfsHeader) == 0x10); + +struct PfsEntry { + u64_le offset; + u64_le size; + u32_le strtab_offset; + u32_le reserved; +}; +static_assert(sizeof(PfsEntry) == 0x18); + +constexpr size_t CopyChunkSize = 16_MiB; + +// A file of the package: one source file, or the RomFS image as its parts (offset in the image, +// file); the gaps between parts are zero. +struct PackageEntry { + std::string name; + u64 size; + std::vector> parts; +}; + +struct CopyJob { + u64 destination; + u64 size; + VirtualFile file; +}; + +bool IsSidecar(const VirtualFile& file) { + const auto extension = file->GetExtension(); + return extension == "nacp" || extension == "png" || extension == "jpg" || + extension == "jpeg" || extension == "bmp" || + (extension == "dat" && file->GetName().starts_with("icon_")); +} + +} // Anonymous namespace + +std::string PackNx2(const VirtualDir& dump_dir, const std::string& out_path, + const Nx2PackProgress& progress) { + if (dump_dir == nullptr) { + return "The dump folder could not be opened."; + } + const VirtualDir exefs = + IsDirectoryExeFS(dump_dir) ? dump_dir : dump_dir->GetSubdirectory("exefs"); + if (exefs == nullptr || !IsDirectoryExeFS(exefs)) { + return "No ExeFS found: the folder needs main and main.npdm, directly or in an exefs " + "folder."; + } + const VirtualDir title_dir = exefs == dump_dir ? exefs->GetParentDirectory() : dump_dir; + + std::vector entries; + const auto add = [&entries](std::string name, VirtualFile file) { + const bool present = std::any_of(entries.begin(), entries.end(), + [&name](const auto& entry) { return entry.name == name; }); + if (!present) { + const u64 size = file->GetSize(); + entries.push_back({std::move(name), size, {{0, std::move(file)}}}); + } + }; + + // The ExeFS (main, main.npdm, rtld, sdk, subsdk*, sidecars dropped next to them). A RomFS + // image already next to main is taken as the RomFS. + VirtualFile romfs_image; + for (const auto& file : exefs->GetFiles()) { + if (file->GetExtension() == "romfs") { + if (romfs_image == nullptr) { + romfs_image = file; + } + continue; + } + add(file->GetName(), file); + } + + // Sidecars in the game folder: a PFS0 has no parent for the loader to look in. + if (title_dir != nullptr && title_dir != exefs) { + for (const auto& file : title_dir->GetFiles()) { + if (IsSidecar(file)) { + add(file->GetName(), file); + } + } + } + + // The RomFS: that image, else the romfs/ folder next to the ExeFS laid out as one image. + if (romfs_image != nullptr) { + add(std::string{Nx2RomFSName}, romfs_image); + } else if (const auto romfs_dir = + title_dir != nullptr ? title_dir->GetSubdirectory("romfs") : nullptr) { + auto parts = RomFSBuildContext{romfs_dir}.Build(); + if (!parts.empty()) { + const u64 size = parts.back().first + parts.back().second->GetSize(); + entries.push_back({std::string{Nx2RomFSName}, size, std::move(parts)}); + } + } else { + LOG_WARNING(Loader, "Packing {} without a RomFS", out_path); + } + + // String table, padded so the data starts 0x20-aligned like Nintendo's own PFS0s. + std::vector strtab; + std::vector pfs_entries; + u64 data_size = 0; + for (const auto& entry : entries) { + pfs_entries.push_back(PfsEntry{ + .offset = data_size, + .size = entry.size, + .strtab_offset = static_cast(strtab.size()), + .reserved = 0, + }); + strtab.insert(strtab.end(), entry.name.begin(), entry.name.end()); + strtab.push_back('\0'); + data_size = Common::AlignUp(data_size + entry.size, 0x10); + } + const size_t metadata_size = + sizeof(PfsHeader) + pfs_entries.size() * sizeof(PfsEntry) + strtab.size(); + strtab.resize(strtab.size() + Common::AlignUp(metadata_size, 0x20) - metadata_size, '\0'); + const u64 data_start = Common::AlignUp(metadata_size, 0x20); + + const PfsHeader header{ + .magic = Common::MakeMagic('P', 'F', 'S', '0'), + .num_entries = static_cast(pfs_entries.size()), + .strtab_size = static_cast(strtab.size()), + .reserved = 0, + }; + + std::vector jobs; + u64 total = 0; + for (size_t i = 0; i < entries.size(); ++i) { + for (const auto& [offset, file] : entries[i].parts) { + const u64 size = file->GetSize(); + if (size != 0) { + jobs.push_back({data_start + pfs_entries[i].offset + offset, size, file}); + total += size; + } + } + } + + Common::FS::IOFile out{out_path, Common::FS::FileAccessMode::Write, + Common::FS::FileType::BinaryFile}; + if (!out.IsOpen()) { + return "The output file could not be created."; + } + const auto fail = [&out, &out_path](std::string error) { + out.Close(); + Common::FS::RemoveFile(out_path); + return error; + }; + + // Not sized up front: on Windows that writes every byte as zero first. The jobs are taken in + // offset order, so the file grows as they land; the OS zero-fills the gaps and paddings. + if (!out.WriteObject(header) || + out.WriteSpan(std::span{pfs_entries}) != pfs_entries.size() || + out.WriteSpan(std::span{strtab}) != strtab.size()) { + return fail("Writing the package failed (is the disk full?)."); + } + if (progress && !progress(0, total)) { + return fail("Cancelled."); + } + + // A RomFS is mostly small files, and copying them one by one waits on each file's open + // (antivirus scans included) with the disk idle. Several readers keep it busy; real files + // are read through their own handles, since the VFS serialises its reads behind one lock. + std::atomic next_job{0}; + std::atomic copied{0}; + std::atomic stop{false}; + std::atomic running{0}; + std::mutex out_mutex; + std::mutex error_mutex; + std::string copy_error; + const auto set_error = [&](std::string error) { + std::scoped_lock lk{error_mutex}; + if (copy_error.empty()) { + copy_error = std::move(error); + } + stop = true; + }; + + const auto copy_worker = [&] { + std::vector buffer(CopyChunkSize); + while (!stop) { + const size_t index = next_job.fetch_add(1); + if (index >= jobs.size()) { + break; + } + const CopyJob& job = jobs[index]; + + Common::FS::IOFile direct; + const auto path = job.file->GetFullPath(); + if (Common::FS::IsFile(path)) { + direct.Open(std::filesystem::path{Common::FS::ToU8String(path)}, + Common::FS::FileAccessMode::Read, Common::FS::FileType::BinaryFile); + if (direct.IsOpen() && direct.GetSize() != job.size) { + direct.Close(); + } + } + + for (u64 position = 0; position < job.size && !stop;) { + const size_t length = + static_cast((std::min)(CopyChunkSize, job.size - position)); + const auto data = std::span{buffer.data(), length}; + const size_t read = direct.IsOpen() ? direct.ReadSpan(data) + : job.file->Read(data.data(), length, position); + if (read != length) { + set_error("Reading " + path + " failed."); + break; + } + { + std::scoped_lock lk{out_mutex}; + if (!out.Seek(static_cast(job.destination + position)) || + out.WriteSpan(std::span{data}) != length) { + set_error("Writing the package failed (is the disk full?)."); + break; + } + } + position += length; + copied += length; + } + } + --running; + }; + + const size_t reader_count = + std::clamp(std::thread::hardware_concurrency() / 2, 4, 16); + std::vector readers; + running = reader_count; + for (size_t i = 0; i < reader_count; ++i) { + readers.emplace_back(copy_worker); + } + bool cancelled = false; + while (running > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds{100}); + if (progress && !cancelled && !progress(copied, total)) { + cancelled = true; + stop = true; + } + } + for (auto& reader : readers) { + reader.join(); + } + + if (cancelled) { + return fail("Cancelled."); + } + if (!copy_error.empty()) { + return fail(copy_error); + } + // The padding after the last file + if (!out.SetSize(data_start + data_size) || !out.Flush()) { + return fail("Writing the package failed (is the disk full?)."); + } + if (progress) { + progress(total, total); + } + LOG_INFO(Loader, "Packed {} files ({} bytes) into {}", jobs.size(), data_start + data_size, + out_path); + return {}; +} + +} // namespace FileSys diff --git a/src/core/file_sys/nx2_package.h b/src/core/file_sys/nx2_package.h new file mode 100644 index 0000000000..984ae2db2b --- /dev/null +++ b/src/core/file_sys/nx2_package.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "common/common_types.h" +#include "core/file_sys/vfs/vfs_types.h" + +namespace FileSys { + +// .nx2: a loose Switch 2 dump (ExeFS + RomFS) packed into one file. It is an extracted-type NSP, +// a PFS0 holding the ExeFS files directly, with the RomFS as a single image and any +// control.nacp/icon sidecars next to them, so the NSP loader boots it like the loose dump. +constexpr std::string_view Nx2Extension = "nx2"; +constexpr std::string_view Nx2RomFSName = "game.romfs"; + +// Called with the bytes written so far and the total; returning false cancels. +using Nx2PackProgress = std::function; + +// Packs the dump in dump_dir (the folder holding exefs/ and romfs/, or the ExeFS folder itself) +// into out_path. Returns an error message, empty on success. A failed or cancelled pack leaves +// no output file. +std::string PackNx2(const VirtualDir& dump_dir, const std::string& out_path, + const Nx2PackProgress& progress); + +} // namespace FileSys diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index 491535a62d..d2f3463ac2 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -286,7 +286,27 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(FileSys : AppLoader(std::move(file_)) , override_update(override_update_) { - const auto file_dir = file->GetContainingDirectory(); + ReadDumpMetadata(file->GetContainingDirectory()); +} + +AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory( + FileSys::VirtualDir directory, bool override_update_, bool read_dump_metadata) + : AppLoader(directory->GetFile("main")) + , dir(std::move(directory)) + , override_update(override_update_) +{ + if (read_dump_metadata) { + ReadDumpMetadata(dir); + } else { + romfs_exefs_dir = dir; + } +} + +// Title ID, icon, control data and RomFS version of a dump whose ExeFS is file_dir. +void AppLoader_DeconstructedRomDirectory::ReadDumpMetadata(const FileSys::VirtualDir& file_dir) { + if (file_dir == nullptr) { + return; + } const auto sidecar_dirs = FindSidecarDirs(file_dir); // Title ID @@ -353,18 +373,12 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(FileSys romfs_exefs_dir = file_dir; if (const auto title_dir = file_dir->GetParentDirectory()) { romfs_version = RomFSProductVersion(FindLooseRomFSDirectory(title_dir)); + } else if (LooseRomFS() != nullptr) { + // A packed dump (.nx2) has no parent directory: its RomFS is the game.romfs image in it. + romfs_version = RomFSProductVersion(FileSys::ExtractRomFS(romfs)); } } -AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory( - FileSys::VirtualDir directory, bool override_update_) - : AppLoader(directory->GetFile("main")) - , dir(std::move(directory)) - , override_update(override_update_) -{ - romfs_exefs_dir = dir; -} - // Loaders made only for metadata (the game list, title lookups) never need the RomFS, and several // of them were alive with a whole copy each. const FileSys::VirtualFile& AppLoader_DeconstructedRomDirectory::LooseRomFS() { diff --git a/src/core/loader/deconstructed_rom_directory.h b/src/core/loader/deconstructed_rom_directory.h index db0120f4ad..f2dfd7848a 100644 --- a/src/core/loader/deconstructed_rom_directory.h +++ b/src/core/loader/deconstructed_rom_directory.h @@ -28,8 +28,11 @@ namespace Loader { class AppLoader_DeconstructedRomDirectory final : public AppLoader { public: explicit AppLoader_DeconstructedRomDirectory(FileSys::VirtualFile main_file, bool override_update_ = false); - // Overload to accept exefs directory. Must contain 'main' and 'main.npdm' - explicit AppLoader_DeconstructedRomDirectory(FileSys::VirtualDir directory, bool override_update_ = false); + // Overload to accept exefs directory. Must contain 'main' and 'main.npdm'. read_dump_metadata + // also takes the title, icon and version from the directory, as for a loose dump: set for a + // packed dump (.nx2), whose files have no containing directory to find them from. + explicit AppLoader_DeconstructedRomDirectory(FileSys::VirtualDir directory, bool override_update_ = false, + bool read_dump_metadata = false); /** * Identifies whether or not the given file is a deconstructed ROM directory. @@ -61,6 +64,8 @@ class AppLoader_DeconstructedRomDirectory final : public AppLoader { /// The loose RomFS next to the ExeFS, found (and built) on first use. const FileSys::VirtualFile& LooseRomFS(); + void ReadDumpMetadata(const FileSys::VirtualDir& file_dir); + FileSys::ProgramMetadata metadata; FileSys::VirtualFile romfs; FileSys::VirtualDir romfs_exefs_dir; ///< Where LooseRomFS looks diff --git a/src/core/loader/loader.cpp b/src/core/loader/loader.cpp index 839bb4d80b..69a1a8ba41 100644 --- a/src/core/loader/loader.cpp +++ b/src/core/loader/loader.cpp @@ -115,6 +115,11 @@ bool HasApplicationProgramContent(const std::shared_ptr& nsp) { return false; } + // An extracted-type NSP (a packed dump, .nx2) holds its ExeFS directly, with no NCAs. + if (nsp->IsExtractedType()) { + return nsp->GetProgramStatus() == ResultStatus::Success; + } + const auto& ncas = nsp->GetNCAs(); return std::any_of(ncas.cbegin(), ncas.cend(), [](const auto& title_entry) { const auto& nca_map = title_entry.second; @@ -215,6 +220,8 @@ FileType GuessFromFilename(const std::string& name) { return FileType::NUP; else if (extension == "nsz") return FileType::NSZ; + else if (extension == "nx2") // a packed Switch 2 dump: an extracted-type NSP + return FileType::NSP; else if (extension == "kip") return FileType::KIP; return FileType::Unknown; diff --git a/src/core/loader/nsp.cpp b/src/core/loader/nsp.cpp index 8b2c79394d..48198ea922 100644 --- a/src/core/loader/nsp.cpp +++ b/src/core/loader/nsp.cpp @@ -4,6 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include #include #include "common/common_types.h" @@ -16,6 +19,7 @@ #include "core/file_sys/registered_cache.h" #include "core/file_sys/romfs_factory.h" #include "core/file_sys/submission_package.h" +#include "core/file_sys/vfs/vfs_vector.h" #include "core/hle/kernel/k_process.h" #include "core/hle/service/filesystem/filesystem.h" #include "core/loader/deconstructed_rom_directory.h" @@ -24,6 +28,32 @@ namespace Loader { +namespace { + +std::string PackageStem(const FileSys::VirtualFile& package) { + const auto& name = package->GetName(); + return name.substr(0, name.rfind('.')); +} + +// A packed dump (.nx2) has control data only if its dump had sidecars. Cover art and a NACP can +// also sit next to the package: ".png" (.jpg, .jpeg, .bmp), ".nacp". +FileSys::VirtualFile FindPackageSidecar(const FileSys::VirtualFile& package, + std::initializer_list extensions) { + const auto dir = package != nullptr ? package->GetContainingDirectory() : nullptr; + if (dir == nullptr) { + return nullptr; + } + const auto stem = PackageStem(package); + for (const auto extension : extensions) { + if (auto sidecar = dir->GetFile(stem + '.' + std::string{extension})) { + return sidecar; + } + } + return nullptr; +} + +} // Anonymous namespace + AppLoader_NSP::AppLoader_NSP(FileSys::VirtualFile file_, const Service::FileSystem::FileSystemController& fsc, const FileSys::ContentProvider& content_provider, u64 program_id, @@ -35,7 +65,27 @@ AppLoader_NSP::AppLoader_NSP(FileSys::VirtualFile file_, } if (nsp->IsExtractedType()) { - secondary_loader = std::make_unique(nsp->GetExeFS(), false); + // A packed dump (.nx2): title, icon and version come from its sidecars and RomFS + secondary_loader = + std::make_unique(nsp->GetExeFS(), false, true); + + // Else from sidecars next to the package, looked up now: the Read* functions are also + // called from guest service calls, which on Android must not reach the content URI + // lookups (JNI) a package outside the app's storage needs. + std::vector icon; + if (secondary_loader->ReadIcon(icon) != ResultStatus::Success) { + icon_file = FindPackageSidecar(file, {"png", "jpg", "jpeg", "bmp"}); + if (icon_file != nullptr) { + icon_file = std::make_shared(icon_file->ReadAllBytes(), + icon_file->GetName()); + } + } + FileSys::NACP control; + if (secondary_loader->ReadControlData(control) != ResultStatus::Success) { + if (const auto sidecar = FindPackageSidecar(file, {"nacp"})) { + nacp_file = std::make_unique(sidecar); + } + } } else { const auto control_nca = nsp->GetNCA(FileSys::GetBaseTitleID(nsp->GetProgramTitleID()), FileSys::ContentRecordType::Control); @@ -228,6 +278,9 @@ ResultStatus AppLoader_NSP::ReadProgramIds(std::vector& out_program_ids) { ResultStatus AppLoader_NSP::ReadIcon(std::vector& buffer) { if (icon_file == nullptr) { + if (nsp->IsExtractedType() && secondary_loader != nullptr) { + return secondary_loader->ReadIcon(buffer); + } return ResultStatus::ErrorNoControl; } @@ -237,6 +290,15 @@ ResultStatus AppLoader_NSP::ReadIcon(std::vector& buffer) { ResultStatus AppLoader_NSP::ReadTitle(std::string& title) { if (nacp_file == nullptr) { + if (nsp->IsExtractedType()) { + if (secondary_loader != nullptr && + secondary_loader->ReadTitle(title) == ResultStatus::Success && !title.empty()) { + return ResultStatus::Success; + } + // The package is named after its dump folder when packed + title = PackageStem(file); + return ResultStatus::Success; + } return ResultStatus::ErrorNoControl; } @@ -246,6 +308,9 @@ ResultStatus AppLoader_NSP::ReadTitle(std::string& title) { ResultStatus AppLoader_NSP::ReadControlData(FileSys::NACP& nacp) { if (nacp_file == nullptr) { + if (nsp->IsExtractedType() && secondary_loader != nullptr) { + return secondary_loader->ReadControlData(nacp); + } return ResultStatus::ErrorNoControl; } @@ -253,6 +318,13 @@ ResultStatus AppLoader_NSP::ReadControlData(FileSys::NACP& nacp) { return ResultStatus::Success; } +ResultStatus AppLoader_NSP::ReadDisplayVersion(std::string& version) { + if (nsp->IsExtractedType() && secondary_loader != nullptr) { + return secondary_loader->ReadDisplayVersion(version); + } + return AppLoader::ReadDisplayVersion(version); +} + ResultStatus AppLoader_NSP::ReadManualRomFS(FileSys::VirtualFile& out_file) { const auto nca = nsp->GetNCA(nsp->GetProgramTitleID(), FileSys::ContentRecordType::HtmlDocument); diff --git a/src/core/loader/nsp.h b/src/core/loader/nsp.h index 7ce436c67e..d4610bb8ad 100644 --- a/src/core/loader/nsp.h +++ b/src/core/loader/nsp.h @@ -54,6 +54,7 @@ class AppLoader_NSP final : public AppLoader { ResultStatus ReadIcon(std::vector& buffer) override; ResultStatus ReadTitle(std::string& title) override; ResultStatus ReadControlData(FileSys::NACP& nacp) override; + ResultStatus ReadDisplayVersion(std::string& version) override; ResultStatus ReadManualRomFS(FileSys::VirtualFile& out_file) override; ResultStatus ReadBanner(std::vector& buffer) override; diff --git a/src/qt_common/qt_common.cpp b/src/qt_common/qt_common.cpp index 7ad7a477d2..72c80e80dd 100644 --- a/src/qt_common/qt_common.cpp +++ b/src/qt_common/qt_common.cpp @@ -59,7 +59,7 @@ const QStringList supported_file_extensions = {QStringLiteral("nro"), QStringLit QStringLiteral("nca"), QStringLiteral("xci"), QStringLiteral("xcz"), QStringLiteral("nsp"), QStringLiteral("nsz"), QStringLiteral("nup"), - QStringLiteral("kip")}; + QStringLiteral("kip"), QStringLiteral("nx2")}; Core::Frontend::WindowSystemType GetWindowSystemType() { // Determine WSI type based on Qt platform. diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index 5fe25aa252..c35df3f262 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -219,6 +219,7 @@ + @@ -290,6 +291,11 @@ &Verify Installed Contents + + + &Pack Switch 2 Dump to .nx2... + + &About Eglantine diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 741b248e4d..007a248bf4 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -8,6 +8,11 @@ #undef VMA_IMPLEMENTATION #endif +#include +#include +#include +#include + #include #include "common/fs/path_util.h" #include "common/game_presets.h" @@ -155,6 +160,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "core/file_sys/card_image.h" #include "core/file_sys/common_funcs.h" #include "core/file_sys/nup_writer.h" +#include "core/file_sys/nx2_package.h" #include "core/file_sys/romfs.h" #include "core/file_sys/savedata_factory.h" @@ -1639,6 +1645,7 @@ void MainWindow::ConnectMenuEvents() { connect_menu(ui->action_Log_Folder, &MainWindow::OnOpenLogFolder); connect_menu(ui->action_Verify_installed_contents, &MainWindow::OnVerifyInstalledContents); + connect_menu(ui->action_Pack_Nx2, &MainWindow::OnPackNx2); connect_menu(ui->action_Firmware_From_Folder, &MainWindow::OnInstallFirmware); connect_menu(ui->action_Firmware_From_ZIP, &MainWindow::OnInstallFirmwareFromZIP); connect_menu(ui->action_Install_Keys, &MainWindow::OnInstallDecryptionKeys); @@ -4005,6 +4012,122 @@ void MainWindow::OnVerifyInstalledContents() { QtCommon::Content::VerifyInstalledContents(); } +// Packs a loose Switch 2 dump (exefs/ + romfs/) into one .nx2 file, which loads like any other +// game file on PC and Android. +void MainWindow::OnPackNx2() { + const QString dump_path = QFileDialog::getExistingDirectory( + this, tr("Select a Switch 2 Dump (the folder holding exefs and romfs)"), + QString::fromStdString(UISettings::values.roms_path)); + if (dump_path.isEmpty()) { + return; + } + + // Name the package after the dump folder, next to it. + QDir title_dir{dump_path}; + if (title_dir.dirName().compare(QStringLiteral("exefs"), Qt::CaseInsensitive) == 0) { + title_dir.cdUp(); + } + const QString name = title_dir.dirName(); + QDir parent_dir = title_dir; + parent_dir.cdUp(); + const QString out_path = QFileDialog::getSaveFileName( + this, tr("Save Packed Dump"), + parent_dir.filePath(name + QStringLiteral(".") + + QString::fromUtf8(FileSys::Nx2Extension.data(), + static_cast(FileSys::Nx2Extension.size()))), + tr("Packed Switch 2 Dump (*.nx2)")); + if (out_path.isEmpty()) { + return; + } + + const auto dump_dir = + QtCommon::vfs->OpenDirectory(dump_path.toStdString(), FileSys::OpenMode::Read); + const std::string out_path_string = out_path.toStdString(); + + QProgressDialog progress(tr("Packing %1...\nIndexing the RomFS").arg(name), tr("Cancel"), 0, + 1000, this); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + progress.setAutoClose(false); + progress.setAutoReset(false); + progress.setValue(0); + + // Packed on its own thread: this one keeps the window (and the game list, which reloads when + // the package appears in a game folder) responsive and shows progress. + std::atomic done{0}; + std::atomic total{0}; + std::atomic cancel{false}; + std::atomic finished{false}; + std::string error; + std::thread packer([&] { + error = FileSys::PackNx2(dump_dir, out_path_string, [&](u64 done_bytes, u64 total_bytes) { + done = done_bytes; + total = total_bytes; + return !cancel; + }); + finished = true; + }); + + const auto format_duration = [this](qint64 seconds) { + if (seconds >= 3600) { + return tr("%1 h %2 min").arg(seconds / 3600).arg((seconds % 3600) / 60); + } + if (seconds >= 60) { + return tr("%1 min").arg((seconds + 30) / 60); + } + return tr("%1 s").arg(seconds); + }; + std::optional copy_start; + QEventLoop loop; + QTimer timer; + connect(&timer, &QTimer::timeout, &loop, [&] { + if (progress.wasCanceled()) { + cancel = true; + } + const u64 done_bytes = done; + const u64 total_bytes = total; + if (total_bytes != 0) { + if (!copy_start) { + copy_start = std::chrono::steady_clock::now(); + } + progress.setValue(static_cast(done_bytes * 1000 / total_bytes)); + const double elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - *copy_start) + .count(); + QString speed_eta; + if (done_bytes != 0 && elapsed >= 3.0) { + const double rate = static_cast(done_bytes) / elapsed; + speed_eta = tr(" · %1 MB/s · about %2 left") + .arg(rate / 1048576.0, 0, 'f', 0) + .arg(format_duration(static_cast( + static_cast(total_bytes - done_bytes) / rate))); + } + progress.setLabelText(tr("Packing %1...\n%2 of %3 GB%4") + .arg(name) + .arg(static_cast(done_bytes) / 1073741824.0, 0, 'f', 1) + .arg(static_cast(total_bytes) / 1073741824.0, 0, 'f', 1) + .arg(speed_eta)); + } + if (finished) { + loop.quit(); + } + }); + timer.start(250); + loop.exec(); + // Stopped before the result box: a tick during its event loop would show the dialog again. + timer.stop(); + packer.join(); + progress.close(); + + if (!error.empty()) { + QMessageBox::warning(this, tr("Packing Failed"), QString::fromStdString(error)); + return; + } + QMessageBox::information( + this, tr("Packing Succeeded"), + tr("Saved %1.\nIt loads like any other game file, on PC and on Android.").arg(out_path)); +} + void MainWindow::OnInstallFirmware() { QtCommon::Content::InstallFirmware(); OnCheckFirmwareDecryption(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 9e98b97ef9..b2a6535e13 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -393,6 +393,7 @@ private slots: void OnOpenModFolder(); void OnOpenLogFolder(); void OnVerifyInstalledContents(); + void OnPackNx2(); void OnInstallFirmware(); void OnInstallFirmwareFromZIP(); void OnInstallDecryptionKeys(); From 7c815b729b3579e29a53fd19f1ee82efcb10c0cd Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:20:00 -0500 Subject: [PATCH 089/154] nx2: Never wait on kernel locks on the GPU thread for sparse blocks Mario Kart World deadlocked loading a race. A guest thread hit an NCE fault on GPU-tracked memory on core 2 and waited on the GPU; the GPU front thread waited on the back thread; the back thread, backing a sparse block for a software blit (BackSparseWrite), waited on the process page table lock; and that lock had been handed to a guest thread runnable on core 2 only. Sparse blocks are now allocated and mapped ahead by a refill thread, up to 16 blocks of 4 MiB (pool and address space; host memory only once written), and BackSparseWrite takes them from that pool. With none ready it waits up to 200 ms, then drops that write and leaves the block empty for the next one; after a timeout it stops waiting until the refill thread adds a block. Region creation, destruction and freeing no longer hold the region mutex across kernel, registry or GPU calls, since the GPU thread takes it. On nx2: merged with the reworked sparse code, whose behaviour is kept where the two overlap: the zero writes over unwritten blocks left out (40902b09c7), pending blocks and the waits on them, blocks given up once the game maps tiles (216), lazily backed GPU-only regions (which take spares too) and the allocation outside m_region_mutex. What this adds: the GPU thread takes blocks from the spare pool instead of allocating and mapping them itself, and puts a block back instead of freeing it when its region went meanwhile; 214, 215 and 216 return freed pages to the kernel, and 214 and 215 back, map and unmap, without holding m_region_mutex. A spare for a region's short last block keeps all its pages, so GPU-only memory is counted by pages. Re-creating a region at the same start (76357b7fbe on nx2-mkw) is left to #212, which releases the previous backing the same way. With #212 merged: its release of a re-created region's backing is kept, without its own byte count (FreeReleasedRegions counts the pages out when it frees them; counted twice, the GPU-only total went too low). Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 81662c7245c2d149372236d0a73f8eb252f18dd5) --- src/core/hle/service/nx2_gsv.h | 298 +++++++++++++++++++++++++-------- 1 file changed, 229 insertions(+), 69 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 805e91594a..c4d90d4186 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -356,6 +356,8 @@ class INx2GpuSession final : public ServiceFramework { ~INx2GpuSession() override { // Waits for a backing the GPU thread may be doing. Tegra::Nx2SetSparseBacking({}); + // Then the refill thread, done with the block it's on. + m_spare_thread = {}; for (auto* event : m_events) { if (event != nullptr) { m_context.CloseEvent(event); @@ -605,23 +607,30 @@ class INx2GpuSession final : public ServiceFramework { w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], w[9], w[10], w[11], w[12]); if (last > start && ((last + 1 - start) % Core::Memory::YUZU_PAGESIZE) == 0) { const u64 size = last + 1 - start; - std::scoped_lock lk{m_region_mutex}; + // Kernel and registry calls are made without m_region_mutex: the GPU thread takes it + // (BackSparseWrite), and they can wait on what waits on the GPU. FreeReleasedRegions(); if ((w[7] & SparseRegionFlag) == 0) { + std::scoped_lock lk{m_region_mutex}; m_gpu_only_ranges[start] = size; // kept out of 216, however it is backed } if ((w[7] & SparseRegionFlag) != 0 && LazySparseRegions()) { // Title 1's terrain texture arrays and layer pools (170 MB-5.8 GB, mostly empty): // backed whole, they ran the pool dry and the later ones got no memory at all. // The region gets a device range of its own, unbacked pages read zero, and - // BackSparseWrite gives a block memory before the GPU writes into it. - m_sparse_regions[start] = { - .size = size, - .blocks = std::vector(Common::DivCeil(size, SparseBlockSize)), - }; + // BackSparseWrite gives a block memory before the GPU writes into it. The device + // range first: the blocks BackSparseWrite backs are mapped in it. Nvidia::Devices::Nx2GpuMemoryRegistry::Get().CreateSparse(start, size); - Tegra::Nx2SparseRegionCreated(start, size, SparseBlockSize); - Tegra::Nx2AddSparseSpan(start, size); + { + std::scoped_lock lk{m_region_mutex}; + m_sparse_regions[start] = { + .size = size, + .blocks = std::vector(Common::DivCeil(size, SparseBlockSize)), + }; + Tegra::Nx2SparseRegionCreated(start, size, SparseBlockSize); + Tegra::Nx2AddSparseSpan(start, size); + } + StartSpareRefill(); LOG_INFO(Service, "nx2:gpu 214 sparse region [{:#x}, {:#x}] backed as the GPU writes it", start, last); } else if (LazyGpuOnlyRegions()) { @@ -629,21 +638,32 @@ class INx2GpuSession final : public ServiceFramework { // title 4's render targets, 285-300 MB regions): like a sparse region it gets a // device range of its own and memory per block only when something writes guest // memory there (downloads, copies). The texture cache sees ordinary memory. - m_sparse_regions[start] = { - .gpu_only = true, - .size = size, - .blocks = std::vector(Common::DivCeil(size, SparseBlockSize)), - }; Nvidia::Devices::Nx2GpuMemoryRegistry::Get().CreateSparse(start, size); - Tegra::Nx2AddSparseSpan(start, size); + { + std::scoped_lock lk{m_region_mutex}; + m_sparse_regions[start] = { + .gpu_only = true, + .size = size, + .blocks = std::vector(Common::DivCeil(size, SparseBlockSize)), + }; + Tegra::Nx2AddSparseSpan(start, size); + } + StartSpareRefill(); LOG_INFO(Service, "nx2:gpu 214 GPU-only region [{:#x}, {:#x}] backed as it is written", start, last); } else if (const auto address = BackGpuOnlyRegion(start, size)) { Nvidia::Devices::Nx2GpuMemoryRegistry::Get().MapAt(start, *address, size); Tegra::Nx2GpuOnlyRegionCreated(start, size); + u64 in_use{}; + u64 peak{}; + { + std::scoped_lock lk{m_region_mutex}; + in_use = m_gpu_only_bytes; + peak = m_gpu_only_peak; + } LOG_INFO(Service, "nx2:gpu 214 GPU-only region [{:#x}, {:#x}] backed at {:#x} " "({} MiB of GPU-only memory in use, peak {} MiB)", - start, last, *address, m_gpu_only_bytes >> 20, m_gpu_only_peak >> 20); + start, last, *address, in_use >> 20, peak >> 20); } } else { LOG_WARNING(Service, "nx2:gpu 214 unexpected region [{:#x}, {:#x}]", start, last); @@ -663,41 +683,62 @@ class INx2GpuSession final : public ServiceFramework { const u64 last = u64{w[5]} | (u64{w[6]} << 32); LOG_DEBUG(Service, "nx2:gpu 215 destroy sparse region [{:#x}, {:#x}] words {:#x} {:#x} {:#x} {:#x}", first, last, w[1], w[2], w[7], w[8]); - std::scoped_lock lk{m_region_mutex}; + // As in 214: kernel, registry and GPU calls without m_region_mutex. FreeReleasedRegions(); - if (m_gpu_only_ranges.erase(first) != 0 && last > first) { - DropGpuOnlyTiles(first, last - first + 1, false); - m_shared_regions.erase(first); - std::erase_if(m_shared_memory, [first](const auto& entry) { return entry.second == first; }); - } - if (last > first) { - if (const auto region = m_sparse_regions.find(first); region != m_sparse_regions.end()) { - m_tile_bytes -= Core::Memory::YUZU_PAGESIZE * - static_cast(std::count(region->second.tile_pages.begin(), - region->second.tile_pages.end(), true)); - m_sparse_regions.erase(region); - Tegra::Nx2SparseRegionDestroyed(first); + if (last <= first) { + std::scoped_lock lk{m_region_mutex}; + m_gpu_only_ranges.erase(first); + } else { + // Under the lock only what BackSparseWrite and 216 read. + bool sparse = false; + std::vector backings; + { + std::scoped_lock lk{m_region_mutex}; + if (m_gpu_only_ranges.erase(first) != 0) { + DropGpuOnlyTiles(first, last - first + 1, false); + m_shared_regions.erase(first); + std::erase_if(m_shared_memory, + [first](const auto& entry) { return entry.second == first; }); + } + if (const auto region = m_sparse_regions.find(first); + region != m_sparse_regions.end()) { + m_tile_bytes -= Core::Memory::YUZU_PAGESIZE * + static_cast(std::count(region->second.tile_pages.begin(), + region->second.tile_pages.end(), true)); + m_sparse_regions.erase(region); + Tegra::Nx2SparseRegionDestroyed(first); + sparse = true; + } else { + Tegra::Nx2GpuOnlyRegionDestroyed(first); + } + // The region's backing: all of it, or the blocks a sparse region got as it was + // written. + for (auto region = m_gpu_only_regions.lower_bound(first); + region != m_gpu_only_regions.end() && region->first <= last;) { + if (region->first + region->second.size - 1 > last) { + ++region; + continue; + } + backings.push_back(std::move(region->second)); + region = m_gpu_only_regions.erase(region); + } + } + if (sparse) { Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DestroySparse(first, last - first + 1); } else { - Tegra::Nx2GpuOnlyRegionDestroyed(first); Nvidia::Devices::Nx2GpuMemoryRegistry::Get().Unmap(first, last - first + 1); } - // The region's backing: all of it, or the blocks a sparse region got as it was written. - std::shared_ptr> done; - for (auto region = m_gpu_only_regions.lower_bound(first); - region != m_gpu_only_regions.end() && region->first <= last;) { - if (region->first + region->second.size - 1 > last) { - ++region; - continue; + if (!backings.empty()) { + for (const auto& backing : backings) { + Nvidia::Devices::Nx2GpuMemoryRegistry::Get().ReleaseBacking(backing.cpu_address, + backing.size); } - Nvidia::Devices::Nx2GpuMemoryRegistry::Get().ReleaseBacking( - region->second.cpu_address, region->second.size); - if (!done) { - done = std::make_shared>(false); - system.GPU().RunInOrder([done] { done->store(true, std::memory_order_release); }); + auto done = std::make_shared>(false); + system.GPU().RunInOrder([done] { done->store(true, std::memory_order_release); }); + std::scoped_lock lk{m_region_mutex}; + for (auto& backing : backings) { + m_released_regions.push_back({std::move(backing), done}); } - m_released_regions.push_back({std::move(region->second), done}); - region = m_gpu_only_regions.erase(region); } } SignalAfterGpu(w[8]); @@ -717,9 +758,9 @@ class INx2GpuSession final : public ServiceFramework { const auto data = ctx.BufferDescriptorA().empty() ? std::span{} : ctx.ReadBufferA(0); const size_t count = data.size() / sizeof(SparseMapRequest); std::vector kinds(count); + FreeReleasedRegions(); // takes kernel locks: before m_region_mutex { std::scoped_lock lk{m_region_mutex}; - FreeReleasedRegions(); ++m_tile_calls; for (size_t i = 0; i < count; ++i) { SparseMapRequest request; @@ -765,25 +806,46 @@ class INx2GpuSession final : public ServiceFramework { return true; } - // Returns the pages of destroyed regions the GPU thread is done with to the kernel. + // Returns the pages of destroyed regions the GPU thread is done with to the kernel. Takes + // kernel locks: called without m_region_mutex. void FreeReleasedRegions() { + std::vector regions; + { + std::scoped_lock lk{m_region_mutex}; + std::erase_if(m_released_regions, [&](ReleasedRegion& released) { + if (!released.done->load(std::memory_order_acquire)) { + return false; + } + regions.push_back(std::move(released.region)); + return true; + }); + } + if (regions.empty()) { + return; + } auto* const process = system.ApplicationProcess(); - std::erase_if(m_released_regions, [&](ReleasedRegion& released) { - if (!released.done->load(std::memory_order_acquire)) { - return false; - } - auto& region = released.region; + u64 freed = 0; + for (auto& region : regions) { if (process != nullptr && R_FAILED(process->GetPageTable().UnmapPageGroup( region.cpu_address, *region.pages, Kernel::KMemoryState::IoMemory))) { LOG_ERROR(Service, "nx2:gpu could not unmap GPU-only backing {:#x}+{:#x}", region.cpu_address, region.size); - return true; // still mapped: keep its pages + continue; // still mapped: keep its pages } + freed += region.pages->GetNumPages() * Core::Memory::YUZU_PAGESIZE; region.pages->Close(system.Kernel()); - m_gpu_only_bytes -= region.size; - return true; - }); + } + { + std::scoped_lock lk{m_region_mutex}; + m_gpu_only_bytes -= (std::min)(m_gpu_only_bytes, freed); + } + // Memory came back: a refill that found none can try again. + { + std::scoped_lock lk{m_spare_mutex}; + m_spare_freed = true; + } + m_spare_cv.notify_all(); } struct MappedBacking { @@ -850,7 +912,8 @@ class INx2GpuSession final : public ServiceFramework { return std::nullopt; } - // Records a mapped backing for the region at gpu_address. Needs m_region_mutex. + // Records a mapped backing for the region at gpu_address (backing.size bytes of it; a spare + // block for a region's short last block has more pages). Needs m_region_mutex. void RegisterGpuOnlyBacking(u64 gpu_address, MappedBacking&& backing) { // A title may create the same region again from the same start with a different size // (Mario Kart World walks 300 MB -> 600 MB -> 1 GB on the same address). Overwriting the @@ -864,22 +927,24 @@ class INx2GpuSession final : public ServiceFramework { it->second.size); auto done = std::make_shared>(false); system.GPU().RunInOrder([done] { done->store(true, std::memory_order_release); }); - m_gpu_only_bytes -= it->second.size; + // Counted out by FreeReleasedRegions, by pages, once the pages are freed. m_released_regions.push_back({std::move(it->second), done}); m_gpu_only_regions.erase(it); } + const u64 bytes = backing.pages->GetNumPages() * Core::Memory::YUZU_PAGESIZE; m_gpu_only_regions[gpu_address] = {backing.address, backing.size, std::move(backing.pages)}; - m_gpu_only_bytes += backing.size; + m_gpu_only_bytes += bytes; m_gpu_only_peak = (std::max)(m_gpu_only_peak, m_gpu_only_bytes); } - // Needs m_region_mutex. + // Allocates and maps without m_region_mutex (kernel locks), then records under it. std::optional BackGpuOnlyRegion(u64 gpu_address, u64 size) { auto backing = AllocateGpuOnlyBacking(size, true, false); if (!backing) { return std::nullopt; } const u64 address = backing->address; + std::scoped_lock lk{m_region_mutex}; RegisterGpuOnlyBacking(gpu_address, std::move(*backing)); return address; } @@ -887,7 +952,8 @@ class INx2GpuSession final : public ServiceFramework { // Runs before a GPU-side write that may land in a sparse region (Tegra::Nx2BackSparseWrite, // usually on the GPU thread): the blocks the write touches get memory, cleared, behind the // region's device pages. The GPU mapping stays as it is, so the images over the region keep - // their storage and only see the write. + // their storage and only see the write. The memory comes from the spare blocks (TakeSpare): no + // kernel locks here. void BackSparseWrite(u64 gpu_address, u64 size, const u8* data) { struct Pending { u64 region; @@ -952,7 +1018,7 @@ class INx2GpuSession final : public ServiceFramework { m_sparse_zero_bytes >> 20); } } - // Allocating and mapping happens outside the lock (see AllocateGpuOnlyBacking). + // Spare blocks are taken outside the lock (TakeSpare can wait for the refill thread). for (const auto& [start, block] : todo) { u64 block_size = SparseBlockSize; { @@ -962,7 +1028,8 @@ class INx2GpuSession final : public ServiceFramework { block_size = (std::min)(SparseBlockSize, it->second.size - block * SparseBlockSize); } } - auto backing = AllocateGpuOnlyBacking(block_size, false, true); + bool exhausted = false; + auto backing = TakeSpare(exhausted); bool stale = false; { std::scoped_lock lk{m_region_mutex}; @@ -974,7 +1041,7 @@ class INx2GpuSession final : public ServiceFramework { stale = true; it->second.blocks[block] = BlockWritten; Tegra::Nx2SparseBlockWritten(start, block); - } else if (!backing) { + } else if (!backing && exhausted) { auto& region = it->second; region.blocks[block] = BlockFailed; // out of memory: not again for every write if (!region.warned) { @@ -983,9 +1050,18 @@ class INx2GpuSession final : public ServiceFramework { "to its unbacked blocks are dropped", start); } + } else if (!backing) { + // No spare ready in time: left empty, so the next write to it tries again. + it->second.blocks[block] = BlockEmpty; + if (++m_spare_drops <= 16 || (m_spare_drops & (m_spare_drops - 1)) == 0) { + LOG_WARNING(Service, "nx2:gpu sparse block {:#x}: no memory ready for it, GPU " + "write dropped ({} so far)", + start + block * SparseBlockSize, m_spare_drops); + } } else { const u64 block_address = start + block * SparseBlockSize; const u64 address = backing->address; + backing->size = block_size; // a spare is a whole block: the last may be shorter it->second.blocks[block] = BlockBacked; RegisterGpuOnlyBacking(block_address, std::move(*backing)); Nvidia::Devices::Nx2GpuMemoryRegistry::Get().BackSparse(block_address, address, @@ -1002,13 +1078,9 @@ class INx2GpuSession final : public ServiceFramework { } m_region_cv.notify_all(); if (stale && backing) { - // Never handed to the GPU: unmap and free right away. - if (auto* const process = system.ApplicationProcess(); process != nullptr) { - process->GetPageTable().UnmapPageGroup(Common::ProcessAddress{backing->address}, - *backing->pages, - Kernel::KMemoryState::IoMemory); - } - backing->pages->Close(system.Kernel()); + // Never handed to the GPU, still clear: back to the spares (freeing it would take + // kernel locks here). + ReturnSpare(std::move(*backing)); } } if (waits_for_other) { @@ -1026,6 +1098,85 @@ class INx2GpuSession final : public ServiceFramework { } } + // Sparse blocks get memory on the GPU thread, which must never wait on the kernel: a guest + // thread holding the page table lock can need a CPU core that is waiting on the GPU. Mario + // Kart World deadlocked loading a race that way: core 2 in a fault handler invalidating GPU + // memory, the GPU behind the back thread, the back thread on the page table lock, and the + // lock's owner runnable on core 2 only. So blocks are allocated and mapped ahead, on a thread + // of their own, and BackSparseWrite takes them from here. Pool and address space only: host + // memory is used once a block is written. + static constexpr size_t SpareBlocks = 16; + static constexpr auto SpareWait = std::chrono::milliseconds{200}; + + void StartSpareRefill() { + std::scoped_lock lk{m_spare_mutex}; + if (!m_spare_thread.joinable()) { + m_spare_thread = std::jthread([this](std::stop_token stop) { RefillSpares(stop); }); + } + } + + void RefillSpares(std::stop_token stop) { + Common::SetCurrentThreadName("nx2:SparseRefill"); + bool failed = false; + while (true) { + { + std::unique_lock lk{m_spare_mutex}; + if (failed) { + // Out of memory: again once regions are freed, or in a second. + m_spare_cv.wait_for(lk, stop, std::chrono::seconds{1}, [&] { return m_spare_freed; }); + m_spare_freed = false; + } + if (!m_spare_cv.wait(lk, stop, [&] { return m_spare_blocks.size() < SpareBlocks; })) { + return; + } + } + auto backing = AllocateGpuOnlyBacking(SparseBlockSize, false, true); + { + std::scoped_lock lk{m_spare_mutex}; + failed = !backing; + m_spare_failed = failed; + if (backing) { + m_spare_blocks.push_back(std::move(*backing)); + m_spare_starved = false; + } + } + m_spare_cv.notify_all(); + } + } + + // A spare block for BackSparseWrite. None: `exhausted` when the pool ran out of memory, else + // the refill thread didn't have one ready in time (it may be stuck in the kernel behind the + // GPU itself: no more waiting then until it adds one). + std::optional TakeSpare(bool& exhausted) { + std::unique_lock lk{m_spare_mutex}; + if (m_spare_blocks.empty() && !m_spare_failed && !m_spare_starved) { + m_spare_cv.notify_all(); + if (!m_spare_cv.wait_for(lk, SpareWait, + [&] { return !m_spare_blocks.empty() || m_spare_failed; })) { + m_spare_starved = true; + } + } + if (m_spare_blocks.empty()) { + exhausted = m_spare_failed; + return std::nullopt; + } + MappedBacking backing = std::move(m_spare_blocks.back()); + m_spare_blocks.pop_back(); + lk.unlock(); + m_spare_cv.notify_all(); + return backing; + } + + // A spare TakeSpare gave out that went unused. + void ReturnSpare(MappedBacking&& backing) { + backing.size = SparseBlockSize; + { + std::scoped_lock lk{m_spare_mutex}; + m_spare_blocks.push_back(std::move(backing)); + } + m_spare_cv.notify_all(); + } + // EDEN_NX2_SPARSE_LAZY=0 backs sparse regions whole, as before. static bool LazySparseRegions() { static const bool lazy = [] { @@ -1391,6 +1542,15 @@ class INx2GpuSession final : public ServiceFramework { u64 m_gpu_only_tile_max{}; ///< Largest such tile, bounds the search u64 m_gpu_only_tile_bytes{}; ///< GPU-only region memory the game maps right now std::vector m_released_regions; + u64 m_spare_drops{}; ///< GPU writes dropped for want of a spare block, under m_region_mutex + + std::mutex m_spare_mutex; + std::condition_variable_any m_spare_cv; + std::vector m_spare_blocks; + bool m_spare_failed{}; ///< The last refill found no memory + bool m_spare_starved{}; ///< A take timed out: no waiting until the refill thread adds a block + bool m_spare_freed{}; ///< Regions were freed since the last failed refill + std::jthread m_spare_thread; ///< Stopped by the destructor, before what it uses goes struct GpuMemory { u64 address; From 6d12d6a111a08646778a005c356384982979cfb0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:23:24 -0500 Subject: [PATCH 090/154] vulkan: EDEN_NX2_CUBE_VIEWS=0 turns off the cube views of 2D arrays Logs the first 16 2D arrays given cube views, to see which titles sample them (Mario Kart World's lighting washed out on this branch). Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../renderer_vulkan/vk_texture_cache.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 246623e171..c7db238484 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -4163,10 +4163,21 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI // The sampling instruction, not the header, picks the dimension: native shaders sample // six layers of a 2D array as a cube (Fortune's Weave keeps point-light cubes in a // layered atlas; without a cube handle they sampled the null view). - // Switch 2 programs only, whose native shaders do this. - if (Settings::CurrentProgramIsSwitch2() && + // Switch 2 programs only, whose native shaders do this. EDEN_NX2_CUBE_VIEWS=0 turns it off. + static const bool cube_views = [] { + const char* const value = std::getenv("EDEN_NX2_CUBE_VIEWS"); + return value == nullptr || value[0] != '0'; + }(); + if (cube_views && Settings::CurrentProgramIsSwitch2() && info.type == VideoCommon::ImageViewType::e2DArray && info.range.extent.layers >= 6 && image.info.num_samples == 1 && IsCubeCompatible(*device, image.info)) { + static std::atomic logged{0}; + if (logged.fetch_add(1, std::memory_order_relaxed) < 16) { + LOG_INFO(Render_Vulkan, "Cube views for 2D array {:#x}: {}x{} format {} layers {}+{}", + image.gpu_addr, image.info.size.width, image.info.size.height, + static_cast(image.info.format), info.range.base.layer, + info.range.extent.layers); + } create(TextureType::ColorCube, 6); if (info.range.extent.layers % 6 == 0) { create(TextureType::ColorArrayCube, std::nullopt); From 504f9ce372de1bfd225477b232959124720de526 Mon Sep 17 00:00:00 2001 From: Franseven Date: Sat, 31 Oct 2026 10:03:57 +0100 Subject: [PATCH 091/154] video_core: release held writes before each command list, byte for byte Held-back guest CPU writes to GPU-written memory are now applied on the GPU thread before a submitted list runs (InvalidateGPUCache), and as OnCachedWrite they copy just the changed bytes through CachedWriteMemory rather than marking whole pages CPU-modified, which re-uploaded them over GPU data that was never copied back. Ported from Eden-NS2 15b48a6615. On nx2: the held-write part of 62a63ee14e only. Its copy-back of the pages the CPU reads is nx2's read traps (41a81ab5e3), kept as they are, and its pending semaphore releases and NPad guard are left out. For Switch 2 programs only, and EDEN_NX2_HELD_WRITES=0 turns it off (logged when a game loads). Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 62a63ee14ea4062e8a399d767438a628649b5582) --- src/core/hle/service/nvdrv/nvdrv.cpp | 2 ++ src/video_core/gpu.cpp | 7 ++++++- src/video_core/gpu_thread.cpp | 14 ++++++++++++++ src/video_core/nx2.h | 16 ++++++++++++++++ src/video_core/rasterizer_interface.h | 6 ++++++ .../renderer_vulkan/vk_rasterizer.cpp | 17 +++++++++++++++++ src/video_core/renderer_vulkan/vk_rasterizer.h | 1 + 7 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/core/hle/service/nvdrv/nvdrv.cpp b/src/core/hle/service/nvdrv/nvdrv.cpp index 1bc493ae56..aaccde0076 100644 --- a/src/core/hle/service/nvdrv/nvdrv.cpp +++ b/src/core/hle/service/nvdrv/nvdrv.cpp @@ -138,6 +138,8 @@ Module::Module(Core::System& system) Devices::g_nx2_driver = false; Tegra::g_nx2_ampere = false; Core::g_nx2_present_count = 0; + LOG_INFO(Service_NVDRV, "Switch 2 held CPU writes before command lists: {} (EDEN_NX2_HELD_WRITES)", + Tegra::Nx2HeldWritesEnabled() ? "on" : "off"); nx2_stub_builder = [this, &system](DeviceFD fd, const std::string& name) { auto device = std::make_shared(system, name); open_files.emplace(fd, device); diff --git a/src/video_core/gpu.cpp b/src/video_core/gpu.cpp index 29d9742ea7..d6e82a1d95 100644 --- a/src/video_core/gpu.cpp +++ b/src/video_core/gpu.cpp @@ -35,6 +35,7 @@ #include "video_core/host1x/host1x.h" #include "video_core/host1x/syncpoint_manager.h" #include "video_core/memory_manager.h" +#include "video_core/nx2.h" #include "video_core/renderer_base.h" #include "video_core/shader_notify.h" @@ -114,7 +115,11 @@ struct GPU::Impl { /// Synchronizes CPU writes with Host GPU memory. void InvalidateGPUCache() { std::function callback_writes([this](PAddr address, size_t size) { - renderer->ReadRasterizer()->OnCacheInvalidation(address, size); + if (Tegra::Nx2HeldWrites()) { + renderer->ReadRasterizer()->OnCachedWrite(address, size); + } else { + renderer->ReadRasterizer()->OnCacheInvalidation(address, size); + } }); system.GatherGPUDirtyMemory(callback_writes); } diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp index 3af8cb65a8..2ef72e86fd 100644 --- a/src/video_core/gpu_thread.cpp +++ b/src/video_core/gpu_thread.cpp @@ -18,6 +18,7 @@ #include "video_core/gpu.h" #include "video_core/gpu_thread.h" #include "video_core/gpu_split.h" +#include "video_core/nx2.h" #include "video_core/nx2_profile.h" #include "video_core/host1x/host1x.h" #include "video_core/renderer_base.h" @@ -95,6 +96,10 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten auto* const rasterizer_ptr = renderer.ReadRasterizer(); if (auto* submit_list = std::get_if(&next.data)) { Profile::Scope scope{Profile::Zone::Push}; + // Held-back guest CPU writes first (see the single-thread path below). + if (Tegra::Nx2HeldWrites()) { + split.Defer([gpu = &system.GPU()] { gpu->InvalidateGPUCache(); }); + } scheduler.Push(system.GPU(), submit_list->channel, std::move(submit_list->entries)); } else if (std::holds_alternative(next.data)) { split.Defer([gpu = &system.GPU()] { gpu->TickWork(); }); @@ -124,6 +129,15 @@ void ThreadManager::StartThread(VideoCore::RendererBase& renderer, Core::Fronten } if (auto* submit_list = std::get_if(&next.data)) { Profile::Scope scope{Profile::Zone::Push}; + // Guest CPU writes to GPU-written memory are held back until the GPU thread + // applies them, by default at the next fence. The guest made them before it + // submitted this list, which may read them: apply them first. Pokemon Pokopia + // keeps constants in pages its native storage bindings mark GPU-written, and some + // frames drew stale ones (flickering shadows, stretched geometry). Switch 2 + // programs only; EDEN_NX2_HELD_WRITES=0 turns it off. + if (Tegra::Nx2HeldWrites()) { + system.GPU().InvalidateGPUCache(); + } scheduler.Push(system.GPU(), submit_list->channel, std::move(submit_list->entries)); } else if (std::holds_alternative(next.data)) { Profile::Scope scope{Profile::Zone::Tick}; diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index 87a6ae1436..aa7e208651 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,21 @@ inline std::atomic g_nx2_ampere{false}; // The running title, for the few places that must behave differently per game. inline std::atomic g_nx2_program_id{0}; +// EDEN_NX2_HELD_WRITES=0 turns off, for Switch 2 programs: applying the guest CPU writes held +// back from GPU-written memory before each submitted command list, and applying them byte for +// byte (OnCachedWrite) instead of re-uploading whole pages over GPU data never copied back. +inline bool Nx2HeldWritesEnabled() { + static const bool enabled = [] { + const char* value = std::getenv("EDEN_NX2_HELD_WRITES"); + return value == nullptr || value[0] != '0'; + }(); + return enabled; +} + +inline bool Nx2HeldWrites() { + return g_nx2_ampere.load(std::memory_order_relaxed) && Nx2HeldWritesEnabled(); +} + // Titles whose guest side reads GPU-written buffer data through a path the buffer cache does not // see, so their buffers must be copied back to guest memory at every fence // (BufferCache::AsyncBufferDownloads, EDEN_NX2_BUFFER_DOWNLOADS). 0400A3A01EE4C000 is Pokemon diff --git a/src/video_core/rasterizer_interface.h b/src/video_core/rasterizer_interface.h index 133a4448b4..9899535461 100644 --- a/src/video_core/rasterizer_interface.h +++ b/src/video_core/rasterizer_interface.h @@ -133,6 +133,12 @@ class RasterizerInterface { /// Notify rasterizer that any caches of the specified region are desync with guest virtual void OnCacheInvalidation(PAddr addr, u64 size) = 0; + /// A guest CPU write to GPU-written memory, held back until now (InvalidateGPUCache, on the + /// GPU thread): only those bytes changed. + virtual void OnCachedWrite(DAddr addr, u64 size) { + OnCacheInvalidation(addr, size); + } + virtual bool TryCacheInvalidation(DAddr addr, u64 size) { if (MustFlushRegion(addr, size, VideoCommon::CacheType::BufferCache)) return false; OnCacheInvalidation(addr, size); diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 5b9c6bd7dd..d6a30e67ef 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1275,6 +1275,23 @@ void RasterizerVulkan::OnCacheInvalidation(DAddr addr, u64 size) { pipeline_cache.InvalidateRegion(addr, size); } +void RasterizerVulkan::OnCachedWrite(DAddr addr, u64 size) { + if (addr == 0 || size == 0) { + return; + } + { + std::scoped_lock lock{texture_cache.mutex}; + texture_cache.WriteMemory(addr, size); + } + { + // Copies just these bytes in: marking the pages CPU-modified instead re-uploads them + // whole from guest memory, over what the GPU wrote there and never copied back. + std::scoped_lock lock{buffer_cache.mutex}; + buffer_cache.CachedWriteMemory(addr, size); + } + pipeline_cache.InvalidateRegion(addr, size); +} + bool RasterizerVulkan::TryCacheInvalidation(DAddr addr, u64 size) { if (addr == 0 || size == 0) return true; { diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.h b/src/video_core/renderer_vulkan/vk_rasterizer.h index 1e550a27ca..e2ba8748b7 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.h +++ b/src/video_core/renderer_vulkan/vk_rasterizer.h @@ -108,6 +108,7 @@ class RasterizerVulkan final : public VideoCore::RasterizerInterface, VideoCommon::CacheType which = VideoCommon::CacheType::All) override; void InnerInvalidation(std::span> sequences) override; void OnCacheInvalidation(DAddr addr, u64 size) override; + void OnCachedWrite(DAddr addr, u64 size) override; bool TryCacheInvalidation(DAddr addr, u64 size) override; bool OnCPUWrite(DAddr addr, u64 size) override; void InvalidateGPUCache() override; From f5163e8292b0ec98c805234eec864466f1148d1c Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:10 -0500 Subject: [PATCH 092/154] vk_staging_buffer_pool: recheck region 0 after a wrap that fell back When the stream buffer wraps, GetStreamBuffer checks regions 0 to Region(size) and, if the GPU still uses them, hands out a staging buffer instead. It left iterator 0 and free_iterator `size` behind, though: the next request of that size or smaller checked the empty range [Region(size) + 1, Region(size) + 1) and got offset 0 while the GPU was still reading region 0, so the upload overwrote data in use (vertex, index and constant data drawn wrong for that frame). The fallback now sets free_iterator to 0, and a free_iterator of 0 makes the next request check from region 0 itself. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp index aafebb4b18..261df65afe 100644 --- a/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp +++ b/src/video_core/renderer_vulkan/vk_staging_buffer_pool.cpp @@ -142,7 +142,8 @@ void StagingBufferPool::TickFrame() { } StagingBufferRef StagingBufferPool::GetStreamBuffer(size_t size) { - if (AreRegionsActive(Region(free_iterator) + 1, + // free_iterator 0: nothing is known free yet, region 0 included (see the wrap below). + if (AreRegionsActive(free_iterator == 0 ? 0 : Region(free_iterator) + 1, (std::min)(Region(iterator + size) + 1, NUM_SYNCS))) { // Avoid waiting for the previous usages to be free return GetStagingBuffer(size, MemoryUsage::Upload); @@ -161,7 +162,10 @@ StagingBufferRef StagingBufferPool::GetStreamBuffer(size_t size) { free_iterator = size; if (AreRegionsActive(0, Region(size) + 1)) { - // Avoid waiting for the previous usages to be free + // Avoid waiting for the previous usages to be free. Region 0 is still in use: with + // free_iterator at `size`, a next request this size or smaller skipped the check and + // got offset 0 while the GPU still read it. The next request checks region 0 again. + free_iterator = 0; return GetStagingBuffer(size, MemoryUsage::Upload); } } From 536a75ebc01d6e5c25e14b0157c269cc9e9187ec Mon Sep 17 00:00:00 2001 From: failaip12 <86018517+failaip12@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:01:30 +0100 Subject: [PATCH 093/154] nx2: survive corrupted GPU commands Mario Kart World crashed about every 45 minutes. 11 of the 14 recorded crash stacks were in code fed by garbage GPU commands. Ignore invalid syncpoint ids, GPU addresses that translate past the device space, walks past the GPU page tables and impossible render targets instead of crashing on them, and retry transient swapchain errors (a Qt run aborted once on VK_INCOMPLETE). With each crash's values injected, upstream crashed, aborted or froze in all six cases and this kept running at 60 fps. The garbage itself still happens. See NX2_NOTES.md rows 153-154. Assisted-by: Claude Opus 5.5 (debugging, implementation, commit message draft) Signed-off-by: failaip12 <86018517+failaip12@users.noreply.github.com> (cherry picked from commit 0f89b1cd0ccd1f5e4bcff93e759e7e618956ffb9) (cherry picked from commit 5242da2772da8597a6423bd162ba20a5b24cd330) --- src/video_core/memory_manager.cpp | 28 ++++++++++++++++--- .../renderer_vulkan/vk_present_manager.cpp | 7 ++++- src/video_core/texture_cache/texture_cache.h | 10 +++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/video_core/memory_manager.cpp b/src/video_core/memory_manager.cpp index 6b8e5b1cf2..c15aa0d27d 100644 --- a/src/video_core/memory_manager.cpp +++ b/src/video_core/memory_manager.cpp @@ -251,14 +251,23 @@ std::optional MemoryManager::GpuToCpuAddress(GPUVAddr gpu_addr) const { if (!IsWithinGPUAddressRange(gpu_addr)) [[unlikely]] { return std::nullopt; } + DAddr dev_addr; if (GetEntry(gpu_addr, true) != EntryType::Mapped) [[unlikely]] { if (GetEntry(gpu_addr, false) != EntryType::Mapped) return std::nullopt; - const DAddr dev_addr_base = DAddr(page_table[PageEntryIndex(gpu_addr, false)]) << cpu_page_bits; - return dev_addr_base + (gpu_addr & page_mask); + dev_addr = (DAddr(page_table[PageEntryIndex(gpu_addr, false)]) << cpu_page_bits) + + (gpu_addr & page_mask); + } else { + dev_addr = (DAddr(big_page_table_dev[PageEntryIndex(gpu_addr, true)]) << cpu_page_bits) + + (gpu_addr & big_page_mask); + } + // The caches index tables by device address, sized for this address space. + if ((dev_addr >> MaxwellDeviceMemoryManager::AS_BITS) != 0) { + LOG_ERROR(HW_GPU, "GPU address {:#x} maps outside the device space, to {:#x}", gpu_addr, + dev_addr); + return std::nullopt; } - const DAddr dev_addr_base = DAddr(big_page_table_dev[PageEntryIndex(gpu_addr, true)]) << cpu_page_bits; - return dev_addr_base + (gpu_addr & big_page_mask); + return dev_addr; } std::optional MemoryManager::GpuToCpuAddress(GPUVAddr addr, std::size_t size) const { @@ -350,6 +359,10 @@ const u8* MemoryManager::GetPointer(GPUVAddr gpu_addr) const { template inline void MemoryManager::MemoryOperation(GPUVAddr gpu_src_addr, std::size_t size, bool is_big_page, FuncMapped&& func_mapped, FuncReserved&& func_reserved, FuncUnmapped&& func_unmapped) const { + // Garbage commands can name any address, and walking past the page tables would crash. + if (!IsWithinGPUAddressRange(gpu_src_addr) || size > address_space_size - gpu_src_addr) { + return; + } using FuncMappedReturn = typename std::invoke_result::type; using FuncReservedReturn = @@ -415,6 +428,10 @@ inline void MemoryManager::MemoryOperation(GPUVAddr gpu_src_addr, std::size_t si } void MemoryManager::ReadBlockImpl(GPUVAddr gpu_src_addr, void* dest_buffer, std::size_t size, [[maybe_unused]] VideoCommon::CacheType which, bool unsafe) const { + if (!IsWithinGPUAddressRange(gpu_src_addr) || size > address_space_size - gpu_src_addr) { + std::memset(dest_buffer, 0, size); + return; + } auto set_to_zero = [&]([[maybe_unused]] std::size_t page_index, [[maybe_unused]] std::size_t offset, std::size_t copy_amount) { std::memset(dest_buffer, 0, copy_amount); dest_buffer = static_cast(dest_buffer) + copy_amount; @@ -677,6 +694,9 @@ bool MemoryManager::IsGranularRange(GPUVAddr gpu_addr, std::size_t size) const { } bool MemoryManager::IsContinuousRange(GPUVAddr gpu_addr, std::size_t size) const { + if (!IsWithinGPUAddressRange(gpu_addr) || size > address_space_size - gpu_addr) { + return false; // Walking it would run off the page tables + } std::optional old_page_addr{}; bool result{true}; auto fail = [&]([[maybe_unused]] std::size_t page_index, [[maybe_unused]] std::size_t offset, diff --git a/src/video_core/renderer_vulkan/vk_present_manager.cpp b/src/video_core/renderer_vulkan/vk_present_manager.cpp index 36f0ab46ab..acf2079b19 100644 --- a/src/video_core/renderer_vulkan/vk_present_manager.cpp +++ b/src/video_core/renderer_vulkan/vk_present_manager.cpp @@ -424,7 +424,12 @@ void PresentManager::CopyToSwapchain(Frame* frame) { surface_recreation_pending = true; break; } - if (except.GetResult() != VK_ERROR_SURFACE_LOST_KHR) { + // The window can change while the swapchain is recreated: the counts it reads can + // change between calls (VK_INCOMPLETE), and the surface can go out of date or stay in + // use. Only worth another try, not the abort an uncaught exception ends in here. + const VkResult result = except.GetResult(); + if (result != VK_ERROR_SURFACE_LOST_KHR && result != VK_INCOMPLETE && + result != VK_ERROR_OUT_OF_DATE_KHR && result != VK_ERROR_NATIVE_WINDOW_IN_USE_KHR) { throw; } diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 5979e2cf62..c25716b929 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -3034,6 +3034,16 @@ ImageViewId TextureCache

::FindRenderTargetView(const ImageInfo& info, GPUVAdd if (info.size.width == 0 || info.size.height == 0 || info.size.depth == 0) { return NULL_IMAGE_VIEW_ID; } + // Render target registers filled by garbage commands (sizes like 2147487260x2147485323, or + // 0x0 with 36232 layers): creating the image would abort. + static constexpr u32 MaxRenderTargetSize = 32768; + static constexpr s32 MaxRenderTargetLayers = 2048; + if (info.size.width > MaxRenderTargetSize || info.size.height > MaxRenderTargetSize || + info.resources.layers > MaxRenderTargetLayers) { + LOG_ERROR(HW_GPU, "Ignoring {}x{} render target with {} layers at {:#x}", info.size.width, + info.size.height, info.resources.layers, gpu_addr); + return ImageViewId{}; // No render target: the null view has no format to size + } ImageId image_id{}; bool delete_state = has_deleted_images; do { From f0d60326b77ecabf493a51c7464286e9f0958dd5 Mon Sep 17 00:00:00 2001 From: failaip12 <86018517+failaip12@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:25:52 +0100 Subject: [PATCH 094/154] nx2: report rejected ranges as unmapped, recreate a missing swapchain MemoryOperation drops ranges past the address space without calling the unmapped callback, so IsFullyMappedRange reported them as mapped. Check the bounds there as IsContinuousRange does. When swapchain recreation fails on every retry, Create has already destroyed the old swapchain and reset the flags that trigger recreation, so the next frame acquired from a null swapchain. Treat a missing swapchain as needing recreation, and drop the frame if it is still missing. Assisted-by: Claude Opus 5.5 (review fixes, commit message draft) Signed-off-by: failaip12 <86018517+failaip12@users.noreply.github.com> (cherry picked from commit fd22c71df505fdc914860a690bcad43a4a231c38) (cherry picked from commit b625b3da0902ef030c8c6be27ebc9abddeed2a3a) --- src/video_core/memory_manager.cpp | 3 +++ src/video_core/renderer_vulkan/vk_present_manager.cpp | 5 +++++ src/video_core/renderer_vulkan/vk_swapchain.h | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/video_core/memory_manager.cpp b/src/video_core/memory_manager.cpp index c15aa0d27d..c12b7f9ec1 100644 --- a/src/video_core/memory_manager.cpp +++ b/src/video_core/memory_manager.cpp @@ -735,6 +735,9 @@ bool MemoryManager::IsContinuousRange(GPUVAddr gpu_addr, std::size_t size) const } bool MemoryManager::IsFullyMappedRange(GPUVAddr gpu_addr, std::size_t size) const { + if (!IsWithinGPUAddressRange(gpu_addr) || size > address_space_size - gpu_addr) { + return false; // MemoryOperation skips the range without calling the unmapped callback + } bool result{true}; auto fail = [&]([[maybe_unused]] std::size_t page_index, [[maybe_unused]] std::size_t offset, [[maybe_unused]] std::size_t copy_amount) { diff --git a/src/video_core/renderer_vulkan/vk_present_manager.cpp b/src/video_core/renderer_vulkan/vk_present_manager.cpp index acf2079b19..6d7ab2ffe5 100644 --- a/src/video_core/renderer_vulkan/vk_present_manager.cpp +++ b/src/video_core/renderer_vulkan/vk_present_manager.cpp @@ -450,6 +450,11 @@ void PresentManager::CopyToSwapchainImpl(Frame* frame) { if (is_suboptimal || size_changed) { RecreateSwapchain(frame); } + if (!swapchain.IsCreated()) { + // Recreation skips a minimized window, so an earlier failed one has nothing to replace it. + DiscardFrame(frame); + return; + } while (swapchain.AcquireNextImage(!use_present_thread)) { RecreateSwapchain(frame); diff --git a/src/video_core/renderer_vulkan/vk_swapchain.h b/src/video_core/renderer_vulkan/vk_swapchain.h index 415160046c..51f64e034f 100644 --- a/src/video_core/renderer_vulkan/vk_swapchain.h +++ b/src/video_core/renderer_vulkan/vk_swapchain.h @@ -45,7 +45,12 @@ class Swapchain { /// Returns true when the swapchain needs to be recreated. bool NeedsRecreation() const { - return IsSubOptimal() || NeedsPresentModeUpdate(); + return !swapchain || IsSubOptimal() || NeedsPresentModeUpdate(); + } + + /// Returns false when a failed recreation left no swapchain. + bool IsCreated() const { + return static_cast(swapchain); } /// Returns true when the swapchain is outdated. From d038b322ca69bef38786291728936311937aac57 Mon Sep 17 00:00:00 2001 From: nowhere02 <140357165+nowhere02@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:11:04 -0300 Subject: [PATCH 095/154] Avoid overallocating small descriptor tables On nx2: merged with the flat-array DescriptorTable (#256): small tables (limit under 0x80000) round up to a power of two, larger ones keep nx2's 0x80000-entry steps, and nx2's own descriptor table tests are kept instead of this commit's. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit cac42ff5158adf9172ba4fb04517a16331672884) (cherry picked from commit afb944c7b6fa0d66db09e6015a536a21ba38e23a) --- src/video_core/texture_cache/descriptor_table.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/video_core/texture_cache/descriptor_table.h b/src/video_core/texture_cache/descriptor_table.h index c34083cf20..0ed95e329f 100644 --- a/src/video_core/texture_cache/descriptor_table.h +++ b/src/video_core/texture_cache/descriptor_table.h @@ -8,6 +8,7 @@ #include #include +#include #include #include "common/alignment.h" @@ -70,9 +71,12 @@ class DescriptorTable { void Refresh(GPUVAddr gpu_addr, u32 limit) noexcept { current_gpu_addr = gpu_addr; current_limit = limit; - // Mario Brothership reallocates a lot of times, so use aggressive pre-alloc sizes - // std::vector by default uses quadratic growth, but that isn't even enough to satisfy brothership - const size_t num_descriptors = ((limit + 0x80000) & (~0x7ffff)) + 1; + // Small tables round up geometrically: the 0x80000-entry steps below allocated 16 MiB + // for every small 32-byte descriptor table. Large ones keep those steps (Mario + // Brothership reallocates a lot of times; std::vector's growth isn't enough for it). + const size_t num_descriptors = limit < 0x80000 + ? std::bit_ceil(static_cast(limit) + 1) + : ((limit + 0x80000) & (~0x7ffff)) + 1; read_descriptors.resize(Common::DivCeil(num_descriptors, 64U)); ClearReadDescriptors(); invalidated = false; From fd286f9926a53f6032f5a9db7d2976a4d0e98665 Mon Sep 17 00:00:00 2001 From: CloudP4bbs Date: Tue, 3 Nov 2026 16:32:07 -0400 Subject: [PATCH 096/154] shader_recompiler/spirv: emit a Phi for every IR type, not only U1 and U32 TypeId only knew U1 and U32, so any phi that carried a float or a vector threw 'Phi node type F32x4 is not implemented' and the pipeline was dropped. The SPIR-V context already holds the scalar and vector ids for all of them, so the mapping is mechanical. Donkey Kong Bananza hits this on four world shaders right after the LDC sizes were widened, which is what made the field visible. (cherry picked from commit 47ad28fe373e4a6131fd3dd6bbbabcd948a07d1b) --- .../backend/spirv/emit_spirv.cpp | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/shader_recompiler/backend/spirv/emit_spirv.cpp b/src/shader_recompiler/backend/spirv/emit_spirv.cpp index 633ab25935..0730e55aeb 100644 --- a/src/shader_recompiler/backend/spirv/emit_spirv.cpp +++ b/src/shader_recompiler/backend/spirv/emit_spirv.cpp @@ -114,8 +114,44 @@ Id TypeId(const EmitContext& ctx, IR::Type type) { switch (type) { case IR::Type::U1: return ctx.U1; + case IR::Type::U8: + return ctx.U8; + case IR::Type::U16: + return ctx.U16; case IR::Type::U32: return ctx.U32[1]; + case IR::Type::U64: + return ctx.U64; + case IR::Type::F16: + return ctx.F16[1]; + case IR::Type::F32: + return ctx.F32[1]; + case IR::Type::F64: + return ctx.F64[1]; + case IR::Type::U32x2: + return ctx.U32[2]; + case IR::Type::U32x3: + return ctx.U32[3]; + case IR::Type::U32x4: + return ctx.U32[4]; + case IR::Type::F16x2: + return ctx.F16[2]; + case IR::Type::F16x3: + return ctx.F16[3]; + case IR::Type::F16x4: + return ctx.F16[4]; + case IR::Type::F32x2: + return ctx.F32[2]; + case IR::Type::F32x3: + return ctx.F32[3]; + case IR::Type::F32x4: + return ctx.F32[4]; + case IR::Type::F64x2: + return ctx.F64[2]; + case IR::Type::F64x3: + return ctx.F64[3]; + case IR::Type::F64x4: + return ctx.F64[4]; default: throw NotImplementedException("Phi node type {}", type); } From 99ee4ca9289620f1823120e7e241258a96d115e8 Mon Sep 17 00:00:00 2001 From: arisqz Date: Fri, 30 Oct 2026 22:58:11 +0100 Subject: [PATCH 097/154] Fixed black textures on AMD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the driver reports a garbage tile size for the RGBA8 terrain arrays, so they’re now backed normally. On nx2: merged into the constructor's current chain (sparse tiles, the phone `backable` path); phones that can back the image keep their own path, which can go past the size other images are capped at. Assisted-by: Claude Opus 5.5 (diagnosis and code) (cherry picked from commit 3d0ab32c3c4114084d6e497ed3840e3375c4ad6d) --- .../renderer_vulkan/vk_texture_cache.cpp | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index c7db238484..18c866b53d 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -2775,8 +2775,23 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu const bool backable = IsMobileGpu(runtime_.device) && HostImageBytes(runtime_.device, host_info, 0) <= SparseLayersCap(runtime_.device); - if (UsesSparseLayers(runtime_.device, info, gpu_addr_) && InitSparseLayers()) { + const bool layered_sparse = UsesSparseLayers(runtime_.device, info, gpu_addr_); + if (layered_sparse && InitSparseLayers()) { flags |= VideoCommon::ImageFlagBits::SparseLayers; + } else if (layered_sparse && !backable && + !IsUnwrittenSparse(runtime_.device, info, gpu_addr_)) { + // The driver's sparse block can't be bound by: back it as before SparseLayers (on a + // phone, the `backable` path below does, past the size other images are capped at). + original_image = MakeImage( + runtime_.device, runtime_.memory_allocator, host_info, + IsHugeAstcArray(runtime_.device, info) ? std::span{HugeAstcViewFormats} + : IsOpaqueBc1Astc(runtime_.device, info) + ? std::span{OpaqueAstcViewFormats} + : runtime->ViewFormats(info.format), + AstcHostFormat(runtime_.device, info)); + if (!original_image.IsUnbacked()) { + flags &= ~VideoCommon::ImageFlagBits::Unbacked; + } } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && InitSparseTiles()) { flags |= VideoCommon::ImageFlagBits::SparseTiles; } else if (texels <= (u64{1} << 28) || @@ -3323,6 +3338,16 @@ bool Image::InitSparseLayers() { s.tile_width *= block_width; s.tile_height *= block_height; } + // A block that is not one sparse page of this image can't be bound by (AMD gives 65549x339860304 + // for the RGBA8 terrain arrays on an RX 9070 XT, which left most of their texels unbound). + if (s.tile_width < block_width || s.tile_height < block_height || + u64{s.tile_width / block_width} * (s.tile_height / block_height) * block_bytes != tile) { + LOG_WARNING(Render_Vulkan, + "Sparse layered image {:#x}: driver block {}x{} is not a {} byte page, backing " + "it whole", + gpu_addr, granularity.width, granularity.height, tile); + return false; + } const u32 tail_lod = (std::min)(it->imageMipTailFirstLod, s.levels); VkDeviceSize bytes = 0; for (u32 level = 0; level < tail_lod; ++level) { From 1bcf1023f96dbef4e4f17248a19a9663cd3586fc Mon Sep 17 00:00:00 2001 From: nowhere02 <140357165+nowhere02@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:09:58 -0300 Subject: [PATCH 098/154] Release gathered buffers when their sources are removed (cherry picked from commit 30583fc85cd3f53caf7f861d80b3d82219f51948) --- src/video_core/renderer_vulkan/vk_buffer_cache.cpp | 1 + .../renderer_vulkan/vk_multi_range_buffer.cpp | 14 ++++++++------ .../renderer_vulkan/vk_multi_range_buffer.h | 2 ++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp index d91745bfc1..177bec5725 100644 --- a/src/video_core/renderer_vulkan/vk_buffer_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_buffer_cache.cpp @@ -437,6 +437,7 @@ u32 BufferCacheRuntime::GetStorageBufferAlignment() const { } void BufferCacheRuntime::TickFrame(Common::SlotVector& slot_buffers) noexcept { + multi_range_buffers.TickFrame(scheduler); for (auto it = slot_buffers.begin(); it != slot_buffers.end(); it++) { if (scheduler.IsFree(it->LastUsageTick())) { it->ResetUsageTracking(); diff --git a/src/video_core/renderer_vulkan/vk_multi_range_buffer.cpp b/src/video_core/renderer_vulkan/vk_multi_range_buffer.cpp index 4d84390002..536ed61477 100644 --- a/src/video_core/renderer_vulkan/vk_multi_range_buffer.cpp +++ b/src/video_core/renderer_vulkan/vk_multi_range_buffer.cpp @@ -206,6 +206,10 @@ void MultiRangeBufferCache::DrainRetired(Scheduler& scheduler) { } } +void MultiRangeBufferCache::TickFrame(Scheduler& scheduler) { + DrainRetired(scheduler); +} + MultiRangeRef MultiRangeBufferCache::Get(const Device& device, Scheduler& scheduler, MemoryAllocator& memory_allocator, u64 key, std::span sources, @@ -248,14 +252,12 @@ MultiRangeRef MultiRangeBufferCache::Get(const Device& device, Scheduler& schedu entry.geometry = geometry; entry.content = content; entry.size = total; + entry.owners.reserve(sources.size()); + for (const MultiRangeSource& source : sources) { + entry.owners.push_back(source.handle); + } if (CanBindSparse(sources)) { entry.sparse_handle = CreateSparse(device, scheduler, sources, total); - if (entry.sparse_handle) { - entry.owners.reserve(sources.size()); - for (const MultiRangeSource& source : sources) { - entry.owners.push_back(source.handle); - } - } } if (!entry.sparse_handle) { VkBufferUsageFlags flags = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | diff --git a/src/video_core/renderer_vulkan/vk_multi_range_buffer.h b/src/video_core/renderer_vulkan/vk_multi_range_buffer.h index f40d3aa1f8..56eabbe75f 100644 --- a/src/video_core/renderer_vulkan/vk_multi_range_buffer.h +++ b/src/video_core/renderer_vulkan/vk_multi_range_buffer.h @@ -59,6 +59,8 @@ class MultiRangeBufferCache final { void DropOwner(Scheduler& scheduler, VkBuffer owner); + void TickFrame(Scheduler& scheduler); + VkDeviceSize block_size{DEFAULT_BLOCK_SIZE}; bool use_sparse{}; From d08f980985ecf38588b0224ceed5e169b456e04a Mon Sep 17 00:00:00 2001 From: Franseven Date: Sat, 31 Oct 2026 20:49:35 +0100 Subject: [PATCH 099/154] shader: pack FP16 texture results with c0 in the low half Ampere hands a TEX.F16 result back as two packed pairs, c0 in the low half of the first destination and c1 in the high half. The lowering merges the two converted halves with ISCADD, which shifts op_a (bits 8..15) up by 16 and adds op_b, so op_a must carry the high half. It was given the low one, leaving H1 = c0 and H0 = c1 in every destination; Splatoon Raiders reads only .H0 for its blue component, which is why its whole scene came out yellow. The same merge elsewhere in the file (the 32-bit multiply, the lane pairing) already puts the high half in bits 8..15. Assisted-by: Claude Opus 5.5 (implementation and debugging) (cherry picked from commit 986289ac5fc430f7dcf2d0792b862745cc9907bc) --- src/shader_recompiler/frontend/ampere/lower_to_maxwell.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h index d9df752556..811f461ddd 100644 --- a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h +++ b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h @@ -2326,7 +2326,9 @@ inline LoweredProgram LowerToMaxwellWith(std::span code, u32 if (target == 255) return; emit(0x5ca8000000000000ULL | predicate | 246 | (u64{1} << 8) | (u64{2} << 10) | (u64{low} << 20)); emit(0x5ca8000000000000ULL | predicate | 247 | (u64{1} << 8) | (u64{2} << 10) | (u64{high} << 20)); - emit(0x5c18000000000000ULL | predicate | target | (u64{246} << 8) | (u64{247} << 20) | (u64{16} << 39)); + // ISCADD shifts op_a (bits 8..15) up, so op_a carries the high half and + // op_b the low one; the other way round leaves H1 = c0 and H0 = c1. + emit(0x5c18000000000000ULL | predicate | target | (u64{247} << 8) | (u64{246} << 20) | (u64{16} << 39)); }; pack(static_cast(dest), 250, components > 1 ? 251U : 255U); if (components > 2) { From 7240af4c318b460f1102ee775772216f4f4ca04a Mon Sep 17 00:00:00 2001 From: Yuri Olive Date: Tue, 3 Nov 2026 21:13:44 -0300 Subject: [PATCH 100/154] vulkan: only attach the required subgroup size when the device supports it for compute Check requiredSubgroupSizeStages for the compute stage as well, so the struct is not chained on a device that exposes subgroup size control but not for this stage. (cherry picked from commit dce00ecf7a98249b8c9fc57f7da2687aa7ee0984) --- src/video_core/renderer_vulkan/vk_compute_pipeline.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 78f2ec3e06..3343140cf4 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -108,7 +108,8 @@ ComputePipeline::ComputePipeline(const Device& device_, Scheduler& scheduler, vk .flags = flags, .stage{ .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, - .pNext = device.IsExtSubgroupSizeControlSupported() && uses_warp_ops + .pNext = device.IsExtSubgroupSizeControlSupported() && uses_warp_ops && + device.IsGuestWarpSizeSupported(VK_SHADER_STAGE_COMPUTE_BIT) ? &subgroup_size_ci : nullptr, .flags = 0, From 26b0f8beebbefd960b8d6cd9fc213c9f3d04c690 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:21:37 -0500 Subject: [PATCH 101/154] settings: the game's own DLSS network off by default on Android Fire Emblem: Fortune's Weave crashed about 13 s in, when its upscale started: compiling the emulated tensor passes of its DLSS network (game_dlss_network, 401dfb9329) took the process from 3.1 to 7.6 GB in 4 s on a 24 GB phone, malloc failed, and Turnip crashed on a pipeline build thread. With FSR 3.1 instead the game loads and plays. game_dlss_network now defaults to off on Android and stays on elsewhere, and Android lists it with the renderer hacks as "Use the game's own DLSS (experimental)". Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../features/settings/model/BooleanSetting.kt | 1 + .../features/settings/model/view/SettingsItem.kt | 7 +++++++ .../settings/ui/SettingsFragmentPresenter.kt | 1 + src/android/app/src/main/res/values/strings.xml | 2 ++ src/common/settings.h | 13 ++++++++++--- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index a21d06b6ed..fffc2eab46 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -28,6 +28,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_ASYNCHRONOUS_SHADERS("use_asynchronous_shaders"), RENDERER_REDUCE_TEXTURE_RESOLUTION("reduce_texture_resolution"), RENDERER_RELEASE_GPU_ONLY_MEMORY("release_gpu_only_memory"), + RENDERER_GAME_DLSS_NETWORK("game_dlss_network"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), ENABLE_BUFFER_HISTORY("enable_buffer_history"), USE_OPTIMIZED_VERTEX_BUFFERS("use_optimized_vertex_buffers"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 30c76c05d8..3b360c8f45 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -792,6 +792,13 @@ abstract class SettingsItem( descriptionId = R.string.renderer_release_gpu_only_memory_description ) ) + put( + SwitchSetting( + BooleanSetting.RENDERER_GAME_DLSS_NETWORK, + titleId = R.string.renderer_game_dlss_network, + descriptionId = R.string.renderer_game_dlss_network_description + ) + ) put( SingleChoiceSetting( IntSetting.FAST_GPU_TIME, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index f3ccc1744b..1740cef53b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -560,6 +560,7 @@ class SettingsFragmentPresenter( add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) add(BooleanSetting.RENDERER_REDUCE_TEXTURE_RESOLUTION.key) add(BooleanSetting.RENDERER_RELEASE_GPU_ONLY_MEMORY.key) + add(BooleanSetting.RENDERER_GAME_DLSS_NETWORK.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_GPU_EMULATION.key) add(SettingsItem.GPU_UNSWIZZLE_COMBINED) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 9f9b4cbec8..beaa7d6dfc 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -612,6 +612,8 @@ Reduce texture resolution Keeps large compressed textures at half resolution, using about a quarter of their video memory. Helps games that run out of memory, such as Switch 2 titles. Textures look slightly blurrier up close. Takes effect when the game starts. Release GPU-only memory (experimental) + Use the game\'s own DLSS (experimental) + Runs the game\'s DLSS network instead of FSR 3.1. Needs a lot of RAM to compile and can crash on phones. Takes effect when the game starts. Frees the RAM copy of Switch 2 GPU-only memory once its textures are on the GPU, saving RAM. Experimental: may cause missing or corrupted textures. GPU Unswizzle Settings Configure GPU-based texture unswizzling parameters or disable it entirely. Adjust these settings to balance performance and texture loading quality. diff --git a/src/common/settings.h b/src/common/settings.h index 6fddf6b56d..b2b94658c4 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -365,9 +365,16 @@ struct Values { true, true}; // A Switch 2 game's own DLSS network (translated) in place of AMD FSR 3.1, where it runs and - // its output passes the checks. - SwitchableSetting game_dlss_network{linkage, true, "game_dlss_network", - Category::Renderer}; + // its output passes the checks. Off by default on Android: compiling its emulated tensor passes + // took Turnip from 3.1 to 7.6 GB in 4 s on a 24 GB phone and crashed it (Fire Emblem: Fortune's + // Weave). + SwitchableSetting game_dlss_network{linkage, +#ifdef __ANDROID__ + false, +#else + true, +#endif + "game_dlss_network", Category::Renderer}; SwitchableSetting fsr_sharpening_slider{linkage, #ifdef __ANDROID__ 0, From 705294ed76474e79ab9793a78dc8c80ccd77b877 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:44:38 -0500 Subject: [PATCH 102/154] nx2:vi2: EDEN_NX2_MKW_PACING, and the Dynamic FPS check finds the game Mario Kart World runs at 60 fps and asks for two vblanks a frame only after a frame ran long. On slower hosts that is nearly every frame, so honouring it holds the game at 30 fps: on an Odin 3, where its frames take 18-25 ms, the pacing slept 12-15 ms of each one. Its interval is still honoured by default, as before; EDEN_NX2_MKW_PACING=1 paces it at one vblank instead (still capped at 60 fps), and =0 doesn't pace it. The Dynamic FPS check looked up the vi2 service thread's process, which is never the game, so its one-vblank pacing never applied. It now looks up the application process. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/core/hle/service/nx2_gsv.h | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index c4d90d4186..74972995f0 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2018,12 +2018,29 @@ class INx2Vi2Session final : public ServiceFramework { const char* value = std::getenv("EDEN_NX2_SWAP_INTERVAL"); return value ? static_cast(std::strtoul(value, nullptr, 10)) : 0U; }(); - const auto& process = Kernel::GetCurrentProcess(kernel); - const bool mkw_dynamic = - process.GetProgramId() == Settings::MarioKartWorldProgramId && - process.IsMkwDynamicFpsPatchApplied() && Settings::values.mkw_dynamic_fps.GetValue(); + // This runs on the vi2 service thread, whose current process is not the game's: look the + // application up instead. + const bool is_mkw = + system.GetApplicationProcessProgramID() == Settings::MarioKartWorldProgramId; + const auto* const app = is_mkw ? system.ApplicationProcess() : nullptr; + const bool mkw_dynamic = app != nullptr && app->IsMkwDynamicFpsPatchApplied() && + Settings::values.mkw_dynamic_fps.GetValue(); + // Mario Kart World runs at 60 fps and asks for two vblanks a frame only when a frame ran + // long, which on slower hosts is nearly every frame: honouring that holds it at 30 fps + // (12-15 ms of sleep a frame on an Odin 3, where frames take 18-25 ms). Its interval is + // honoured by default; EDEN_NX2_MKW_PACING=1 paces it at one vblank, =0 doesn't pace it. + static const int mkw_pacing = [] { + const char* value = std::getenv("EDEN_NX2_MKW_PACING"); + if (value == nullptr) { + return 2; + } + return value[0] == '0' ? 0 : (value[0] == '1' ? 1 : 2); + }(); + if (is_mkw && mkw_pacing == 0 && forced_interval == 0) { + return; + } u32 swap_interval = std::clamp(timing >> 16, 1, 4); - if (mkw_dynamic) { + if (mkw_dynamic || (is_mkw && mkw_pacing == 1)) { swap_interval = 1; } if (forced_interval != 0) { From 0de544413581b155ca57501dadac1078edda8557 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:01:07 -0500 Subject: [PATCH 103/154] loader: Mario Kart World 60 FPS mode, and both modes on Android mkw_60fps forces the game's fixed one-vblank mode at the same site as Franz's 30 FPS mode (ldr w8, [x19, #0x210] becomes mov w8, #1), with no frame-time patch. The game's modes, read from its main loop: 0 is dynamic, from the CPU frame time at [x19, #0x128]; 1 is one vblank; 2 is two, and its tick counter advances by the vblank count, so a host too slow for 60 fps runs the game in slow motion. When several are set, 30 FPS wins over 60 FPS, and 60 FPS over dynamic; the load log names the mode it applied. Qt lists it with the other two. Android gets the 30 and 60 FPS modes with the renderer hacks. The dynamic mode is left off Android: there it broke the ARM64 macro JIT ("instruction outside uploaded RAM"). Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../features/settings/model/BooleanSetting.kt | 2 ++ .../settings/model/view/SettingsItem.kt | 14 ++++++++++++++ .../settings/ui/SettingsFragmentPresenter.kt | 2 ++ .../app/src/main/res/values/strings.xml | 4 ++++ src/common/settings.h | 1 + src/core/loader/nso.cpp | 18 +++++++++++------- src/qt_common/config/shared_translation.cpp | 7 +++++-- 7 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index fffc2eab46..e503566371 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -29,6 +29,8 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_REDUCE_TEXTURE_RESOLUTION("reduce_texture_resolution"), RENDERER_RELEASE_GPU_ONLY_MEMORY("release_gpu_only_memory"), RENDERER_GAME_DLSS_NETWORK("game_dlss_network"), + RENDERER_MKW_30FPS("mkw_30fps"), + RENDERER_MKW_60FPS("mkw_60fps"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), ENABLE_BUFFER_HISTORY("enable_buffer_history"), USE_OPTIMIZED_VERTEX_BUFFERS("use_optimized_vertex_buffers"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 3b360c8f45..18a8912767 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -799,6 +799,20 @@ abstract class SettingsItem( descriptionId = R.string.renderer_game_dlss_network_description ) ) + put( + SwitchSetting( + BooleanSetting.RENDERER_MKW_30FPS, + titleId = R.string.renderer_mkw_30fps, + descriptionId = R.string.renderer_mkw_30fps_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_MKW_60FPS, + titleId = R.string.renderer_mkw_60fps, + descriptionId = R.string.renderer_mkw_60fps_description + ) + ) put( SingleChoiceSetting( IntSetting.FAST_GPU_TIME, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 1740cef53b..c831343c4c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -561,6 +561,8 @@ class SettingsFragmentPresenter( add(BooleanSetting.RENDERER_REDUCE_TEXTURE_RESOLUTION.key) add(BooleanSetting.RENDERER_RELEASE_GPU_ONLY_MEMORY.key) add(BooleanSetting.RENDERER_GAME_DLSS_NETWORK.key) + add(BooleanSetting.RENDERER_MKW_30FPS.key) + add(BooleanSetting.RENDERER_MKW_60FPS.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_GPU_EMULATION.key) add(SettingsItem.GPU_UNSWIZZLE_COMBINED) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index beaa7d6dfc..784f47b1bb 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -614,6 +614,10 @@ Release GPU-only memory (experimental) Use the game\'s own DLSS (experimental) Runs the game\'s DLSS network instead of FSR 3.1. Needs a lot of RAM to compile and can crash on phones. Takes effect when the game starts. + Mario Kart World: 30 FPS mode + Forces the game to use its native 30 FPS mode at normal game speed. Takes effect after restarting the game. + Mario Kart World: 60 FPS mode + Always renders at 60 fps. Slower hardware runs the game in slow motion. Takes effect after restarting the game; the 30 FPS mode takes precedence. Frees the RAM copy of Switch 2 GPU-only memory once its textures are on the GPU, saving RAM. Experimental: may cause missing or corrupted textures. GPU Unswizzle Settings Configure GPU-based texture unswizzling parameters or disable it entirely. Adjust these settings to balance performance and texture loading quality. diff --git a/src/common/settings.h b/src/common/settings.h index b2b94658c4..e7a5580bc5 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -620,6 +620,7 @@ struct Values { // Mario Kart World: select a fixed or dynamic in-game frame-rate mode (loader/nso.cpp). SwitchableSetting mkw_30fps{linkage, false, "mkw_30fps", Category::RendererAdvanced}; + SwitchableSetting mkw_60fps{linkage, false, "mkw_60fps", Category::RendererAdvanced}; SwitchableSetting mkw_dynamic_fps{linkage, false, "mkw_dynamic_fps", Category::RendererAdvanced}; diff --git a/src/core/loader/nso.cpp b/src/core/loader/nso.cpp index 4b9c89d38e..945f54cec6 100644 --- a/src/core/loader/nso.cpp +++ b/src/core/loader/nso.cpp @@ -177,15 +177,19 @@ std::optional AppLoader_NSO::LoadModule(Kernel::KProcess& process, Core:: std::copy(pi_header.begin() + sizeof(NSOHeader), pi_header.end(), patchable_section.data()); } - // Mario Kart World selects a 1- or 2-vblank frame mode in its main loop. Force its native - // 30 FPS mode, or let it choose dynamically using the whole frame time (CPU + GPU wait). - // Match the instruction sequence instead of relying on a title-version-specific address. - const bool mkw_dynamic = Settings::values.mkw_dynamic_fps.GetValue(); + // Mario Kart World selects a 1- or 2-vblank frame mode in its main loop (mode 0: dynamic, from + // the CPU frame time at [x19, #0x128]; 1: one vblank; 2: two vblanks). Force its native 30 FPS + // mode, its 60 FPS mode, or let it choose dynamically using the whole frame time (CPU + GPU + // wait), in that order of precedence. Match the instruction sequence instead of relying on a + // title-version-specific address. + const bool mkw_30 = Settings::values.mkw_30fps.GetValue(); + const bool mkw_60 = !mkw_30 && Settings::values.mkw_60fps.GetValue(); + const bool mkw_dynamic = !mkw_30 && !mkw_60 && Settings::values.mkw_dynamic_fps.GetValue(); bool mkw_dynamic_fps_patch_applied = false; - if ((mkw_dynamic || Settings::values.mkw_30fps.GetValue()) && name == "main" && + if ((mkw_30 || mkw_60 || mkw_dynamic) && name == "main" && process.GetProgramId() == Settings::MarioKartWorldProgramId) { constexpr u32 ModeLoadInsn = 0xB9421268; // ldr w8, [x19, #0x210] - const u32 mode = mkw_dynamic ? 0U : 2U; + const u32 mode = mkw_dynamic ? 0U : (mkw_60 ? 1U : 2U); const u32 mode_instruction = 0x52800008U | (mode << 5); // mov w8, #mode const auto& text = nso_header.segments[0]; u8* const code = codeset.memory.data() + module_start + text.location; @@ -233,7 +237,7 @@ std::optional AppLoader_NSO::LoadModule(Kernel::KProcess& process, Core:: mkw_dynamic_fps_patch_applied = true; } LOG_INFO(Loader, "Mario Kart World: {} FPS mode enabled (main+{:#x})", - mkw_dynamic ? "dynamic" : "30", + mkw_dynamic ? "dynamic" : (mkw_60 ? "60" : "30"), text.location + mode_site * sizeof(u32)); } else { LOG_WARNING(Loader, diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 007c7bf569..79db5de732 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -289,10 +289,13 @@ std::unique_ptr InitializeTranslations(QObject* parent) { INSERT(Settings, mkw_30fps, tr("Mario Kart World: 30 FPS mode"), tr("Forces the game to use its native 30 FPS mode at normal game speed. " "Takes effect after restarting the game.")); + INSERT(Settings, mkw_60fps, tr("Mario Kart World: 60 FPS mode"), + tr("Always renders at 60 fps. Slower hardware runs the game in slow motion. " + "Takes effect after restarting the game; the 30 FPS mode takes precedence.")); INSERT(Settings, mkw_dynamic_fps, tr("Mario Kart World: dynamic FPS (experimental)"), tr("Lets the game adjust between 30 and 60 FPS using whole-frame timing to maintain " - "normal game speed. Takes effect after restarting the game and takes precedence over " - "the 30 FPS mode.")); + "normal game speed. Takes effect after restarting the game; the 30 and 60 FPS modes " + "take precedence.")); INSERT(Settings, barrier_feedback_loops, tr("Barrier feedback loops"), tr("Improves rendering of transparency effects in specific games.")); INSERT(Settings, enable_buffer_history, tr("Enable buffer history"), From 3785e62dacfb30dea77474460827c6f62750f1aa Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:15:15 -0500 Subject: [PATCH 104/154] android: EDEN_ANDROID_FRAME_RATE_HINT overrides the surface frame-rate hint The hint follows the rate frames reach the screen, and it feeds back on itself: once a game ran its menus at 30 fps, a hint of 30 has the display schedule Eden at 30, so nothing above 30 is observed again. Mario Kart World then stayed at exactly 30.0 in races its GPU ran at 40-45 fps. EDEN_ANDROID_FRAME_RATE_HINT=0 sends no preference, and = a fixed one. It is read on every update, because the surface exists before a game's env.txt is applied. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../app/src/main/jni/emu_window/emu_window.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp index 8dfea40693..1890f5d2f8 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window.cpp +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "common/android/id_cache.h" @@ -180,8 +181,16 @@ void EmuWindow_Android::UpdateFrameRateHint() { return; } + // EDEN_ANDROID_FRAME_RATE_HINT=0 sends no preference, = a fixed one. The observed rate + // feeds back: once a game ran its menus at 30 fps, a hint of 30 has the display schedule us + // at 30, so nothing above 30 is ever observed again (Mario Kart World stayed at exactly 30.0 + // in races its GPU ran at 40-45 fps). + // Read each time: the surface exists before a game's env.txt is applied. + const char* const forced_value = std::getenv("EDEN_ANDROID_FRAME_RATE_HINT"); + const float forced_hint = forced_value == nullptr ? -1.0f : std::strtof(forced_value, nullptr); const auto now = Clock::now(); - const float frame_rate_hint = GetFrameRateHint(); + const float frame_rate_hint = + forced_hint >= 0.0f ? QuantizeFrameRateHint(forced_hint) : GetFrameRateHint(); if (std::fabs(frame_rate_hint - m_last_frame_rate_hint) < 0.01f) { m_pending_frame_rate_hint = frame_rate_hint; m_pending_frame_rate_hint_votes = 0; From 282e7ffacddc0b324433fca705afda10a1ce42ad Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:19:23 -0500 Subject: [PATCH 105/154] nx2:vi2: don't pace the presents of games that wait on vsync A game that binds a native event to vsync (261) waits on it between frames, so it already runs on Eden's vblank timer. Pacing its presents as well, to a 60 Hz clock of another phase, held each one until it had missed the timer's next vblank. Mario Kart World, which binds one, ran at 27-30 fps with 4-14 ms of pacing sleep a frame, while Eden's GPU threads were idle half the time and the present's fence wait was 3-4 ms. Fire Emblem: Fortune's Weave binds no vsync event and keeps its pacing, without which it runs at double speed. EDEN_NX2_PACE_VSYNC_GAMES=1 paces vsync games anyway. Assisted-by: Claude Opus 5.5 (profiling, implementation and debugging) --- src/core/hle/service/nx2_gsv.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 74972995f0..63a0963235 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2006,6 +2006,10 @@ class INx2Vi2Session final : public ServiceFramework { // next vblank instead (title 4 ran at up to 160 fps, its timing and audio with it). std::chrono::steady_clock::time_point m_next_vblank{}; u32 m_swap_word{}; + // The game bound a native event to vsync (261) and waits on it between frames: it paces + // itself, so PaceToVblank leaves its presents alone. + bool m_game_waits_for_vsync{}; + bool m_logged_vsync_pacing{}; // timing: present word 12, (swap interval << 16) | refresh rate. Fire Emblem: Fortune's Weave // sends 0x1003c during its boot logos and 0x2003c (two vblanks per frame, 30 fps) after; paced @@ -2039,6 +2043,23 @@ class INx2Vi2Session final : public ServiceFramework { if (is_mkw && mkw_pacing == 0 && forced_interval == 0) { return; } + // A game that waits on the vsync event between frames already runs on the vblank timer. + // Pacing its presents too, to a clock of another phase, made it miss that timer's next + // vblank: Mario Kart World, which binds one, ran at 27-30 fps with 4-14 ms of sleep a + // frame while Eden's GPU threads were idle half the time. Fortune's Weave binds none + // and keeps its pacing (at every vblank it ran at double speed). + // EDEN_NX2_PACE_VSYNC_GAMES=1 paces them anyway. + static const bool pace_vsync_games = [] { + const char* value = std::getenv("EDEN_NX2_PACE_VSYNC_GAMES"); + return value != nullptr && value[0] == '1'; + }(); + if (m_game_waits_for_vsync && !pace_vsync_games && forced_interval == 0 && + !(is_mkw && mkw_pacing == 1)) { + if (!std::exchange(m_logged_vsync_pacing, true)) { + LOG_INFO(Service, "nx2:vi2 presents not paced: the game waits on the vsync event"); + } + return; + } u32 swap_interval = std::clamp(timing >> 16, 1, 4); if (mkw_dynamic || (is_mkw && mkw_pacing == 1)) { swap_interval = 1; @@ -2434,6 +2455,7 @@ class INx2Vi2Session final : public ServiceFramework { LOG_INFO(Service, "nx2:vi2 261 native event {} -> vsync", index); if (index < m_native_events.size() && m_native_events[index]) { SetVsyncSlot(index, true); + m_game_waits_for_vsync = true; } TipcReplyWords(ctx, {}); } From 8e01750c7c8f1434d04ad222d2b9419781d67fd2 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:23:53 -0500 Subject: [PATCH 106/154] nx2:vi2: EDEN_NX2_FRAMES_AHEAD=2 never waits for the presented frame =1 runs a frame ahead only while the GPU keeps up. On a phone it rarely does, so presents still waited for their own frame: Mario Kart World's present blocked 5-13 ms on its fence, its frame timer saw frames over 16.7 ms, and the game stayed in its 30 fps mode. =2 waits only for the previous frame, as presents did before 0f14696044, so the CPU prepares a frame while the GPU runs the last one. Assisted-by: Claude Opus 5.5 (profiling, implementation and debugging) --- src/core/hle/service/nx2_gsv.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 63a0963235..94c3152a7c 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1903,10 +1903,11 @@ class INx2Vi2Session final : public ServiceFramework { // 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. + // EDEN_NX2_FRAMES_AHEAD=1 always waits for the previous frame, =0 always for this one, + // =2 only ever for the previous one, even with the GPU behind. static const int frames_ahead_setting = [] { const char* value = std::getenv("EDEN_NX2_FRAMES_AHEAD"); - return value == nullptr ? -1 : value[0] == '1' ? 1 : 0; + return value == nullptr ? -1 : value[0] == '2' ? 2 : value[0] == '1' ? 1 : 0; }(); // Donkey Kong Bananza's channels wait on each other every frame, so it always ran a frame // ahead, where it rewrote memory the GPU thread hadn't read yet (misread commands, then @@ -1918,7 +1919,10 @@ class INx2Vi2Session final : public ServiceFramework { frames_ahead = 0; } const auto entered = std::chrono::steady_clock::now(); - if (frames_ahead == 1 || (frames_ahead == -1 && !WaitForFrame(~w[22], w[24], true))) { + if (frames_ahead == 2) { + WaitForFrame(m_previous_fence_id, m_previous_fence_value); + } else 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); From 8ed2b943fa20130f60d5b85032196cf6d50a98d0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:34:51 -0500 Subject: [PATCH 107/154] nx2: nx2_command_lists, off by default on Android Upstream's command list series (the submit-time copy of a list's words, and the copies and flushes of lists on GPU-written pages) becomes a setting, nx2_command_lists ("Switch 2 command list fixes"), read once when a game starts and logged. It stays on by default on desktop and is off by default on Android: on an Odin 3, Mario Kart World's front waited 47 ms a second on it ("pending sleep"; 2 ms without), ran at 31.8 fps against 33.1 without it, and flickered. Off, IsGpuWrittenCommands and ReadGpuWrittenCommands report no GPU-written list and the snapshot at submit is skipped, so lists are read from guest memory when they run. EDEN_NX2_COMMAND_LISTS=0/1 overrides the setting. Android lists it in Advanced graphics with the renderer hacks, and Qt with the graphics hacks. Based on 3d4116411b (nx2-mkw), which made it a setting off by default everywhere. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../features/settings/model/BooleanSetting.kt | 1 + .../features/settings/model/view/SettingsItem.kt | 7 +++++++ .../settings/ui/SettingsFragmentPresenter.kt | 1 + src/android/app/src/main/res/values/strings.xml | 2 ++ src/common/settings.h | 10 ++++++++++ src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp | 6 ++++-- src/core/hle/service/nvdrv/nvdrv.cpp | 15 +++++++++++++++ src/qt_common/config/shared_translation.cpp | 4 ++++ src/video_core/dma_pusher.cpp | 7 ++++--- src/video_core/nx2.h | 11 +++++++++++ 10 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index e503566371..1cb9a0a790 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -29,6 +29,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_REDUCE_TEXTURE_RESOLUTION("reduce_texture_resolution"), RENDERER_RELEASE_GPU_ONLY_MEMORY("release_gpu_only_memory"), RENDERER_GAME_DLSS_NETWORK("game_dlss_network"), + RENDERER_NX2_COMMAND_LISTS("nx2_command_lists"), RENDERER_MKW_30FPS("mkw_30fps"), RENDERER_MKW_60FPS("mkw_60fps"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 18a8912767..7c675aec36 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -799,6 +799,13 @@ abstract class SettingsItem( descriptionId = R.string.renderer_game_dlss_network_description ) ) + put( + SwitchSetting( + BooleanSetting.RENDERER_NX2_COMMAND_LISTS, + titleId = R.string.renderer_nx2_command_lists, + descriptionId = R.string.renderer_nx2_command_lists_description + ) + ) put( SwitchSetting( BooleanSetting.RENDERER_MKW_30FPS, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index c831343c4c..3e6fbee302 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -561,6 +561,7 @@ class SettingsFragmentPresenter( add(BooleanSetting.RENDERER_REDUCE_TEXTURE_RESOLUTION.key) add(BooleanSetting.RENDERER_RELEASE_GPU_ONLY_MEMORY.key) add(BooleanSetting.RENDERER_GAME_DLSS_NETWORK.key) + add(BooleanSetting.RENDERER_NX2_COMMAND_LISTS.key) add(BooleanSetting.RENDERER_MKW_30FPS.key) add(BooleanSetting.RENDERER_MKW_60FPS.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 784f47b1bb..f8712835e6 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -614,6 +614,8 @@ Release GPU-only memory (experimental) Use the game\'s own DLSS (experimental) Runs the game\'s DLSS network instead of FSR 3.1. Needs a lot of RAM to compile and can crash on phones. Takes effect when the game starts. + Switch 2 command list fixes + Upstream\'s fixes for Switch 2 command lists the game rewrites after submitting. Off on phones: on an Odin 3 they cost ~45 ms of GPU-thread waiting per second in Mario Kart World and caused flicker. Takes effect when a game starts. Mario Kart World: 30 FPS mode Forces the game to use its native 30 FPS mode at normal game speed. Takes effect after restarting the game. Mario Kart World: 60 FPS mode diff --git a/src/common/settings.h b/src/common/settings.h index e7a5580bc5..28e1c2c987 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -368,6 +368,16 @@ struct Values { // its output passes the checks. Off by default on Android: compiling its emulated tensor passes // took Turnip from 3.1 to 7.6 GB in 4 s on a 24 GB phone and crashed it (Fire Emblem: Fortune's // Weave). + // Upstream's command list series (copies of lists at submit, and of lists the GPU writes). + // Off by default on Android: on an Odin 3 it cost ~45 ms of GPU-thread waiting a second in + // Mario Kart World and caused flicker. + SwitchableSetting nx2_command_lists{linkage, +#ifdef __ANDROID__ + false, +#else + true, +#endif + "nx2_command_lists", Category::RendererHacks}; SwitchableSetting game_dlss_network{linkage, #ifdef __ANDROID__ false, diff --git a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp index 395a1fe273..0225be3417 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_gpu.cpp @@ -25,6 +25,7 @@ #include "video_core/gpu.h" #include "video_core/host1x/host1x.h" #include "video_core/memory_manager.h" +#include "video_core/nx2.h" namespace Service::Nvidia::Devices { namespace { @@ -448,12 +449,13 @@ NvResult nvhost_gpu::SubmitGPFIFOImpl(IoctlSubmitGpfifo& params, Tegra::CommandL // frames ahead), and Mario Kart World then refills command memory it already // submitted: the end of a frame ran with the next frame's present image as its render // target, leaving an image three frames old to be shown. Keep the words as submitted. - // EDEN_NX2_PB_SNAPSHOT=0: execute from memory. + // EDEN_NX2_PB_SNAPSHOT=0 or EDEN_NX2_COMMAND_LISTS=0: execute from memory. static const bool snapshot = [] { const char* value = std::getenv("EDEN_NX2_PB_SNAPSHOT"); return value == nullptr || value[0] != '0'; }(); - if (snapshot && channel_state->memory_manager && channel_state->payload) { + if (snapshot && Tegra::Nx2CommandLists() && 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); diff --git a/src/core/hle/service/nvdrv/nvdrv.cpp b/src/core/hle/service/nvdrv/nvdrv.cpp index aaccde0076..f038489f9c 100644 --- a/src/core/hle/service/nvdrv/nvdrv.cpp +++ b/src/core/hle/service/nvdrv/nvdrv.cpp @@ -5,9 +5,11 @@ // SPDX-FileCopyrightText: 2021 Skyline Team and Contributors // SPDX-License-Identifier: GPL-3.0-or-later +#include #include #include +#include "common/settings.h" #include "core/core.h" #include "core/hle/kernel/k_event.h" #include "core/hle/service/ipc_helpers.h" @@ -140,6 +142,19 @@ Module::Module(Core::System& system) Core::g_nx2_present_count = 0; LOG_INFO(Service_NVDRV, "Switch 2 held CPU writes before command lists: {} (EDEN_NX2_HELD_WRITES)", Tegra::Nx2HeldWritesEnabled() ? "on" : "off"); + { + // Read once per game start; EDEN_NX2_COMMAND_LISTS=0/1 overrides the setting. + bool command_lists = Settings::values.nx2_command_lists.GetValue(); + const char* const override_value = std::getenv("EDEN_NX2_COMMAND_LISTS"); + if (override_value != nullptr && override_value[0] != '\0') { + command_lists = override_value[0] != '0'; + } + Tegra::g_nx2_command_lists = command_lists; + LOG_INFO(Service_NVDRV, "Switch 2 command list fixes: {} ({})", command_lists ? "on" : "off", + override_value != nullptr && override_value[0] != '\0' + ? "EDEN_NX2_COMMAND_LISTS" + : "nx2_command_lists"); + } nx2_stub_builder = [this, &system](DeviceFD fd, const std::string& name) { auto device = std::make_shared(system, name); open_files.emplace(fd, device); diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 79db5de732..7d020fbce1 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -302,6 +302,10 @@ std::unique_ptr InitializeTranslations(QObject* parent) { tr("Enables access to previous buffer states.\nThis option may improve rendering " "quality and performance consistency in some games.")); INSERT(Settings, fix_bloom_effects, tr("Fix bloom effects"), tr("Removes bloom in Burnout.")); + INSERT(Settings, nx2_command_lists, tr("Switch 2 command list fixes"), + tr("Upstream's fixes for Switch 2 command lists the game rewrites after submitting. Off " + "on phones: on an Odin 3 they cost ~45 ms of GPU-thread waiting per second in Mario " + "Kart World and caused flicker. Takes effect when a game starts.")); INSERT(Settings, reduce_texture_resolution, tr("Reduce texture resolution"), tr("Large compressed textures keep their mip levels from the second one on: a quarter " "of the video memory, slightly blurrier up close.")); diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 2e48366eda..f5bda256f8 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -588,7 +588,8 @@ void DmaPusher::RefreshSnapshot(CommandList& list, u64 now) { } bool DmaPusher::IsGpuWrittenCommands(GPUVAddr address, size_t size) { - if (!Tegra::g_nx2_ampere.load(std::memory_order_relaxed) || size == 0) { + if (!Tegra::g_nx2_ampere.load(std::memory_order_relaxed) || !Tegra::Nx2CommandLists() || + size == 0) { return false; } for (GPUVAddr page = address & ~GPUVAddr{0xfff}; page < address + size; page += 0x1000) { @@ -608,8 +609,8 @@ bool DmaPusher::ReadGpuWrittenCommands(GPUVAddr address, u32 count) { // 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) { + if (!Tegra::g_nx2_ampere.load(std::memory_order_relaxed) || !Tegra::Nx2CommandLists() || + count == 0 || rasterizer == nullptr) { return false; } const GPUVAddr end = address + u64{count} * sizeof(u32); diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h index aa7e208651..9c74f0b649 100644 --- a/src/video_core/nx2.h +++ b/src/video_core/nx2.h @@ -39,6 +39,17 @@ inline bool Nx2HeldWrites() { return g_nx2_ampere.load(std::memory_order_relaxed) && Nx2HeldWritesEnabled(); } +// nx2_command_lists, set once per game start (nvdrv's Module; EDEN_NX2_COMMAND_LISTS=0/1 +// overrides it). Off, the command list series is: no copy of a list's words at submit +// (nvhost_gpu), and no copies or flushes of lists on GPU-written pages (DmaPusher's +// IsGpuWrittenCommands, ReadGpuWrittenCommands). Lists are then read from guest memory when they +// run, as before the series. +inline std::atomic g_nx2_command_lists{true}; + +inline bool Nx2CommandLists() { + return g_nx2_command_lists.load(std::memory_order_relaxed); +} + // Titles whose guest side reads GPU-written buffer data through a path the buffer cache does not // see, so their buffers must be copied back to guest memory at every fence // (BufferCache::AsyncBufferDownloads, EDEN_NX2_BUFFER_DOWNLOADS). 0400A3A01EE4C000 is Pokemon From 990c8bc9b5ee219c424f5fd6ee42d3baae180b5f Mon Sep 17 00:00:00 2001 From: Aidan Kinzett Date: Tue, 3 Nov 2026 17:28:02 +1000 Subject: [PATCH 108/154] android: detect .nup files in the game list (#381) Assisted-by: Claude Sonnet 5.5 (implementation) (cherry picked from commit b319adfd9ffaa257f052c2ff6b7581a8ebd4e58e) --- src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt | 2 +- .../app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt | 2 +- src/android/app/src/main/jni/game_metadata.cpp | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt index 502fab0de9..6e32478cd5 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -100,7 +100,7 @@ class Game( companion object { val extensions: Set = HashSet( - listOf("xci", "nsp", "nca", "nro", "nsz", "xcz", "nx2") + listOf("xci", "nsp", "nca", "nro", "nsz", "xcz", "nx2", "nup") ) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index 819598f03e..8ecc6f124a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -115,7 +115,7 @@ object GameHelper { // File extensions considered as external content, buuut should // be done better imo. - private val externalContentExtensions = setOf("nsp", "xci", "nsz", "xcz") + private val externalContentExtensions = setOf("nsp", "xci", "nsz", "xcz", "nup") private fun scanContentContainersRecursive( files: Array, diff --git a/src/android/app/src/main/jni/game_metadata.cpp b/src/android/app/src/main/jni/game_metadata.cpp index 9b6cd44e9f..dbe788011e 100644 --- a/src/android/app/src/main/jni/game_metadata.cpp +++ b/src/android/app/src/main/jni/game_metadata.cpp @@ -76,8 +76,7 @@ jboolean Java_org_yuzu_yuzu_1emu_utils_GameMetadata_getIsValid(JNIEnv* env, jobj auto const file_type = loader->GetFileType(); if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) return false; - if ((file_type == Loader::FileType::NSP || file_type == Loader::FileType::XCI || - file_type == Loader::FileType::NSZ || file_type == Loader::FileType::XCZ) && + if (Loader::IsContainerType(file_type) && !Loader::IsBootableGameContainer(file, file_type)) return false; u64 program_id = 0; From 6358be05a7adaa2780fa0673e4717a490ae91ff4 Mon Sep 17 00:00:00 2001 From: Aidan Kinzett Date: Tue, 3 Nov 2026 17:40:12 +1000 Subject: [PATCH 109/154] fix: Validate main and main.npdm on the same effective ExeFS selected by AppLoader_NUP (cherry picked from commit b185ddda2a835f6b4a42ae4856dc46e65e0dd65b) --- src/core/loader/loader.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/loader/loader.cpp b/src/core/loader/loader.cpp index 69a1a8ba41..1486aa160d 100644 --- a/src/core/loader/loader.cpp +++ b/src/core/loader/loader.cpp @@ -176,15 +176,23 @@ bool IsBootableGameContainer(FileSys::VirtualFile file, FileType type, u64 progr if (nup.GetStatus() != ResultStatus::Success) { return false; } + const auto has_usable_exefs = [&nup](std::size_t index) { + const auto exefs = nup.GetExeFSDir(index); + return exefs != nullptr && exefs->GetFile("main") != nullptr && + exefs->GetFile("main.npdm") != nullptr; + }; // A base package boots normally; a standalone update (no base) carries // a complete patched tree and boots on its own (SPEC section 4.1.2). if (const auto base = nup.FindBasePackage(); base.has_value() && nup.GetExeFSDir(*base) != nullptr) { - return true; + const auto update = nup.FindBestUpdatePackage(); + return has_usable_exefs(update && nup.GetExeFSDir(*update) != nullptr + ? *update + : *base); } if (const auto update = nup.FindBestUpdatePackage(); update.has_value() && nup.GetExeFSDir(*update) != nullptr) { - return true; + return has_usable_exefs(*update); } return false; } From 464abfc3b63e5717030343104c09ebf9d22a29ae Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:08:19 -0500 Subject: [PATCH 110/154] nx2:profile: zones for the writer waits and macro DMA-read drains, front counters The split's WaitForWriters (every GPFIFO entry whose pages are not marked GPU-written, and ReadGpuWrittenCommands) had no profile zone, so its spin counted as "push", which is why push and "split drain" traded places between builds. It is "split writers" now. The drain a front macro DMA read makes is "split dma read" (Drain takes the zone it counts as); semaphore acquires stay "split drain". New per-second counters on the profile line: 3D register writes made by front macros (the rest are direct pushbuffer writes), words sent as multi-method entries, GPFIFO entries started, acquires met by the semaphore ledger and same-value writes elided inside the macro JIT (the last two for the changes that follow). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/dma_pusher.cpp | 3 +++ src/video_core/engines/maxwell_3d.cpp | 5 ++++- src/video_core/gpu_split.cpp | 8 +++++++- src/video_core/gpu_split.h | 19 ++++++++++++++++++- src/video_core/nx2_profile.h | 25 +++++++++++++++++++------ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index f5bda256f8..5419946fdd 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -110,6 +110,9 @@ bool DmaPusher::Step() { auto& current_command = command_list.command_lists[dma_pushbuffer_subindex]; const CommandListHeader& header = current_command; dma_state.dma_get = header.addr; + if (command_resume_offset == 0) { + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::GpfifoEntries, 1); + } if (signal_sync && !synced) { std::unique_lock lk(sync_mutex); diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 9cd01089e1..75deb437a5 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -947,6 +947,9 @@ void Maxwell3D::FrontWrite(VideoCommon::GpuSplit& split, FrontState& fs, u32 met } if (Profile::Enabled()) [[unlikely]] { Profile::Add(Profile::Counter::MethodWrites, 1); + if (from_macro) { + Profile::Add(Profile::Counter::MacroWrites, 1); + } } if (method == MAXWELL3D_REG_INDEX(shadow_ram_control) && FrontVerifyEnabled()) [[unlikely]] { fs.control_writes[fs.control_count++ % fs.control_writes.size()] = { @@ -1174,7 +1177,7 @@ void Maxwell3D::FrontDmaRead(VideoCommon::GpuSplit& split, FrontState& fs, u32 m u32 count) { // The words may be written by methods still queued for the back (indirect draw arguments // from a culling dispatch), and the caches that may hold newer ones belong to it. - split.Drain(); + split.Drain(VideoCommon::Nx2Profile::Zone::SplitDmaRead); const GPUVAddr source = (GPUVAddr{fs.regs[0x154] & 0x1ffffff} << 32) | fs.regs[0x155]; const u32 ram = fs.regs[0x156]; const u32 words = std::min(count, 0x1000); diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 532932341a..54299277a7 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -331,12 +331,16 @@ void GpuSplit::Publish() { } void GpuSplit::Drain() { + Drain(Nx2Profile::Zone::SplitDrain); +} + +void GpuSplit::Drain(Nx2Profile::Zone zone) { consumed_cache = consumed.load(std::memory_order_acquire); if (consumed_cache == write_position) { return; } Publish(); - Nx2Profile::Scope scope{Nx2Profile::Zone::SplitDrain}; + Nx2Profile::Scope scope{zone}; auto next_report = std::chrono::steady_clock::now() + std::chrono::seconds{5}; for (u32 spins = 0;; ++spins) { consumed_cache = consumed.load(std::memory_order_acquire); @@ -372,6 +376,7 @@ bool GpuSplit::WaitForWriters() { } wait_target.store(target, std::memory_order_seq_cst); Publish(); + Nx2Profile::Scope scope{Nx2Profile::Zone::SplitWriters}; for (u32 spins = 0;; ++spins) { consumed_cache = consumed.load(std::memory_order_acquire); if (consumed_cache >= target) { @@ -406,6 +411,7 @@ void GpuSplit::SwitchEngine(Tegra::Engines::EngineInterface* engine) { void GpuSplit::MultiMethod(Tegra::Engines::EngineInterface* engine, u32 method, const u32* words, u32 amount, u32 methods_pending, const GPUVAddr* segment) { SelectEngine(engine); + Nx2Profile::Add(Nx2Profile::Counter::MultiWords, amount); // Long runs (constant buffer and inline uploads) go in pieces, as when a method's data // continues in the next GPFIFO entry. while (amount > 0) { diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index cc4330ace5..a58e04901a 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -20,6 +20,10 @@ namespace Tegra::Engines { class EngineInterface; } +namespace VideoCommon::Nx2Profile { +enum class Zone : u32; +} + namespace VideoCommon { // Splits the GPU thread in two (NX2_GPU_THREAD_SPLIT.md). The GPU thread (the front) takes the @@ -113,8 +117,21 @@ class GpuSplit { /// Makes what was sent visible to the back. void Publish(); - /// Waits until the back executed everything sent so far. + /// Waits until the back executed everything sent so far. `zone`: the profile zone the wait + /// counts as (SplitDrain, or SplitDmaRead for a macro DMA read). void Drain(); + void Drain(Nx2Profile::Zone zone); + + /// Front thread: the ring position after everything sent so far (an entry sent now ends + /// below the position returned after it is sent). + [[nodiscard]] u64 WritePosition() const { + return write_position; + } + /// Front thread: the ring position the back has executed up to (as last seen; refreshed). + [[nodiscard]] u64 Consumed() { + consumed_cache = consumed.load(std::memory_order_acquire); + return consumed_cache; + } /// Compute or copy work was just sent: what can write GPU-written command lists. void NoteWriter() { diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h index b922a144d9..f81bcb0fa0 100644 --- a/src/video_core/nx2_profile.h +++ b/src/video_core/nx2_profile.h @@ -52,6 +52,8 @@ enum class Zone : u32 { Method, ///< Engine method calls (what a split GPU thread's back half would run) SplitDrain, ///< Split GPU thread: the front waits for the back (gpu_split.h) SplitWait, ///< Split GPU thread: the back waits for work + SplitWriters, ///< Split GPU thread: the front waits for queued compute/copy work (WaitForWriters) + SplitDmaRead, ///< Split GPU thread: the front drains the back for a macro DMA read Count, }; @@ -62,7 +64,7 @@ inline constexpr std::array(Zone::Count)> zone_ "sync descriptors", "read handles", "fill image views", "texture buffers", "update buffers", "descriptor acquire", "bind stage buffers", "push descriptors", "render targets", "configure draw", "dynamic state", "draw record", "texture upload", "fence", - "fence skipped", "method", "split drain", "split wait", + "fence skipped", "method", "split drain", "split wait", "split writers", "split dma read", }; // Set at startup, or by System::Load when a gpu_profile file is in the user directory (Android @@ -82,7 +84,7 @@ inline u64 Now() { struct ThreadData { std::array(Zone::Count)> exclusive_ns{}; std::array(Zone::Count)> count{}; - std::array counters{}; ///< See Counter + std::array counters{}; ///< See Counter u64 child_ns{}; ///< Time of the zones nested in the current one u64 window_start{}; }; @@ -132,6 +134,11 @@ enum class Counter : u32 { SameValueWrites, ///< Of MethodWrites: the register already held the value AcquireChecks, ///< Semaphore acquires evaluated AcquireWaits, ///< Of AcquireChecks: not met yet (the channel waits) + MacroWrites, ///< Of MethodWrites: made by macros running on the split's front + MultiWords, ///< Words the front sent to the back as multi-method entries + GpfifoEntries, ///< Command list (GPFIFO) entries started + AcquireLedger, ///< Of AcquireChecks: met without draining the back (semaphore ledger) + JitSkippedWrites, ///< Of SameValueWrites: elided inside the ARM64 macro JIT }; /// Device pages (4 KiB) the native MME has DMA-read since start (GPU thread only; filled only @@ -186,12 +193,18 @@ inline void Report(const char* thread_name = "GPU thread") { static_cast(data.counters[1]) * 1e9 / window, static_cast(data.counters[3]) * 1e9 / 1024.0 / window, static_cast(data.counters[2]) * 1e9 / 1024.0 / window); - line += fmt::format(" | 3D register writes {:.0f} ({:.0f} same value)", + line += fmt::format(" | 3D register writes {:.0f} ({:.0f} same value, {:.0f} from macros, {:.0f} elided in JIT)", static_cast(data.counters[4]) * 1e9 / window, - static_cast(data.counters[5]) * 1e9 / window); - line += fmt::format(" | acquires {:.0f} ({:.0f} waited)", + static_cast(data.counters[5]) * 1e9 / window, + static_cast(data.counters[8]) * 1e9 / window, + static_cast(data.counters[12]) * 1e9 / window); + line += fmt::format(" | multi words {:.0f} | GPFIFO entries {:.0f}", + static_cast(data.counters[9]) * 1e9 / window, + static_cast(data.counters[10]) * 1e9 / window); + line += fmt::format(" | acquires {:.0f} ({:.0f} waited, {:.0f} met by ledger)", static_cast(data.counters[6]) * 1e9 / window, - static_cast(data.counters[7]) * 1e9 / window); + static_cast(data.counters[7]) * 1e9 / window, + static_cast(data.counters[11]) * 1e9 / window); LOG_INFO(HW_GPU, "{} per second:{}", thread_name, line); data = {}; data.window_start = now; From 3a1c59c2683629c7cd9be4dab270ced0ed5e8b47 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:12:50 -0500 Subject: [PATCH 111/154] nx2:split: semaphore ledger, acquires met without draining the back Every puller acquire drained the split's back before reading its word (~10,000 a second in Mario Kart World): the front stopped until the back had run everything queued, then the back idled while the front refilled the ring, so the two threads overlapped at most one acquire interval of work ("split drain" on the front, "split wait" on the back). The front now keeps the 32-bit puller releases it queued (SEMAPHORED write, SEMAPHORE_RELEASE, SEM_EXECUTE release and the Switch 2 mirror), by device address with the ring position they were sent at (semaphore_ledger.h). An acquire (the Switch 2 trigger path, SEM_EXECUTE acquires, SEMAPHORE_ACQUIRE and the resume of a suspended channel) is met without the drain when: - the last puller release queued for the word is still ahead of the back in the ring and meets the acquire: the back runs the ring in order, so everything after the acquire runs after that release (any channel: all channels feed the one ring, in parse order); or - no such release is still queued and guest memory, read without flushing the back-owned caches, already meets it. A newer value the caches hold is a later release; sync words count up (NVN sync objects), so memory meeting the acquire is enough. A queued puller release that would not meet it keeps the drain path. Anything else takes the old path: drain, flush, read, latch waiters, park. QMD/engine releases, DMA copies and CPU writes are not recorded, 64-bit SEM_EXECUTE payloads and reductions forget the word. The assumption is that GPU work queued before an acquire does not move its word backwards. EDEN_NX2_ACQ_LEDGER=0 turns it off. The profile line counts the acquires met this way ("met by ledger"); "split drain" and "split wait" should fall by the drains they replace, "acquires ... waited" should not change. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/CMakeLists.txt | 1 + src/video_core/engines/puller.cpp | 108 +++++++++++++++++++++++++++++- src/video_core/semaphore_ledger.h | 85 +++++++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/video_core/semaphore_ledger.h diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 46c7318a11..a0bf824fa4 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -112,6 +112,7 @@ add_library(video_core STATIC native_query_trace.h nx2.h nx2_profile.h + semaphore_ledger.h present.h pte_kind.h query_cache/bank_base.h diff --git a/src/video_core/engines/puller.cpp b/src/video_core/engines/puller.cpp index 0ac798567f..2bb967f4ba 100644 --- a/src/video_core/engines/puller.cpp +++ b/src/video_core/engines/puller.cpp @@ -25,6 +25,7 @@ #include "video_core/memory_manager.h" #include "video_core/nx2.h" #include "video_core/rasterizer_interface.h" +#include "video_core/semaphore_ledger.h" namespace Tegra::Engines { @@ -59,6 +60,69 @@ void DrainBack() { VideoCommon::GpuSplit::Get().Drain(); } +using Ledger = Nx2SemaphoreLedger; + +// Records a 32-bit puller release the front just queued for the back (semaphore_ledger.h). +void NoteRelease(DmaPusher& dma_pusher, GPUVAddr address, u32 payload) { + if (!Ledger::Enabled()) { + return; + } + if (const auto device = dma_pusher.memory_manager.GpuToCpuAddress(address)) { + Ledger::Get().NoteRelease(*device, payload, VideoCommon::GpuSplit::Get().WritePosition()); + } +} + +void ForgetRelease(DmaPusher& dma_pusher, GPUVAddr address) { + if (!Ledger::Enabled()) { + return; + } + if (const auto device = dma_pusher.memory_manager.GpuToCpuAddress(address)) { + Ledger::Get().Forget(*device); + } +} + +// Whether an acquire is met without draining the split's back (semaphore_ledger.h): the last +// puller release queued for the word meets it and is still ahead of the back in the ring, or +// guest memory already meets it and no queued puller release moves the word away. The word is +// read without flushing the caches, which belong to the back: a value they hold but memory +// does not yet is newer, and sync words count up, so memory meeting the acquire is enough. +bool AcquireMetWithoutDrain(DmaPusher& dma_pusher, GPUVAddr address, u32 value, + Ledger::Compare compare) { + auto& split = VideoCommon::GpuSplit::Get(); + if (!Ledger::Enabled() || !split.Active()) { + return false; + } + const auto device = dma_pusher.memory_manager.GpuToCpuAddress(address); + if (!device) { + return false; + } + if (const Ledger::Entry* entry = Ledger::Get().Find(*device)) { + const bool entry_met = Ledger::Met(compare, entry->value, value); + const bool queued = entry->order > split.Consumed(); + if (queued) { + // The word will hold this release's value when the back passes the acquire. + return entry_met; + } + } + return Ledger::Met(compare, dma_pusher.memory_manager.Read(address), value); +} + +Ledger::Compare CompareOf(u32 operation) { + // Puller GpuSemaphoreOperation values (PendingAcquire::Matches). + switch (operation) { + case 0x1: + return Ledger::Compare::Equal; + case 0x4: + return Ledger::Compare::Gequal; + default: + return Ledger::Compare::AndNonZero; + } +} + +void CountLedgerAcquire() { + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::AcquireLedger, 1); +} + } // Anonymous namespace bool Puller::PendingAcquire::Matches(u32 current) const { @@ -78,6 +142,11 @@ bool Puller::CanResumeAcquire(DmaPusher& dma_pusher) { if (!pending_acquire) { return true; } + if (AcquireMetWithoutDrain(dma_pusher, pending_acquire->address, pending_acquire->value, + CompareOf(pending_acquire->operation))) { + CountLedgerAcquire(); + return true; + } // The release may still be queued for the split GPU thread's back half (and the caches // ReadSemaphoreWord asks are its state). DrainBack(); @@ -233,6 +302,10 @@ void Puller::ProcessSemaphoreTriggerMethod(DmaPusher& dma_pusher) { rasterizer->Query(mirror, VideoCommon::QueryType::Payload, properties, payload, 0); } }); + NoteRelease(dma_pusher, sequence_address, payload); + if (mirror != 0) { + NoteRelease(dma_pusher, mirror, payload); + } } else if (g_nx2_ampere) { // Switch 2 waits on semaphores the CPU (or another channel) releases, e.g. once it has // written the commands this channel runs next: suspend the channel until the value is @@ -250,6 +323,11 @@ void Puller::ProcessSemaphoreTriggerMethod(DmaPusher& dma_pusher) { return; } VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::AcquireChecks, 1); + if (AcquireMetWithoutDrain(dma_pusher, acquire.address, acquire.value, + CompareOf(acquire.operation))) { + CountLedgerAcquire(); + return; + } DrainBack(); if (acquire.Matches(ReadSemaphoreWord(dma_pusher, acquire.address))) { return; @@ -307,12 +385,18 @@ void Puller::ProcessSemaphoreRelease(DmaPusher& dma_pusher) { rasterizer->Query(sequence_address, VideoCommon::QueryType::Payload, VideoCommon::QueryPropertiesFlags::IsAFence, payload, 0); }); + NoteRelease(dma_pusher, sequence_address, payload); } void Puller::ProcessSemaphoreAcquire(DmaPusher& dma_pusher) { + const auto value = regs.semaphore_acquire; + if (AcquireMetWithoutDrain(dma_pusher, regs.semaphore_address.SemaphoreAddress(), value, + Ledger::Compare::Equal)) { + CountLedgerAcquire(); + return; + } DrainBack(); u32 word = dma_pusher.memory_manager.Read(regs.semaphore_address.SemaphoreAddress()); - const auto value = regs.semaphore_acquire; while (word != value) { regs.acquire_active = true; regs.acquire_value = value; @@ -331,13 +415,35 @@ void Puller::ProcessSemaphoreExecute(DmaPusher& dma_pusher, u32 argument) { (regs.reg_array[0x17] & ~3U); const u32 payload = regs.reg_array[0x19]; const u32 operation = argument & 7; + // PAYLOAD_SIZE (bit 24): a 64-bit payload (SEM_PAYLOAD_HI, 0x1a). Only the low word is + // written and compared here; the ledger stays out of such words. + const bool payload_64 = (argument & (1U << 24)) != 0; if (operation == 1) { // RELEASE OnBack([rasterizer = dma_pusher.rasterizer, address, payload] { rasterizer->Query(address, VideoCommon::QueryType::Payload, VideoCommon::QueryPropertiesFlags::IsAFence, payload, 0); }); + if (payload_64) { + ForgetRelease(dma_pusher, address); + } else { + NoteRelease(dma_pusher, address, payload); + } return; } + if (operation == 6) { // REDUCTION: the word's value depends on what it held + ForgetRelease(dma_pusher, address); + } + if (!payload_64 && operation <= 5 && operation != 1) { + const Ledger::Compare compare = operation == 0 ? Ledger::Compare::Equal + : operation == 2 ? Ledger::Compare::Gequal + : operation == 3 ? Ledger::Compare::CircularGequal + : operation == 4 ? Ledger::Compare::AndNonZero + : Ledger::Compare::Nor; + if (AcquireMetWithoutDrain(dma_pusher, address, payload, compare)) { + CountLedgerAcquire(); + return; + } + } // Acquires: check once after flushing pending fences; never block the GPU thread on them. DrainBack(); dma_pusher.rasterizer->ReleaseFences(); diff --git a/src/video_core/semaphore_ledger.h b/src/video_core/semaphore_ledger.h new file mode 100644 index 0000000000..fc5c77b1b0 --- /dev/null +++ b/src/video_core/semaphore_ledger.h @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "common/common_types.h" + +namespace Tegra { + +// The puller's semaphore releases as the split GPU thread's front queued them (gpu_split.h), +// by device address: a channel's acquire can be met without draining the back when the last +// release queued for its word already meets it (the back executes the ring in order, so +// everything after the acquire runs after that release) or when guest memory already holds a +// value that meets it and no queued puller release would move the word away from it. +// Releases the front does not see (QMD and engine semaphores, DMA copies, CPU writes) are not +// recorded: an acquire on such a word still drains the back and reads it unless guest memory +// already meets it. That last case assumes the word is not moved backwards by GPU work queued +// before the acquire (NVN sync objects count up). EDEN_NX2_ACQ_LEDGER=0 turns the ledger off. +class Nx2SemaphoreLedger { +public: + enum class Compare : u32 { + Equal, ///< word == value + Gequal, ///< word >= value (unsigned) + CircularGequal, ///< (s32)(word - value) >= 0 + AndNonZero, ///< (word & value) != 0 + Nor, ///< (word & value) == 0 + }; + + struct Entry { + u64 order; ///< Ring position after the release was sent (GpuSplit::WritePosition) + u32 value; + }; + + static Nx2SemaphoreLedger& Get() { + static Nx2SemaphoreLedger ledger; + return ledger; + } + + static bool Enabled() { + static const bool enabled = [] { + const char* value = std::getenv("EDEN_NX2_ACQ_LEDGER"); + return value == nullptr || value[0] != '0'; + }(); + return enabled; + } + + /// A 32-bit puller release of `value` to the word at `device_address` was queued. + void NoteRelease(u64 device_address, u32 value, u64 order) { + entries[device_address] = Entry{order, value}; + } + + /// The word gets a value the front cannot know (reduction, 64-bit payload). + void Forget(u64 device_address) { + entries.erase(device_address); + } + + [[nodiscard]] const Entry* Find(u64 device_address) const { + const auto it = entries.find(device_address); + return it == entries.end() ? nullptr : &it->second; + } + + [[nodiscard]] static bool Met(Compare compare, u32 word, u32 value) { + switch (compare) { + case Compare::Equal: + return word == value; + case Compare::Gequal: + return word >= value; + case Compare::CircularGequal: + return static_cast(word - value) >= 0; + case Compare::AndNonZero: + return (word & value) != 0; + case Compare::Nor: + return (word & value) == 0; + } + return false; + } + +private: + std::unordered_map entries; +}; + +} // namespace Tegra From f680fea2878fa3ea1ded7b4ae6e0208c68b47256 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:12:50 -0500 Subject: [PATCH 112/154] macro: elide same-value state writes inside the ARM64 JIT Title 1's front macros emit ~9-10 million 3D register writes a second, over half of which keep the register's value: FrontWrite returned from those without sending anything, but each still cost the compiled code an indirect call into Thunks::CallEmit, the emit lambda and FrontWrite. The compiled emit now checks first, against the front's own mirrors (TuringMacroJit::Elision): a method whose must_call byte is 0 (not executable, not an MME DMA read, below the macro range) whose register already holds the value, and whose shadow RAM word does too while the shadow RAM tracks (control 0/1), is skipped in place; Passthrough (2) needs only the register; Replay (3) always calls back, as the written value comes from the shadow RAM. The mode is read on every emit, so a macro that switches it mid way is followed. This is exactly the set FrontWrite skips, so the registers the front and back hold are unchanged (EDEN_NX2_SPLIT_VERIFY=1 compares them as before); nothing is elided while a macro's first calls are checked against the interpreter, which sees every emit. Elided writes stay in the profile counters (3D register writes, same value, from macros) and are reported separately ("elided in JIT"). EDEN_NX2_JIT_SKIP_SAME=0 turns it off (FrontWrite then skips them as before); EDEN_NX2_SPLIT_SKIP=0 disables both. Expected: "macro" on the front falls, counters unchanged. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 51 +++++++++++++++++++++ src/video_core/macro_turing_jit_arm64.h | 61 +++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 75deb437a5..5c49bd26c7 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -855,6 +855,12 @@ struct Maxwell3D::FrontState { /// then can it have a deferred instanced draw to issue at the end of a macro. bool draw_pending{true}; bool skip_same{}; ///< SkipSameValueWrites() && FrontSkipSame(), read once +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) + /// Methods whose emits the macro JIT must always call back for (TuringMacroJit::Elision): + /// executable ones, the MME DMA reads (FrontDmaRead) and the macro range. + std::array jit_must_call{}; + TuringMacroJit::Elision elision{}; +#endif }; namespace { @@ -901,6 +907,16 @@ bool FrontHandledOutsideDrawManager(u32 method) { } } +// EDEN_NX2_JIT_SKIP_SAME=0: the ARM64 macro JIT calls back for every emit; the same-value +// writes are then skipped in FrontWrite as before (A/B switch for the in-JIT elision). +bool JitSkipSameEnabled() { + static const bool skip = [] { + const char* value = std::getenv("EDEN_NX2_JIT_SKIP_SAME"); + return value == nullptr || value[0] != '0'; + }(); + return skip; +} + // EDEN_NX2_SPLIT_SKIP=0: send state writes that keep the register's value too. bool FrontSkipSame() { static const bool skip = [] { @@ -923,6 +939,13 @@ Maxwell3D::FrontState& Maxwell3D::Front(Core::System& system, VideoCommon::GpuSp std::memcpy(front->shadow.data(), shadow_state.reg_array.data(), sizeof(front->shadow)); front->control = shadow_state.shadow_ram_control; front->skip_same = SkipSameValueWrites() && FrontSkipSame(); +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) + for (u32 method = 0; method < front->jit_must_call.size(); ++method) { + const bool dma_read = method == 0x157 || method == 0x158; + front->jit_must_call[method] = + method >= Regs::NUM_REGS || execution_mask[method] || dma_read ? 1 : 0; + } +#endif front->executing_macro = executing_macro; front->parameters = std::move(macro_params); macro_params.clear(); @@ -1148,10 +1171,38 @@ void Maxwell3D::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split // call (or could not be compiled): the front interprets the macros then. With macros // on the front the back never runs them, so the interpreter is the front's. const bool jit = engine.native_jit->Usable(); +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) + // Same-value state writes are elided inside the compiled code, as FrontWrite would skip + // them (never when the front sends them all, EDEN_NX2_SPLIT_SKIP=0). + if (jit && fs.skip_same && JitSkipSameEnabled()) { + fs.elision = TuringMacroJit::Elision{ + .must_call = fs.jit_must_call.data(), + .regs = fs.regs.data(), + .shadow = fs.shadow.data(), + .control = reinterpret_cast(&fs.control), + .skipped = 0, + }; + engine.native_jit->SetElision(&fs.elision); + } else { + engine.native_jit->SetElision(nullptr); + } +#endif const auto result = jit ? engine.native_jit->Execute(start, fs.parameters, std::span{native_data_ram}, read, emit) : engine.native_interpreter.Execute(engine.native_code, start, fs.parameters, read, emit); +#if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) + if (const u32 skipped = fs.elision.skipped; skipped != 0) { + fs.elision.skipped = 0; + if (VideoCommon::Nx2Profile::Enabled()) [[unlikely]] { + namespace Profile = VideoCommon::Nx2Profile; + Profile::Add(Profile::Counter::MethodWrites, skipped); + Profile::Add(Profile::Counter::MacroWrites, skipped); + Profile::Add(Profile::Counter::SameValueWrites, skipped); + Profile::Add(Profile::Counter::JitSkippedWrites, skipped); + } + } +#endif if (!result.success) { engine.native_execution_failed = true; LOG_ERROR(HW_GPU, "Native MME ({}, split front) failed: start={:#x}, consumed={}/{}, error={}", diff --git a/src/video_core/macro_turing_jit_arm64.h b/src/video_core/macro_turing_jit_arm64.h index 102161663b..ffdbdc6448 100644 --- a/src/video_core/macro_turing_jit_arm64.h +++ b/src/video_core/macro_turing_jit_arm64.h @@ -41,6 +41,26 @@ class TuringMacroJit { using Word = std::uint32_t; using Result = TuringMacroInterpreter::Result; + /// Same-value elision (Maxwell3D::FrontRunMacro): an emit of a method whose `must_call` + /// byte is 0 (a state register without side effects) is skipped inside the compiled code + /// when `regs[method]` already holds the value and, with the shadow RAM tracking + /// (`*control` 0 or 1), `shadow[method]` does too; mode 3 (replay) never elides. Exactly + /// the writes Maxwell3D::FrontWrite would return from without sending; `skipped` counts + /// them. Set per call with SetElision; the checked calls (ExecuteChecked) never elide, so + /// the comparison with the interpreter sees every emit. + struct Elision { + const std::uint8_t* must_call; + const Word* regs; + const Word* shadow; + const Word* control; + Word skipped; + }; + static constexpr Word MustCallMethods = 0x1000; ///< Entries in Elision::must_call + + void SetElision(Elision* elision_) { + elision = elision_; + } + struct State { std::array regs; Word carry; @@ -67,6 +87,7 @@ class TuringMacroJit { bool (*emit)(State*, Word method, Word value); Word (*read)(State*, Word address); const std::vector* fifo; + const Elision* elision; ///< Null: every emit calls back }; enum Error : Word { @@ -151,7 +172,7 @@ class TuringMacroJit { --left; return ExecuteChecked(start, parameters, data_ram, read, emit); } - return Run(start, parameters, data_ram, read, emit); + return Run(start, parameters, data_ram, read, emit, elision); } /// The code for `code` without its jump table (see Relocate), for tests. @@ -199,8 +220,9 @@ class TuringMacroJit { template Result Run(Word start, std::vector& parameters, std::span data_ram, Read& read, - Emit& emit) { + Emit& emit, const Elision* elide) { State state; + state.elision = elide; state.regs.fill(0); state.carry = state.method = state.increment = 0; state.loop_count = state.loop_start = state.loop_end = 0; @@ -557,11 +579,40 @@ class TuringMacroJit { if (d.out_emit[lane]) { Output(W2, d, d.out_emit[lane]); LDR(W1, st, offsetof(State, method)); + // Same-value elision (State::elision): skip the callback for a state write + // that changes nothing. W1 (x1, zero-extended) is the method, < 0x1000. + Label call_emit, elided, after_emit; + LDR(X16, st, offsetof(State, elision)); + CBZ(X16, call_emit); + LDR(X0, X16, offsetof(Elision, must_call)); + LDRB(W17, X0, X1); + CBNZ(W17, call_emit); + LDR(X0, X16, offsetof(Elision, regs)); + LDR(W17, X0, X1, oaknut::IndexExt::LSL, 2); + CMP(W17, W2); + B(Cond::NE, call_emit); + LDR(X0, X16, offsetof(Elision, control)); + LDR(W17, X0, 0); + CMP(W17, 3); // Replay: the value written comes from the shadow RAM + B(Cond::EQ, call_emit); + CMP(W17, 2); // Passthrough: the shadow RAM is not involved + B(Cond::EQ, elided); + LDR(X0, X16, offsetof(Elision, shadow)); + LDR(W17, X0, X1, oaknut::IndexExt::LSL, 2); + CMP(W17, W2); + B(Cond::NE, call_emit); + l(elided); + LDR(W17, X16, offsetof(Elision, skipped)); + ADD(W17, W17, 1); + STR(W17, X16, offsetof(Elision, skipped)); + B(after_emit); + l(call_emit); MOV(X0, st); LDR(X16, st, offsetof(State, emit)); BLR(X16); TST(W0, 0xff); B(Cond::EQ, Far(fail_emit)); + l(after_emit); LDR(W0, st, offsetof(State, increment)); LDR(W1, st, offsetof(State, method)); ADD(W1, W1, W0); @@ -887,7 +938,7 @@ class TuringMacroJit { Result ExecuteChecked(Word start, std::vector& parameters, std::span data_ram, Read& read, Emit& emit) { if (data_ram.size() != std::tuple_size_v) { - return Run(start, parameters, data_ram, read, emit); + return Run(start, parameters, data_ram, read, emit, nullptr); } if (!check) { check = std::make_unique(); @@ -926,7 +977,8 @@ class TuringMacroJit { c.emits.push_back({method, value, ok, c.words.size(), c.changes.size()}); return ok; }; - const Result result = Run(start, parameters, data_ram, recording_read, recording_emit); + const Result result = + Run(start, parameters, data_ram, recording_read, recording_emit, nullptr); if (c.comparable) { Compare(start, result, data_ram); } @@ -1065,6 +1117,7 @@ class TuringMacroJit { std::unique_ptr program; std::uint64_t compiled_generation{~std::uint64_t{0}}; + Elision* elision{}; bool diverged{}; // The compiled code (the interpreter's input when checking) and the checks left per entry // (the last one counts entries outside the code). From cbc42f3e54064ad0d3cc93b61f301eb6f59a1230 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:26:40 -0500 Subject: [PATCH 113/154] nx2:split: wait for queued writers only with the command list series on Every GPFIFO entry whose pages were not marked GPU-written made the front wait for the compute dispatches and copies queued for the back (WaitForWriters) and then look at the marks again. The marks come from the command list series (nx2_command_lists); with it off, the Android default, IsGpuWrittenCommands is always false, so the wait ran for every entry (~130,000 a second in Mario Kart World, 340-680 ms of every second of a race in the "split writers" zone) and its result was thrown away. The old single-thread code only waited inside ReadGpuWrittenCommands, behind the same gate. Wait only when the series is on and the title is Switch 2; the desktop and lists-on behaviour is unchanged. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/dma_pusher.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 5419946fdd..991617b882 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -181,7 +181,11 @@ bool DmaPusher::Step() { // billions of instances and lost the device loading a race). bool gpu_written = IsGpuWrittenCommands(dma_state.dma_get, size_t{header.size} * sizeof(u32)); - if (!gpu_written && VideoCommon::GpuSplit::Get().Active() && + // Only with the command list series on (nx2_command_lists): without it no page is ever + // marked, IsGpuWrittenCommands is always false, and the wait (for every compute dispatch + // or copy queued for the back) ran for every entry, ~130,000 times a second, for nothing. + if (!gpu_written && Tegra::g_nx2_ampere.load(std::memory_order_relaxed) && + Tegra::Nx2CommandLists() && VideoCommon::GpuSplit::Get().Active() && VideoCommon::GpuSplit::Get().WaitForWriters()) { gpu_written = IsGpuWrittenCommands(dma_state.dma_get, size_t{header.size} * sizeof(u32)); From 04f2ce6451da1ccdccd9d1165cf5fcdcea1c5c53 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:37:34 -0500 Subject: [PATCH 114/154] nx2:split: sink the state writes of front macros on the back Every write a front macro made went to the back as a direct entry (GpuSplit::MacroWrite): ConsumeSink and a full CallMethod at once, as the back's own macros write. Nearly all of them are plain state writes, 4-4.5 million a second in a Mario Kart World race, and that was the back's "method" zone (181-203 ms of every second, more than any draw-setup zone). A state write (not in execution_mask) now goes as an ordinary method entry: the back puts it in the engine's method sink and applies it before the next executable method (ConsumeSinkImpl: shadow RAM, same-value skip, dirty flags), exactly what CallMethod did at once; the end of a draw macro consumes the sink too (FinishFrontMacro), and a shadow RAM mode change is an executable method, so every sunk write is applied under the mode it was made in. Executable methods still go direct. The front's register and shadow mirrors are unchanged, and SPLIT_VERIFY already counts sunk writes as applied. EDEN_NX2_MACRO_SINK=0 sends every macro write direct as before. Expected: the back's "method" falls by most of its calls (the "called" entry count drops to the executable methods), "other" rises by the cheaper sink pushes. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 5c49bd26c7..05cef9b029 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -917,6 +917,17 @@ bool JitSkipSameEnabled() { return skip; } +// EDEN_NX2_MACRO_SINK=0: the back runs every write a front macro makes through CallMethod at +// once (GpuSplit::MacroWrite), instead of putting state writes in its method sink like the +// pushbuffer's and applying them before the next executable method. +bool MacroSinkEnabled() { + static const bool sink = [] { + const char* value = std::getenv("EDEN_NX2_MACRO_SINK"); + return value == nullptr || value[0] != '0'; + }(); + return sink; +} + // EDEN_NX2_SPLIT_SKIP=0: send state writes that keep the register's value too. bool FrontSkipSame() { static const bool skip = [] { @@ -1025,9 +1036,12 @@ void Maxwell3D::FrontWrite(VideoCommon::GpuSplit& split, FrontState& fs, u32 met break; } } - if (from_macro) { + if (from_macro && (execution_mask[method] || !MacroSinkEnabled())) { split.MacroWrite(this, method, argument); } else { + // A state write: the back sinks it and applies it (shadow RAM, same-value skip, dirty + // flags) before the next executable method, as CallMethod would have at once. The + // macro's end consumes the sink too (FinishFrontMacro). split.Method(this, method, argument, is_last_call, nullptr); } } From fdc8231afa06f90ea5c0aa1e9f1894e6cf863256 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:37:58 -0500 Subject: [PATCH 115/154] maxwell3d: check for a deferred draw once per sink, not per sunk write ConsumeSinkImpl applied every sunk state write through ProcessDirtyRegisters, which first asks the draw manager whether an instanced draw is deferred. Only draw methods make one, and none runs while the sink is consumed, so the answer is the same for the whole sink: ask once, and apply the writes (same value skip, register store, dirty flags) in place when there is none. The per-write path with the check is kept for the rare consume with a deferred draw. No behaviour change. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 05cef9b029..c70c65d317 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -319,12 +319,22 @@ u32 Maxwell3D::ProcessShadowRam(u32 method, u32 argument) { void Maxwell3D::ConsumeSinkImpl(Core::System& system) { const auto control = shadow_state.shadow_ram_control; // Sunk methods are state writes only (not in execution_mask): skip unchanged values, as - // CallMethod does. + // CallMethod does. A deferred instanced draw is issued before the first write that is not + // part of its loop (ProcessDirtyRegisters); without one when the sink is consumed, none can + // appear while it is (only draw methods make one), so the check is made once. const bool skip_same = SkipSameValueWrites(); + const bool deferred_draw = draw_manager.HasDeferredDraw(); const auto process = [&](u32 method, u32 value) { - if (!skip_same || regs.reg_array[method] != value) { + if (skip_same && regs.reg_array[method] == value) { + return; + } + if (deferred_draw) [[unlikely]] { ProcessDirtyRegisters(method, value); + return; } + regs.reg_array[method] = value; + for (auto const& table : dirty.tables) + dirty.flags[table[method]] = true; }; if (control == Regs::ShadowRamControl::Track || control == Regs::ShadowRamControl::TrackWithFilter) { for (auto [method, value] : method_sink) { From 83e8c367b70d0df22e5613804fee07bad430cb3a Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:37:58 -0500 Subject: [PATCH 116/154] nx2:profile: back entry counts, uniform buffer and update zones Counters on the back's line: ring method entries put in the method sink, run through CallMethod, and deferred calls run, to attribute its time outside the zones ("other"). New zones to split "bind stage buffers" and "update buffers": small uniform buffers compared with their last stream copy ("ubo reuse", with the hits counted), copied into the stream buffer ("ubo copy", with the bytes), and bound from the buffer cache ("ubo cached"); the index buffer, vertex buffer and per-stage parts of UpdateGraphicsBuffers. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/buffer_cache/buffer_cache.h | 24 +++++++++++++++++----- src/video_core/gpu_split.cpp | 4 ++++ src/video_core/nx2_profile.h | 21 ++++++++++++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 32006be03a..c7a2d3e5b8 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -1211,17 +1211,24 @@ void BufferCache

::BindHostGraphicsUniformBuffer(size_t stage, u32 index, u32 // stream allocation again instead of allocating and copying (uniform_stream_reuse.h). // EDEN_NX2_UBOREUSE=0 turns it off. if (VideoCommon::UniformStreamReuseEnabled()) { + Nx2Profile::Scope scope{Nx2Profile::Zone::UboReuse}; streamed = VideoCommon::BindSmallUniformReusing( runtime, device_memory, stage, index, binding_index, device_addr, size); + if (streamed) { + Nx2Profile::Add(Nx2Profile::Counter::UboReuseHits, 1); + } } } if (!streamed) { + Nx2Profile::Scope scope{Nx2Profile::Zone::UboCopy}; + Nx2Profile::Add(Nx2Profile::Counter::UboCopyBytes, size); const std::span span = runtime.BindMappedUniformBuffer(stage, binding_index, size); device_memory.ReadBlockUnsafe(device_addr, span.data(), size); } return; } // Classic cached path + Nx2Profile::Scope cached_scope{Nx2Profile::Zone::UboCached}; if (SynchronizeBuffer(buffer, device_addr, size)) { ++channel_state->uniform_cache_hits[0]; } @@ -1541,14 +1548,21 @@ void BufferCache

::DoUpdateGraphicsBuffers(bool is_indexed) { graphics_segments.clear(); BufferOperations([&]() { if (is_indexed) { + Nx2Profile::Scope scope{Nx2Profile::Zone::UpdateIndex}; UpdateIndexBuffer(); } - UpdateVertexBuffers(); + { + Nx2Profile::Scope scope{Nx2Profile::Zone::UpdateVertex}; + UpdateVertexBuffers(); + } UpdateTransformFeedbackBuffers(); - for (size_t stage = 0; stage < NUM_STAGES; ++stage) { - UpdateUniformBuffers(stage); - UpdateStorageBuffers(stage); - UpdateTextureBuffers(stage); + { + Nx2Profile::Scope scope{Nx2Profile::Zone::UpdateStages}; + for (size_t stage = 0; stage < NUM_STAGES; ++stage) { + UpdateUniformBuffers(stage); + UpdateStorageBuffers(stage); + UpdateTextureBuffers(stage); + } } if (current_draw_indirect) { UpdateDrawIndirect(); diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 54299277a7..ca9b399ebb 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -456,12 +456,15 @@ size_t GpuSplit::Execute(const u64* entry) { auto* const engine = back_engine; if ((head & Direct) != 0) { Nx2Profile::Scope method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); engine->ConsumeSink(*system); engine->CallMethod(*system, method, argument, true); } else if (!engine->execution_mask[method]) { + Nx2Profile::Add(Nx2Profile::Counter::BackSinkWrites, 1); engine->method_sink.emplace_back(method, argument); } else { Nx2Profile::Scope method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); engine->ConsumeSink(*system); if ((head & HasSegment) != 0) { engine->current_dma_segment = entry[1]; @@ -486,6 +489,7 @@ size_t GpuSplit::Execute(const u64* entry) { } case Kind::Call: { const size_t payload = static_cast((head >> 8) & 0xffffff); + Nx2Profile::Add(Nx2Profile::Counter::BackDeferredCalls, 1); reinterpret_cast(entry[1])(entry + 2); return 2 + payload; } diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h index f81bcb0fa0..3fb979097f 100644 --- a/src/video_core/nx2_profile.h +++ b/src/video_core/nx2_profile.h @@ -54,6 +54,12 @@ enum class Zone : u32 { SplitWait, ///< Split GPU thread: the back waits for work SplitWriters, ///< Split GPU thread: the front waits for queued compute/copy work (WaitForWriters) SplitDmaRead, ///< Split GPU thread: the front drains the back for a macro DMA read + UpdateIndex, ///< Of UpdateBuffers: the index buffer + UpdateVertex, ///< Of UpdateBuffers: the vertex buffers + UpdateStages, ///< Of UpdateBuffers: uniform, storage and texture buffers of every stage + UboReuse, ///< Of BindStageBuffers: small uniform buffers compared with their last stream copy + UboCopy, ///< Of BindStageBuffers: small uniform buffers copied into the stream buffer + UboCached, ///< Of BindStageBuffers: uniform buffers bound from the buffer cache Count, }; @@ -65,6 +71,7 @@ inline constexpr std::array(Zone::Count)> zone_ "descriptor acquire", "bind stage buffers", "push descriptors", "render targets", "configure draw", "dynamic state", "draw record", "texture upload", "fence", "fence skipped", "method", "split drain", "split wait", "split writers", "split dma read", + "update index", "update vertex", "update stages", "ubo reuse", "ubo copy", "ubo cached", }; // Set at startup, or by System::Load when a gpu_profile file is in the user directory (Android @@ -84,7 +91,7 @@ inline u64 Now() { struct ThreadData { std::array(Zone::Count)> exclusive_ns{}; std::array(Zone::Count)> count{}; - std::array counters{}; ///< See Counter + std::array counters{}; ///< See Counter u64 child_ns{}; ///< Time of the zones nested in the current one u64 window_start{}; }; @@ -139,6 +146,11 @@ enum class Counter : u32 { GpfifoEntries, ///< Command list (GPFIFO) entries started AcquireLedger, ///< Of AcquireChecks: met without draining the back (semaphore ledger) JitSkippedWrites, ///< Of SameValueWrites: elided inside the ARM64 macro JIT + BackSinkWrites, ///< Back thread: ring method entries put in the engine's method sink + BackMethodCalls, ///< Back thread: ring method entries run through CallMethod (executable, direct) + BackDeferredCalls, ///< Back thread: deferred calls run (Defer) + UboReuseHits, ///< Small uniform buffers bound to their last stream copy (bytes equal) + UboCopyBytes, ///< Bytes of small uniform buffers copied into the stream buffer }; /// Device pages (4 KiB) the native MME has DMA-read since start (GPU thread only; filled only @@ -205,6 +217,13 @@ inline void Report(const char* thread_name = "GPU thread") { static_cast(data.counters[6]) * 1e9 / window, static_cast(data.counters[7]) * 1e9 / window, static_cast(data.counters[11]) * 1e9 / window); + line += fmt::format(" | back entries: {:.0f} sunk, {:.0f} called, {:.0f} deferred calls", + static_cast(data.counters[13]) * 1e9 / window, + static_cast(data.counters[14]) * 1e9 / window, + static_cast(data.counters[15]) * 1e9 / window); + line += fmt::format(" | small UBOs: {:.0f} reused, {:.0f} KiB copied", + static_cast(data.counters[16]) * 1e9 / window, + static_cast(data.counters[17]) * 1e9 / 1024.0 / window); LOG_INFO(HW_GPU, "{} per second:{}", thread_name, line); data = {}; data.window_start = now; From dbf8f839ecdc630f0c98a855557cf6a7fb134780 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:55:51 -0500 Subject: [PATCH 117/154] nx2:profile: method histogram, sampled hot zones, dispatch and batch counters The back's line now lists the ten engine methods it called most (by method number, per second), to see what its executable calls are. The zones entered millions of times a second ("method" and the small uniform buffer ones) are sampled: every call counted, one in 16 (8 for the UBOs) timed and scaled, so their clock reads stop distorting the profile. New counters: chunks dispatched to the Vulkan worker and how many of them woke it, submits from FlushWork (with a "flush submit" zone), and constant buffer words batched by the change that follows. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/buffer_cache/buffer_cache.h | 6 +- src/video_core/gpu_split.cpp | 9 +- src/video_core/nx2_profile.h | 101 ++++++++++++++++++ .../renderer_vulkan/vk_rasterizer.cpp | 2 + .../renderer_vulkan/vk_scheduler.cpp | 3 + 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index c7a2d3e5b8..6539174e45 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -1211,7 +1211,7 @@ void BufferCache

::BindHostGraphicsUniformBuffer(size_t stage, u32 index, u32 // stream allocation again instead of allocating and copying (uniform_stream_reuse.h). // EDEN_NX2_UBOREUSE=0 turns it off. if (VideoCommon::UniformStreamReuseEnabled()) { - Nx2Profile::Scope scope{Nx2Profile::Zone::UboReuse}; + Nx2Profile::SampledScope<3> scope{Nx2Profile::Zone::UboReuse}; streamed = VideoCommon::BindSmallUniformReusing( runtime, device_memory, stage, index, binding_index, device_addr, size); if (streamed) { @@ -1220,7 +1220,7 @@ void BufferCache

::BindHostGraphicsUniformBuffer(size_t stage, u32 index, u32 } } if (!streamed) { - Nx2Profile::Scope scope{Nx2Profile::Zone::UboCopy}; + Nx2Profile::SampledScope<3> scope{Nx2Profile::Zone::UboCopy}; Nx2Profile::Add(Nx2Profile::Counter::UboCopyBytes, size); const std::span span = runtime.BindMappedUniformBuffer(stage, binding_index, size); device_memory.ReadBlockUnsafe(device_addr, span.data(), size); @@ -1228,7 +1228,7 @@ void BufferCache

::BindHostGraphicsUniformBuffer(size_t stage, u32 index, u32 return; } // Classic cached path - Nx2Profile::Scope cached_scope{Nx2Profile::Zone::UboCached}; + Nx2Profile::SampledScope<3> cached_scope{Nx2Profile::Zone::UboCached}; if (SynchronizeBuffer(buffer, device_addr, size)) { ++channel_state->uniform_cache_hits[0]; } diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index ca9b399ebb..df64fc7791 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -455,7 +455,7 @@ size_t GpuSplit::Execute(const u64* entry) { const u32 argument = static_cast(head >> 32); auto* const engine = back_engine; if ((head & Direct) != 0) { - Nx2Profile::Scope method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); engine->ConsumeSink(*system); engine->CallMethod(*system, method, argument, true); @@ -463,7 +463,7 @@ size_t GpuSplit::Execute(const u64* entry) { Nx2Profile::Add(Nx2Profile::Counter::BackSinkWrites, 1); engine->method_sink.emplace_back(method, argument); } else { - Nx2Profile::Scope method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); engine->ConsumeSink(*system); if ((head & HasSegment) != 0) { @@ -471,6 +471,7 @@ size_t GpuSplit::Execute(const u64* entry) { } engine->CallMethod(*system, method, argument, (head & IsLast) != 0); } + Nx2Profile::CountMethod(method); return (head & HasSegment) != 0 ? 2 : 1; } case Kind::Multi: { @@ -478,7 +479,8 @@ size_t GpuSplit::Execute(const u64* entry) { const u32 amount = static_cast(head >> 32); const size_t data = (head & HasSegment) != 0 ? 3 : 2; auto* const engine = back_engine; - Nx2Profile::Scope method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::CountMethod(method); engine->ConsumeSink(*system); if ((head & HasSegment) != 0) { engine->current_dma_segment = entry[2]; @@ -491,6 +493,7 @@ size_t GpuSplit::Execute(const u64* entry) { const size_t payload = static_cast((head >> 8) & 0xffffff); Nx2Profile::Add(Nx2Profile::Counter::BackDeferredCalls, 1); reinterpret_cast(entry[1])(entry + 2); + Nx2Profile::CountMethod(method, amount); return 2 + payload; } } diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h index 3fb979097f..2b50c89081 100644 --- a/src/video_core/nx2_profile.h +++ b/src/video_core/nx2_profile.h @@ -7,8 +7,10 @@ #include #include #include +#include #include #include +#include #include "common/common_types.h" #include "common/logging.h" @@ -60,6 +62,7 @@ enum class Zone : u32 { UboReuse, ///< Of BindStageBuffers: small uniform buffers compared with their last stream copy UboCopy, ///< Of BindStageBuffers: small uniform buffers copied into the stream buffer UboCached, ///< Of BindStageBuffers: uniform buffers bound from the buffer cache + FlushSubmit, ///< Of FlushWork: the scheduler submits (Flush) Count, }; @@ -72,6 +75,7 @@ inline constexpr std::array(Zone::Count)> zone_ "configure draw", "dynamic state", "draw record", "texture upload", "fence", "fence skipped", "method", "split drain", "split wait", "split writers", "split dma read", "update index", "update vertex", "update stages", "ubo reuse", "ubo copy", "ubo cached", + "flush submit", }; // Set at startup, or by System::Load when a gpu_profile file is in the user directory (Android @@ -94,6 +98,9 @@ struct ThreadData { std::array counters{}; ///< See Counter u64 child_ns{}; ///< Time of the zones nested in the current one u64 window_start{}; + u64 sample_tick{}; ///< SampledScope: which constructions time themselves + /// Calls of each engine method the back ran (CountMethod), by method number. + std::unique_ptr> method_calls; }; inline thread_local ThreadData t_data; @@ -131,6 +138,61 @@ class Scope { u64 parent_child_ns{}; }; +/// A zone entered millions of times a second: every call is counted, one in 2^Shift is timed +/// and its time scaled up, so the clock reads stop distorting what they measure. While not +/// timing, the scope is as if absent (nested zones count against the enclosing one). +template +class SampledScope { +public: + explicit SampledScope(Zone zone_) : zone{zone_}, enabled{Enabled()} { + if (!enabled) { + return; + } + ++t_data.count[static_cast(zone)]; + timing = (t_data.sample_tick++ & ((u64{1} << Shift) - 1)) == 0; + if (!timing) { + return; + } + parent_child_ns = t_data.child_ns; + t_data.child_ns = 0; + start = Now(); + } + + ~SampledScope() { + if (!enabled || !timing) { + return; + } + const u64 elapsed = Now() - start; + auto& data = t_data; + const u64 own = elapsed > data.child_ns ? elapsed - data.child_ns : 0; + data.exclusive_ns[static_cast(zone)] += own << Shift; + data.child_ns = parent_child_ns + elapsed; + } + + SampledScope(const SampledScope&) = delete; + SampledScope& operator=(const SampledScope&) = delete; + +private: + Zone zone; + bool enabled; + bool timing{}; + u64 start{}; + u64 parent_child_ns{}; +}; + +/// An engine method the back ran (profiling only): the report lists the most called. +inline void CountMethod(u32 method, u32 amount = 1) { + if (!Enabled()) { + return; + } + auto& calls = t_data.method_calls; + if (!calls) { + calls = std::make_unique>(); + calls->fill(0); + } + (*calls)[method & 0xffff] += amount; +} + /// Amounts counted on the GPU thread, logged per second with the zones. enum class Counter : u32 { DownloadBytes, ///< Buffer data copied back from the host GPU at fences @@ -151,6 +213,11 @@ enum class Counter : u32 { BackDeferredCalls, ///< Back thread: deferred calls run (Defer) UboReuseHits, ///< Small uniform buffers bound to their last stream copy (bytes equal) UboCopyBytes, ///< Bytes of small uniform buffers copied into the stream buffer + Dispatches, ///< Recorded chunks handed to the Vulkan worker (Scheduler::DispatchWork) + WorkerWakes, ///< Of Dispatches: the worker was asleep and was woken + Submits, ///< Scheduler::Flush from FlushWork + CbBatchWords, ///< Constant buffer words front macros sent batched (CB_DATA) + CbBatchEntries, ///< Ring entries those words went in }; /// Device pages (4 KiB) the native MME has DMA-read since start (GPU thread only; filled only @@ -224,8 +291,42 @@ inline void Report(const char* thread_name = "GPU thread") { line += fmt::format(" | small UBOs: {:.0f} reused, {:.0f} KiB copied", static_cast(data.counters[16]) * 1e9 / window, static_cast(data.counters[17]) * 1e9 / 1024.0 / window); + line += fmt::format(" | dispatches {:.0f} ({:.0f} wakes), submits {:.0f} | CB words batched {:.0f} in {:.0f} entries", + static_cast(data.counters[18]) * 1e9 / window, + static_cast(data.counters[19]) * 1e9 / window, + static_cast(data.counters[20]) * 1e9 / window, + static_cast(data.counters[21]) * 1e9 / window, + static_cast(data.counters[22]) * 1e9 / window); + if (data.method_calls) { + // The ten most called engine methods (per second). + std::array, 10> top{}; + for (u32 method = 0; method < 0x10000; ++method) { + const u32 calls = (*data.method_calls)[method]; + if (calls <= top.back().second) { + continue; + } + size_t at = top.size() - 1; + while (at > 0 && top[at - 1].second < calls) { + top[at] = top[at - 1]; + --at; + } + top[at] = {method, calls}; + } + line += " | methods called:"; + for (const auto& [method, calls] : top) { + if (calls == 0) { + break; + } + line += fmt::format(" {:#x} {:.0f}", method, static_cast(calls) * 1e9 / window); + } + } LOG_INFO(HW_GPU, "{} per second:{}", thread_name, line); + auto method_calls = std::move(data.method_calls); data = {}; + if (method_calls) { + method_calls->fill(0); + data.method_calls = std::move(method_calls); + } data.window_start = now; } diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index d6a30e67ef..52272c57f9 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1611,6 +1611,8 @@ bool AccelerateDMA::BufferClear(GPUVAddr src_address, u64 amount, u32 value) { return buffer_cache.DMAClear(src_address, amount, value); } + VideoCommon::Nx2Profile::Scope submit_scope{VideoCommon::Nx2Profile::Zone::FlushSubmit}; + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::Submits, 1); bool AccelerateDMA::BufferCopy(GPUVAddr src_address, GPUVAddr dest_address, u64 amount) { std::scoped_lock lock{buffer_cache.mutex}; buffer_cache.Nx2CollectListCopies(true); diff --git a/src/video_core/renderer_vulkan/vk_scheduler.cpp b/src/video_core/renderer_vulkan/vk_scheduler.cpp index ccb34ace48..913d478a2b 100644 --- a/src/video_core/renderer_vulkan/vk_scheduler.cpp +++ b/src/video_core/renderer_vulkan/vk_scheduler.cpp @@ -22,6 +22,7 @@ #include "video_core/renderer_vulkan/vk_command_pool.h" #include "video_core/renderer_vulkan/vk_graphics_pipeline.h" #include "video_core/renderer_vulkan/vk_master_semaphore.h" +#include "video_core/nx2_profile.h" #include "video_core/renderer_vulkan/vk_scheduler.h" #include "video_core/renderer_vulkan/vk_state_tracker.h" #include "video_core/renderer_vulkan/vk_texture_cache.h" @@ -160,7 +161,9 @@ void Scheduler::DispatchWork() { work_queue.push(std::move(chunk)); wake = worker_waiting; } + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::Dispatches, 1); if (wake) { + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::WorkerWakes, 1); event_cv.notify_all(); } AcquireNewChunk(); From f363208284d61fedfdbf4e374073e78a83f7b2bb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:55:52 -0500 Subject: [PATCH 118/154] nx2:split: send a front macro's constant buffer data as one entry per run Each constant buffer data word (CB_DATA) a front macro wrote went to the back as its own entry and a full CallMethod (ProcessCBData: a one-word ProcessCBMultiData). The pushbuffer's non-incrementing runs already go as one multi-method entry and ProcessCBMultiData, which uploads the same bytes at the same offsets and, unlike the word-by-word path, touches neither the data register nor its shadow copy. A macro's CB_DATA words now queue on the front (GpuSplit::QueueWord) and go as one multi-method entry per run, flushed before anything else enters the ring (Reserve, Publish, Drain, WaitForWriters), so the order is kept. The front mirrors what the back does for such a run: the constant buffer offset advances, the data register and shadow word do not, so SPLIT_VERIFY compares as before. Replay mode keeps the word-by-word path (a written word is then the shadow RAM's). EDEN_NX2_CB_BATCH=0 sends the words one by one as before. Expected: the back's "called" entries fall by the CB_DATA words, "CB words batched" shows them and the entries they went in. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 25 +++++++++++++++++++++++++ src/video_core/gpu_split.cpp | 14 ++++++++++++++ src/video_core/gpu_split.h | 25 +++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index c70c65d317..184657e7e6 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -927,6 +927,16 @@ bool JitSkipSameEnabled() { return skip; } +// EDEN_NX2_CB_BATCH=0: a front macro's constant buffer data words (CB_DATA) go to the back one +// by one (CallMethod each) instead of as one multi-method entry per run. +bool CbBatchEnabled() { + static const bool batch = [] { + const char* value = std::getenv("EDEN_NX2_CB_BATCH"); + return value == nullptr || value[0] != '0'; + }(); + return batch; +} + // EDEN_NX2_MACRO_SINK=0: the back runs every write a front macro makes through CallMethod at // once (GpuSplit::MacroWrite), instead of putting state writes in its method sink like the // pushbuffer's and applying them before the next executable method. @@ -979,6 +989,21 @@ Maxwell3D::FrontState& Maxwell3D::Front(Core::System& system, VideoCommon::GpuSp void Maxwell3D::FrontWrite(VideoCommon::GpuSplit& split, FrontState& fs, u32 method, u32 argument, bool is_last_call, bool from_macro) { namespace Profile = VideoCommon::Nx2Profile; + // A macro's constant buffer data: a run of words goes as one multi-method entry, which the + // back uploads with ProcessCBMultiData as it does the pushbuffer's runs: the same bytes at + // the same offsets, without touching the data register or its shadow copy (nor does the + // front then). Not in replay mode, where a word written is the shadow RAM's. + constexpr u32 cb_data_first = MAXWELL3D_REG_INDEX(const_buffer.buffer); + if (from_macro && method >= cb_data_first && method < cb_data_first + 16 && + fs.control != Regs::ShadowRamControl::Replay && CbBatchEnabled()) { + if (Profile::Enabled()) [[unlikely]] { + Profile::Add(Profile::Counter::MethodWrites, 1); + Profile::Add(Profile::Counter::MacroWrites, 1); + } + fs.regs[MAXWELL3D_REG_INDEX(const_buffer.offset)] += sizeof(u32); + split.QueueWord(this, cb_data_first, argument); + return; + } // As ProcessShadowRam does on the back. u32 value = argument; bool shadow_kept = true; diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index df64fc7791..3170a7a254 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -317,7 +317,19 @@ void GpuSplit::SleepOnBack() { front_waiting.store(false, std::memory_order_relaxed); } +void GpuSplit::FlushPending() { + const u32 count = pending_count; + if (count == 0) { + return; + } + pending_count = 0; + Nx2Profile::Add(Nx2Profile::Counter::CbBatchWords, count); + Nx2Profile::Add(Nx2Profile::Counter::CbBatchEntries, 1); + MultiMethod(pending_engine, pending_method, pending_words.data(), count, count, nullptr); +} + void GpuSplit::Publish() { + FlushPending(); if (published_local == write_position) { return; } @@ -335,6 +347,7 @@ void GpuSplit::Drain() { } void GpuSplit::Drain(Nx2Profile::Zone zone) { + FlushPending(); consumed_cache = consumed.load(std::memory_order_acquire); if (consumed_cache == write_position) { return; @@ -369,6 +382,7 @@ void GpuSplit::Drain(Nx2Profile::Zone zone) { } bool GpuSplit::WaitForWriters() { + FlushPending(); const u64 target = std::min(last_writer_position, write_position); consumed_cache = consumed.load(std::memory_order_acquire); if (consumed_cache >= target) { diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index a58e04901a..77dc77a30d 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include #include @@ -88,6 +89,21 @@ class GpuSplit { } void MultiMethod(Tegra::Engines::EngineInterface* engine, u32 method, const u32* words, u32 amount, u32 methods_pending, const GPUVAddr* segment); + /// A word of a run of one engine method (a macro's constant buffer data): goes with the + /// words queued before it as one multi-method entry, sent before anything else enters the + /// ring (Reserve, Publish, Drain and WaitForWriters flush it), so the order is kept. + void QueueWord(Tegra::Engines::EngineInterface* engine, u32 method, u32 word) { + if (pending_count != 0 && (engine != pending_engine || method != pending_method || + pending_count == PendingWords)) [[unlikely]] { + FlushPending(); + } + if (pending_count == 0) { + pending_engine = engine; + pending_method = method; + } + pending_words[pending_count++] = word; + } + void FlushPending(); /// A write made by a macro: CallMethod without the method sink, as the engines' macros do. void MacroWrite(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument) { SelectEngine(engine); @@ -165,6 +181,9 @@ class GpuSplit { /// Room for an entry of `words` at the write position (never across the ring's end). u64* Reserve(size_t words) { + if (pending_count != 0) [[unlikely]] { + FlushPending(); + } const size_t index = write_position & RingMask; if (index + words <= RingWords && write_position + words - consumed_cache <= RingWords) [[likely]] { @@ -207,6 +226,12 @@ class GpuSplit { /// Words between publishes: small right after a drain, while the back has nothing to do. size_t publish_every{PublishEvery}; Tegra::Engines::EngineInterface* front_engine{}; + /// QueueWord: a run not yet sent. + static constexpr u32 PendingWords = 256; + std::array pending_words{}; + u32 pending_count{}; + u32 pending_method{}; + Tegra::Engines::EngineInterface* pending_engine{}; // Back side. Tegra::Engines::EngineInterface* back_engine{}; From bf7af93cb643574e382a9c4a8b85067873e74e83 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:55:52 -0500 Subject: [PATCH 119/154] buffer_cache: compare a small uniform buffer's bytes in place BindSmallUniformReusing copied the guest bytes to a scratch buffer and compared the copy with the slot's last stream copy; in Mario Kart World every one of ~500,000 compares a second is equal and nothing is copied after it. Compare the guest bytes where they are when the range is contiguous in host memory (DeviceMemoryManager::GetSpan), falling back to the copy otherwise. Same bytes read, same result; one pass over them instead of two. EDEN_NX2_UBO_DIRECT=0 copies first as before. Expected: "ubo reuse" falls. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../buffer_cache/uniform_stream_reuse.h | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/video_core/buffer_cache/uniform_stream_reuse.h b/src/video_core/buffer_cache/uniform_stream_reuse.h index 7ba31c4717..a3e23d4c69 100644 --- a/src/video_core/buffer_cache/uniform_stream_reuse.h +++ b/src/video_core/buffer_cache/uniform_stream_reuse.h @@ -97,23 +97,34 @@ template } Reuse& reuse = UniformStreamReuseState(); typename Reuse::Slot& slot = reuse.slots[stage][index]; - u8* const scratch = reuse.scratch.data(); - device_memory.ReadBlockUnsafe(device_addr, scratch, size); + // The guest bytes are compared in place when they are contiguous in host memory (one + // read instead of a copy and a read; nearly every bind compares equal); otherwise through + // the scratch copy. EDEN_NX2_UBO_DIRECT=0 always copies first. + static const bool direct = [] { + const char* value = std::getenv("EDEN_NX2_UBO_DIRECT"); + return value == nullptr || value[0] != '0'; + }(); + const u8* bytes = direct ? device_memory.GetSpan(device_addr, size) : nullptr; + if (bytes == nullptr) { + u8* const scratch = reuse.scratch.data(); + device_memory.ReadBlockUnsafe(device_addr, scratch, size); + bytes = scratch; + } const bool same_bytes = - slot.size == size && std::memcmp(slot.bytes.data(), scratch, size) == 0; + slot.size == size && std::memcmp(slot.bytes.data(), bytes, size) == 0; if (same_bytes && slot.tick == runtime.CurrentTick()) { runtime.RebindStreamUniform(slot.ref, size); return true; } const std::span span = runtime.BindMappedUniformBuffer(stage, binding_index, size); - std::memcpy(span.data(), scratch, size); + std::memcpy(span.data(), bytes, 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); + std::memcpy(slot.bytes.data(), bytes, size); } return true; } From 9411540a4fc866251ec83de91c36655a02c02ca5 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:55:52 -0500 Subject: [PATCH 120/154] vulkan: EDEN_NX2_DISPATCH_DRAWS sets the draws between worker dispatches "flush work" costs ~1 us per draw on Android: not the per-draw check, which returns at once 31 times in 32, but the dispatch every 32 draws (a chunk hand-over and, when the worker sleeps, a wake-up). The interval is a setting now (a power of two, 1-1024; 32 stays the default) so it can be measured against the profile's dispatch and wake counts; it changes when recorded commands reach the worker, not what is recorded. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../renderer_vulkan/vk_rasterizer.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 52272c57f9..1543b2697a 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1574,7 +1574,20 @@ void RasterizerVulkan::FlushWork() { #endif // __ANDROID__ static_assert(DRAWS_TO_DISPATCH % (CHECK_MASK + 1) == 0); - if ((++draw_counter & CHECK_MASK) != 0) { + // EDEN_NX2_DISPATCH_DRAWS=: draws between dispatches to the worker + // (each is a chunk hand-over and, when it sleeps, a wake-up). + static const u32 check_mask = [] { + const char* value = std::getenv("EDEN_NX2_DISPATCH_DRAWS"); + if (value == nullptr || value[0] < '0' || value[0] > '9') { + return CHECK_MASK; + } + const unsigned long parsed = std::strtoul(value, nullptr, 10); + if (parsed == 0 || parsed > 1024 || (parsed & (parsed - 1)) != 0) { + return CHECK_MASK; + } + return static_cast(parsed - 1); + }(); + if ((++draw_counter & check_mask) != 0) { return; } // Keep the host GPU working while native commands are still being recorded. @@ -1598,6 +1611,8 @@ void RasterizerVulkan::FlushWork() { scheduler.DispatchWork(); return; } + VideoCommon::Nx2Profile::Scope submit_scope{VideoCommon::Nx2Profile::Zone::FlushSubmit}; + VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::Submits, 1); scheduler.Flush(); draw_counter = 0; } @@ -1611,8 +1626,6 @@ bool AccelerateDMA::BufferClear(GPUVAddr src_address, u64 amount, u32 value) { return buffer_cache.DMAClear(src_address, amount, value); } - VideoCommon::Nx2Profile::Scope submit_scope{VideoCommon::Nx2Profile::Zone::FlushSubmit}; - VideoCommon::Nx2Profile::Add(VideoCommon::Nx2Profile::Counter::Submits, 1); bool AccelerateDMA::BufferCopy(GPUVAddr src_address, GPUVAddr dest_address, u64 amount) { std::scoped_lock lock{buffer_cache.mutex}; buffer_cache.Nx2CollectListCopies(true); From b9834e4bb5c72646bd586f764f8f029f3aeb9b13 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:56:19 -0500 Subject: [PATCH 121/154] nx2:profile: count the direct and executable method calls in the histogram The two CallMethod paths of GpuSplit::Execute were left out of the method histogram the previous change added; only multi-method entries were counted. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/gpu_split.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 3170a7a254..2c28e7c8ec 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -471,6 +471,7 @@ size_t GpuSplit::Execute(const u64* entry) { if ((head & Direct) != 0) { Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); + Nx2Profile::CountMethod(method); engine->ConsumeSink(*system); engine->CallMethod(*system, method, argument, true); } else if (!engine->execution_mask[method]) { @@ -479,22 +480,22 @@ size_t GpuSplit::Execute(const u64* entry) { } else { Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; Nx2Profile::Add(Nx2Profile::Counter::BackMethodCalls, 1); + Nx2Profile::CountMethod(method); engine->ConsumeSink(*system); if ((head & HasSegment) != 0) { engine->current_dma_segment = entry[1]; } engine->CallMethod(*system, method, argument, (head & IsLast) != 0); } - Nx2Profile::CountMethod(method); return (head & HasSegment) != 0 ? 2 : 1; } case Kind::Multi: { const u32 method = static_cast((head >> 8) & 0xffff); const u32 amount = static_cast(head >> 32); const size_t data = (head & HasSegment) != 0 ? 3 : 2; + Nx2Profile::CountMethod(method, amount); auto* const engine = back_engine; Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; - Nx2Profile::CountMethod(method); engine->ConsumeSink(*system); if ((head & HasSegment) != 0) { engine->current_dma_segment = entry[2]; @@ -507,7 +508,6 @@ size_t GpuSplit::Execute(const u64* entry) { const size_t payload = static_cast((head >> 8) & 0xffffff); Nx2Profile::Add(Nx2Profile::Counter::BackDeferredCalls, 1); reinterpret_cast(entry[1])(entry + 2); - Nx2Profile::CountMethod(method, amount); return 2 + payload; } } From 4f6560f5bf27276bc33be25ffe9ae762c0fafbc0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:14:07 -0500 Subject: [PATCH 122/154] nx2:split: send a pushbuffer constant buffer run as one entry The back's most called method was CB_DATA (0x8e4, ~940,000 a second in a Mario Kart World race) from the pushbuffer, not macros: short incrementing runs starting at the constant buffer offset register (offset, then one or a few data words), ten per draw, each word a method call on the back (ConsumeSink, CallMethod, a one-word upload). Such a run (within the offset and data registers, at most 32 words, whole in the entry) now goes as one ConstantRun entry. The back applies the offset write through CallMethod (shadow RAM, same-value skip, dirty flags), then the data words' shadow RAM, register and dirty effects as CallMethod makes them, then one ProcessCBMultiData, which uploads the same bytes from the same offset and advances it the same as a ProcessCBData per word. The front mirrors the same registers (FrontLoadConstantRun), so SPLIT_VERIFY compares as before; shadow RAM replay mode keeps the word-by-word path. The entry counts in the method histogram as before; "CB runs" shows the words and entries, and the counter for the bind change that follows is declared here. EDEN_NX2_CB_BATCH_PB=0 turns it off. Expected: the back's "called" entries and 0x8e4 fall by the data words, "sunk" by the offset writes. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/dma_pusher.cpp | 32 +++++++++++++ src/video_core/engines/engine_interface.h | 9 ++++ src/video_core/engines/maxwell_3d.cpp | 56 +++++++++++++++++++++++ src/video_core/engines/maxwell_3d.h | 9 ++++ src/video_core/gpu_split.cpp | 24 ++++++++++ src/video_core/gpu_split.h | 5 ++ src/video_core/nx2_profile.h | 9 +++- 7 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 991617b882..2eb265fd32 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -307,6 +307,38 @@ size_t DmaPusher::ProcessCommands(std::span commands, size_ dma_state.is_last_call = true; index += max_write; } else if (dma_state.method_count) { + // A whole incrementing run within the 3D engine's constant buffer offset and data + // registers goes to the split's back as one entry (Maxwell3D::LoadConstantRun), not + // a method call per word. EDEN_NX2_CB_BATCH_PB=0 turns it off. + static const bool cb_runs = [] { + const char* value = std::getenv("EDEN_NX2_CB_BATCH_PB"); + return value == nullptr || value[0] != '0'; + }(); + using Engines::Maxwell3D; + constexpr u32 cb_offset = MAXWELL3D_REG_INDEX(const_buffer.offset); + constexpr u32 cb_last = MAXWELL3D_REG_INDEX(const_buffer.buffer) + 15; + if (cb_runs && !dma_state.non_incrementing && !dma_increment_once && + dma_state.method >= cb_offset && dma_state.method <= cb_last && + dma_state.method_count <= Engines::Maxwell3D::MaxConstantRun && + dma_state.method_count <= cb_last - dma_state.method + 1 && + index + dma_state.method_count <= commands.size() && g_semaphore_watch == 0 && + subchannel_type[dma_state.subchannel] == Engines::EngineTypes::Maxwell3D && + subchannels[dma_state.subchannel] != nullptr) { + auto& split = VideoCommon::GpuSplit::Get(); + auto* const maxwell3d = + static_cast(subchannels[dma_state.subchannel]); + if (split.Active() && split.FrontMacros() && + maxwell3d->GetNativeMacroEngine().native_turing && + maxwell3d->FrontLoadConstantRun(system, split, dma_state.method, + &commands[index].argument, + dma_state.method_count)) { + index += dma_state.method_count; + dma_state.method += dma_state.method_count; + dma_state.method_count = 0; + dma_state.is_last_call = true; + continue; + } + } // Fast-path batching for consecutive non-execution methods in increasing mode if (!dma_state.non_incrementing && !dma_increment_once && dma_state.method >= non_puller_methods && diff --git a/src/video_core/engines/engine_interface.h b/src/video_core/engines/engine_interface.h index 83dbf13059..fb73c584b6 100644 --- a/src/video_core/engines/engine_interface.h +++ b/src/video_core/engines/engine_interface.h @@ -37,6 +37,15 @@ class EngineInterface { /// Write multiple values to the register identified by method. virtual void CallMultiMethod(Core::System& system, u32 method, const u32* base_start, u32 amount, u32 methods_pending) = 0; + /// A run of `count` incrementing methods from `first_method`, each with its word, as the + /// pushbuffer would call them one by one (GpuSplit::ConstantRun). Engines with a cheaper + /// equivalent override it. + virtual void LoadConstantRun(Core::System& system, u32 first_method, const u32* words, u32 count) { + for (u32 i = 0; i < count; ++i) { + CallMethod(system, first_method + i, words[i], i + 1 == count); + } + } + void ConsumeSink(Core::System& system) { if (!method_sink.empty()) { ConsumeSinkImpl(system); diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 184657e7e6..0c6846cab5 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -1356,6 +1356,62 @@ void Maxwell3D::FrontVerify(VideoCommon::GpuSplit& split, FrontState& fs) { } } +bool Maxwell3D::FrontLoadConstantRun(Core::System& system, VideoCommon::GpuSplit& split, + u32 first_method, const u32* words, u32 count) { + namespace Profile = VideoCommon::Nx2Profile; + constexpr u32 cb_offset = MAXWELL3D_REG_INDEX(const_buffer.offset); + auto& fs = Front(system, split); + if (fs.control == Regs::ShadowRamControl::Replay) { + return false; // A word written is then the shadow RAM's: word by word (FrontWrite) + } + const bool tracked = fs.control == Regs::ShadowRamControl::Track || + fs.control == Regs::ShadowRamControl::TrackWithFilter; + // What FrontWrite mirrors for each word: the shadow RAM, the register, the offset the data + // words advance. The back makes the same changes in LoadConstantRun. + for (u32 i = 0; i < count; ++i) { + const u32 method = first_method + i; + const u32 word = words[i]; + if (tracked) { + fs.shadow[method] = word; + } + fs.regs[method] = word; + if (method != cb_offset) { + fs.regs[cb_offset] += sizeof(u32); + } + } + if (Profile::Enabled()) [[unlikely]] { + Profile::Add(Profile::Counter::MethodWrites, count); + } + split.ConstantRun(this, first_method, words, count); + return true; +} + +void Maxwell3D::LoadConstantRun(Core::System& system, u32 first_method, const u32* words, + u32 count) { + constexpr u32 cb_offset = MAXWELL3D_REG_INDEX(const_buffer.offset); + u32 index = 0; + if (first_method == cb_offset) { + // The offset: a state write, as CallMethod applies it (shadow RAM, same-value skip, + // dirty flags). + CallMethod(system, cb_offset, words[0], count == 1); + index = 1; + } + const u32 data_count = count - index; + if (data_count == 0) { + return; + } + // The data words: the shadow RAM, register and dirty flags per word as CallMethod makes + // them, then one upload from the offset (ProcessCBData per word advances it the same). + std::array values; + for (u32 i = 0; i < data_count; ++i) { + const u32 method = first_method + index + i; + const u32 value = ProcessShadowRam(method, words[index + i]); + ProcessDirtyRegisters(method, value); + values[i] = value; + } + ProcessCBMultiData(values.data(), data_count); +} + void Maxwell3D::FinishFrontMacro(Core::System& system) { // What ProcessMacro and CallMacroMethod do around the macro. ConsumeSink(system); diff --git a/src/video_core/engines/maxwell_3d.h b/src/video_core/engines/maxwell_3d.h index 7646ddbbe3..f1660db464 100644 --- a/src/video_core/engines/maxwell_3d.h +++ b/src/video_core/engines/maxwell_3d.h @@ -3202,6 +3202,15 @@ class Maxwell3D final : public EngineInterface { /// Write multiple values to the register identified by method. void CallMultiMethod(Core::System& system, u32 method, const u32* base_start, u32 amount, u32 methods_pending) override; + /// A pushbuffer run from the constant buffer offset or data registers: the offset write as + /// CallMethod makes it, the data words' register and shadow RAM effects, one upload. + void LoadConstantRun(Core::System& system, u32 first_method, const u32* words, u32 count) override; + /// Split front: mirrors such a run and sends it as one entry. False (nothing done) when it + /// must go word by word (shadow RAM replay mode). + bool FrontLoadConstantRun(Core::System& system, VideoCommon::GpuSplit& split, u32 first_method, + const u32* words, u32 count); + static constexpr u32 MaxConstantRun = 32; + /// GPU thread split with macros on its front half (gpu_split.h, EDEN_NX2_SPLIT=2), called by /// the front instead of CallMethod: runs macros and macro uploads against a copy of the /// registers and sends the register writes to the back thread. diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index 2c28e7c8ec..d171844449 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -448,6 +448,20 @@ void GpuSplit::MultiMethod(Tegra::Engines::EngineInterface* engine, u32 method, } } +void GpuSplit::ConstantRun(Tegra::Engines::EngineInterface* engine, u32 first_method, + const u32* words, u32 count) { + SelectEngine(engine); + const size_t size = 1 + (size_t{count} + 1) / 2; + u64* const entry = Reserve(size); + entry[0] = static_cast(Kind::ConstantRun) | (u64{first_method & 0xffff} << 8) | + (u64{count} << 32); + entry[size - 1] = 0; + std::memcpy(entry + 1, words, size_t{count} * sizeof(u32)); + Commit(size); + Nx2Profile::Add(Nx2Profile::Counter::CbRunWords, count); + Nx2Profile::Add(Nx2Profile::Counter::CbRunEntries, 1); +} + void GpuSplit::DeferFunction(std::function&& func) { auto* const heap = new std::function(std::move(func)); Defer([heap] { @@ -504,6 +518,16 @@ size_t GpuSplit::Execute(const u64* entry) { amount, static_cast(entry[1])); return data + (amount + 1) / 2; } + case Kind::ConstantRun: { + const u32 method = static_cast((head >> 8) & 0xffff); + const u32 count = static_cast(head >> 32); + auto* const engine = back_engine; + Nx2Profile::SampledScope<4> method_scope{Nx2Profile::Zone::Method}; + Nx2Profile::CountMethod(method, count); + engine->ConsumeSink(*system); + engine->LoadConstantRun(*system, method, reinterpret_cast(entry + 1), count); + return 1 + (size_t{count} + 1) / 2; + } case Kind::Call: { const size_t payload = static_cast((head >> 8) & 0xffffff); Nx2Profile::Add(Nx2Profile::Counter::BackDeferredCalls, 1); diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index 77dc77a30d..1cb8a7dd5e 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -104,6 +104,10 @@ class GpuSplit { pending_words[pending_count++] = word; } void FlushPending(); + /// A pushbuffer run of `count` incrementing methods from `first_method` within the 3D + /// engine's constant buffer offset and data registers (EngineInterface::LoadConstantRun). + void ConstantRun(Tegra::Engines::EngineInterface* engine, u32 first_method, const u32* words, + u32 count); /// A write made by a macro: CallMethod without the method sink, as the engines' macros do. void MacroWrite(Tegra::Engines::EngineInterface* engine, u32 method, u32 argument) { SelectEngine(engine); @@ -164,6 +168,7 @@ class GpuSplit { Method, ///< Bits 8-23 method, 32-63 argument; flags IsLast, HasSegment (word 1) Multi, ///< Bits 8-23 method, 32-63 amount; word 1 methods pending, [segment], words Call, ///< Bits 8-31 payload size in words; word 1 thunk, then the payload + ConstantRun, ///< Bits 8-23 first method, 32-63 count; then the words (two per u64) }; static constexpr u64 IsLast = 1 << 4; static constexpr u64 HasSegment = 1 << 5; diff --git a/src/video_core/nx2_profile.h b/src/video_core/nx2_profile.h index 2b50c89081..4a34559218 100644 --- a/src/video_core/nx2_profile.h +++ b/src/video_core/nx2_profile.h @@ -95,7 +95,7 @@ inline u64 Now() { struct ThreadData { std::array(Zone::Count)> exclusive_ns{}; std::array(Zone::Count)> count{}; - std::array counters{}; ///< See Counter + std::array counters{}; ///< See Counter u64 child_ns{}; ///< Time of the zones nested in the current one u64 window_start{}; u64 sample_tick{}; ///< SampledScope: which constructions time themselves @@ -218,6 +218,9 @@ enum class Counter : u32 { Submits, ///< Scheduler::Flush from FlushWork CbBatchWords, ///< Constant buffer words front macros sent batched (CB_DATA) CbBatchEntries, ///< Ring entries those words went in + CbRunWords, ///< Pushbuffer constant buffer runs (offset + data words) sent as one entry + CbRunEntries, ///< Those entries + CbBindsSkipped, ///< Constant buffer binds not sent: same slot, buffer and size as last time }; /// Device pages (4 KiB) the native MME has DMA-read since start (GPU thread only; filled only @@ -297,6 +300,10 @@ inline void Report(const char* thread_name = "GPU thread") { static_cast(data.counters[20]) * 1e9 / window, static_cast(data.counters[21]) * 1e9 / window, static_cast(data.counters[22]) * 1e9 / window); + line += fmt::format(" | CB runs {:.0f} words in {:.0f} entries, binds skipped {:.0f}", + static_cast(data.counters[23]) * 1e9 / window, + static_cast(data.counters[24]) * 1e9 / window, + static_cast(data.counters[25]) * 1e9 / window); if (data.method_calls) { // The ten most called engine methods (per second). std::array, 10> top{}; From 136fb401431b362b519679622da5188708531175 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:14:36 -0500 Subject: [PATCH 123/154] nx2:split: don't send a constant buffer bind that changes nothing bind_groups[0] and [4] (the vertex and fragment stages' constant buffer binds, 0x904 and 0x924) were ~400,000 of the back's method calls a second: every draw binds the same buffers again. ProcessCBBind sets the stage's slot from the bind group's configuration and the constant buffer size and address registers; with all four the same as at the last bind sent, it sets the binding the slot already holds (the buffer cache only drops the resolved buffer id, which it resolves again at the next draw). The front keeps those four words per bind group and does not send a bind equal to them; a bind group written through a multi-method entry (bound on the back word by word) forgets them. The registers and shadow RAM the front and back hold are unchanged (same values), so SPLIT_VERIFY compares as before. EDEN_NX2_CB_BIND_SKIP=0 sends every bind. Expected: "binds skipped" close to the 0x904/0x924 counts, which fall by it. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index 0c6846cab5..f8872e0a2a 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -865,6 +865,9 @@ struct Maxwell3D::FrontState { /// then can it have a deferred instanced draw to issue at the end of a macro. bool draw_pending{true}; bool skip_same{}; ///< SkipSameValueWrites() && FrontSkipSame(), read once + /// Per bind group: the raw config and constant buffer size/address of the last bind sent; + /// a bind equal to it changes nothing on the back (ProcessCBBind) and is not sent. + std::array, 5> last_bind{}; #if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) /// Methods whose emits the macro JIT must always call back for (TuringMacroJit::Elision): /// executable ones, the MME DMA reads (FrontDmaRead) and the macro range. @@ -927,6 +930,20 @@ bool JitSkipSameEnabled() { return skip; } +// EDEN_NX2_CB_BIND_SKIP=0: send a constant buffer bind even when its stage's bind group holds +// the same configuration, buffer address and size as the last bind sent (ProcessCBBind would +// set the same binding again). +bool CbBindSkipEnabled() { + static const bool skip = [] { + const char* value = std::getenv("EDEN_NX2_CB_BIND_SKIP"); + return value == nullptr || value[0] != '0'; + }(); + return skip; +} + +constexpr u32 BindGroupStride = MAXWELL3D_REG_INDEX(bind_groups[1].raw_config) - + MAXWELL3D_REG_INDEX(bind_groups[0].raw_config); + // EDEN_NX2_CB_BATCH=0: a front macro's constant buffer data words (CB_DATA) go to the back one // by one (CallMethod each) instead of as one multi-method entry per run. bool CbBatchEnabled() { @@ -970,6 +987,9 @@ Maxwell3D::FrontState& Maxwell3D::Front(Core::System& system, VideoCommon::GpuSp std::memcpy(front->shadow.data(), shadow_state.reg_array.data(), sizeof(front->shadow)); front->control = shadow_state.shadow_ram_control; front->skip_same = SkipSameValueWrites() && FrontSkipSame(); + for (auto& bind : front->last_bind) { + bind.fill(~u32{0}); + } #if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) for (u32 method = 0; method < front->jit_must_call.size(); ++method) { const bool dma_read = method == 0x157 || method == 0x158; @@ -1062,6 +1082,26 @@ void Maxwell3D::FrontWrite(VideoCommon::GpuSplit& split, FrontState& fs, u32 met case MAXWELL3D_REG_INDEX(falcon[4]): fs.regs[MAXWELL3D_REG_INDEX(shadow_scratch)] = 1; break; + case MAXWELL3D_REG_INDEX(bind_groups[0].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[1].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[2].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[3].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[4].raw_config): { + // ProcessCBBind sets the stage's slot from this configuration and the constant + // buffer registers: the same ones as last time set the binding it already has. + const size_t stage = (method - MAXWELL3D_REG_INDEX(bind_groups[0].raw_config)) / BindGroupStride; + const std::array bind{value, fs.regs[MAXWELL3D_REG_INDEX(const_buffer.size)], + fs.regs[MAXWELL3D_REG_INDEX(const_buffer.address_high)], + fs.regs[MAXWELL3D_REG_INDEX(const_buffer.address_low)]}; + if (fs.last_bind[stage] == bind && CbBindSkipEnabled()) { + if (Profile::Enabled()) [[unlikely]] { + Profile::Add(Profile::Counter::CbBindsSkipped, 1); + } + return; + } + fs.last_bind[stage] = bind; + break; + } default: if (method >= cb_data && method < cb_data + 16) { fs.regs[MAXWELL3D_REG_INDEX(const_buffer.offset)] += sizeof(u32); @@ -1175,6 +1215,15 @@ void Maxwell3D::FrontCallMultiMethod(Core::System& system, VideoCommon::GpuSplit case MAXWELL3D_REG_INDEX(falcon[4]): fs.regs[MAXWELL3D_REG_INDEX(shadow_scratch)] = 1; break; + case MAXWELL3D_REG_INDEX(bind_groups[0].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[1].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[2].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[3].raw_config): + case MAXWELL3D_REG_INDEX(bind_groups[4].raw_config): + // Bound on the back word by word, not through FrontWrite: forget the last bind. + fs.last_bind[(method - MAXWELL3D_REG_INDEX(bind_groups[0].raw_config)) / BindGroupStride] + .fill(~u32{0}); + break; default: if (!FrontHandledOutsideDrawManager(method)) { fs.draw_pending = true; From bd99749bfa18bfc4e56bf98b5d450810c3486a68 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:30:07 -0500 Subject: [PATCH 124/154] loader: Mario Kart World reduced detail (mkw_reduce_detail) A setting next to the FPS modes ("Mario Kart World: reduced detail (performance)", off by default; Android toggle and Qt entry) that patches the game's main module before it is mapped, as the FPS patch does, each patch only where its pattern occurs exactly once (skipped and logged otherwise): - two shadow cascades: in DepthShadow's init, ldr w8, [x21, #0x1a48] (the cascade count) becomes mov w8, #2 (main+0xcafe2c in the dumped build); - the LODDistanceRatio key in rodata renamed, so the field is not loaded and keeps the constructor's 1.0 instead of the configured 2.0. EDEN_NX2_MKW_DETAIL, when set (with the setting off too), replaces the set: "cascades=1|2|3" (3: none), "lod=1.0|0.75|0.5" (below 1.0 the constructor's mov w8, #1.0f becomes 0.75 or 0.5 as well), "elink" (VehicleNumNoDiv key overwritten with the existing VehicleNumDiv2), "emit" (NoEmitSingle with the existing NoEmitDiv2), "traffic" (MobCarDispNum and MobCarDispDist renamed). Key overwrites keep the length (NUL padded). Every applied patch is logged with its main+offset. Checked against the module dump: each pattern occurs once there. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../features/settings/model/BooleanSetting.kt | 1 + .../settings/model/view/SettingsItem.kt | 7 + .../settings/ui/SettingsFragmentPresenter.kt | 1 + .../app/src/main/res/values/strings.xml | 2 + src/common/settings.h | 3 + src/core/loader/nso.cpp | 169 ++++++++++++++++++ src/qt_common/config/shared_translation.cpp | 3 + 7 files changed, 186 insertions(+) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index 1cb9a0a790..9b1df6b2e3 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -32,6 +32,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_NX2_COMMAND_LISTS("nx2_command_lists"), RENDERER_MKW_30FPS("mkw_30fps"), RENDERER_MKW_60FPS("mkw_60fps"), + RENDERER_MKW_REDUCE_DETAIL("mkw_reduce_detail"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), ENABLE_BUFFER_HISTORY("enable_buffer_history"), USE_OPTIMIZED_VERTEX_BUFFERS("use_optimized_vertex_buffers"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 7c675aec36..86808533b9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -820,6 +820,13 @@ abstract class SettingsItem( descriptionId = R.string.renderer_mkw_60fps_description ) ) + put( + SwitchSetting( + BooleanSetting.RENDERER_MKW_REDUCE_DETAIL, + titleId = R.string.renderer_mkw_reduce_detail, + descriptionId = R.string.renderer_mkw_reduce_detail_description + ) + ) put( SingleChoiceSetting( IntSetting.FAST_GPU_TIME, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 3e6fbee302..4c4e37b56c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -564,6 +564,7 @@ class SettingsFragmentPresenter( add(BooleanSetting.RENDERER_NX2_COMMAND_LISTS.key) add(BooleanSetting.RENDERER_MKW_30FPS.key) add(BooleanSetting.RENDERER_MKW_60FPS.key) + add(BooleanSetting.RENDERER_MKW_REDUCE_DETAIL.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_GPU_EMULATION.key) add(SettingsItem.GPU_UNSWIZZLE_COMBINED) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index f8712835e6..5e4c813b55 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -620,6 +620,8 @@ Forces the game to use its native 30 FPS mode at normal game speed. Takes effect after restarting the game. Mario Kart World: 60 FPS mode Always renders at 60 fps. Slower hardware runs the game in slow motion. Takes effect after restarting the game; the 30 FPS mode takes precedence. + Mario Kart World: reduced detail (performance) + Fewer shadow cascades and shorter LOD distances to cut draw calls. Takes effect after restarting the game. Frees the RAM copy of Switch 2 GPU-only memory once its textures are on the GPU, saving RAM. Experimental: may cause missing or corrupted textures. GPU Unswizzle Settings Configure GPU-based texture unswizzling parameters or disable it entirely. Adjust these settings to balance performance and texture loading quality. diff --git a/src/common/settings.h b/src/common/settings.h index 28e1c2c987..c2afa7ed9c 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -633,6 +633,9 @@ struct Values { SwitchableSetting mkw_60fps{linkage, false, "mkw_60fps", Category::RendererAdvanced}; SwitchableSetting mkw_dynamic_fps{linkage, false, "mkw_dynamic_fps", Category::RendererAdvanced}; + // Mario Kart World: fewer shadow cascades and shorter LOD distances (loader/nso.cpp). + SwitchableSetting mkw_reduce_detail{linkage, false, "mkw_reduce_detail", + Category::RendererAdvanced}; SwitchableSetting use_reactive_flushing{linkage, #ifdef __ANDROID__ diff --git a/src/core/loader/nso.cpp b/src/core/loader/nso.cpp index 945f54cec6..34aaad8ed5 100644 --- a/src/core/loader/nso.cpp +++ b/src/core/loader/nso.cpp @@ -5,9 +5,13 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include +#include #include +#include #include +#include #include #include "common/common_funcs.h" @@ -33,6 +37,119 @@ namespace Loader { namespace { + +// Mario Kart World: reduced detail (Settings::values.mkw_reduce_detail). Pattern-matched text +// and rodata patches to the module before it is mapped, each applied only when its pattern +// occurs exactly once. The default set is two shadow cascades and the LOD distance ratio key +// renamed (the game then keeps the constructor's 1.0 instead of the loaded 2.0). +// EDEN_NX2_MKW_DETAIL, when set, replaces the set: a comma list of "cascades=1|2|3" (3: no +// patch), "lod=1.0|0.75|0.5" (below 1.0 the constructor default is patched too), "elink" +// (vehicle count divisor key), "emit" (single emit key), "traffic" (mob car count and distance +// keys), e.g. "cascades=2,lod=1.0,elink,emit,traffic". +struct MkwDetailOptions { + u32 cascades{2}; + bool lod_key{true}; + u32 lod_default{0}; ///< 0: keep the constructor's 1.0; else the mov w8 immediate to use + bool elink{}; + bool emit{}; + bool traffic{}; +}; + +MkwDetailOptions ParseMkwDetail(std::string_view text) { + MkwDetailOptions options{.cascades = 3, .lod_key = false}; + while (!text.empty()) { + const size_t comma = text.find(','); + const std::string_view token = text.substr(0, comma); + text = comma == std::string_view::npos ? std::string_view{} : text.substr(comma + 1); + if (token.empty()) { + continue; + } + if (token == "elink") { + options.elink = true; + } else if (token == "emit") { + options.emit = true; + } else if (token == "traffic") { + options.traffic = true; + } else if (token.starts_with("cascades=")) { + const std::string_view value = token.substr(9); + options.cascades = value == "1" ? 1U : value == "2" ? 2U : 3U; + } else if (token.starts_with("lod=")) { + const std::string_view value = token.substr(4); + options.lod_key = true; + if (value == "1.0" || value == "1") { + options.lod_default = 0; + } else if (value == "0.75") { + options.lod_default = 0x52A7E808; // mov w8, #0x3f40'0000 (0.75f) + } else if (value == "0.5") { + options.lod_default = 0x52A7E008; // mov w8, #0x3f00'0000 (0.5f) + } else { + LOG_WARNING(Loader, "EDEN_NX2_MKW_DETAIL: unknown LOD ratio '{}' (1.0, 0.75, 0.5)", + value); + options.lod_key = false; + } + } else { + LOG_WARNING(Loader, "EDEN_NX2_MKW_DETAIL: unknown item '{}'", token); + } + } + return options; +} + +/// The offset of `needle` in `hay` when it occurs exactly once; `matches` gets the count. +std::optional FindOnce(std::span hay, std::span needle, + size_t& matches) { + matches = 0; + std::optional found; + auto it = hay.begin(); + while (true) { + it = std::search(it, hay.end(), needle.begin(), needle.end()); + if (it == hay.end()) { + break; + } + if (++matches == 1) { + found = static_cast(it - hay.begin()); + } + ++it; + } + return matches == 1 ? found : std::nullopt; +} + +/// Replaces the first word of the one occurrence of `sequence` in the text segment. +void PatchMkwWords(std::span text, u64 text_offset, std::span sequence, + u32 replacement, std::string_view what) { + std::array needle{}; + const size_t bytes = sequence.size() * sizeof(u32); + ASSERT(bytes <= needle.size()); + std::memcpy(needle.data(), sequence.data(), bytes); + size_t matches = 0; + const auto at = FindOnce(text, std::span{needle.data(), bytes}, matches); + if (!at) { + LOG_WARNING(Loader, "Mario Kart World: {} patch skipped ({} matches)", what, matches); + return; + } + std::memcpy(text.data() + *at, &replacement, sizeof(replacement)); + LOG_INFO(Loader, "Mario Kart World: {} (main+{:#x})", what, text_offset + *at); +} + +/// Overwrites the one occurrence of the NUL-framed `key` in rodata with `replacement`, of the +/// same length (NUL padding included). +void PatchMkwKey(std::span rodata, u64 rodata_offset, std::string_view key, + std::string_view replacement, std::string_view what) { + ASSERT(key.size() == replacement.size()); + std::array needle{}; + ASSERT(key.size() + 2 <= needle.size()); + needle[0] = 0; + std::memcpy(needle.data() + 1, key.data(), key.size()); + needle[key.size() + 1] = 0; + size_t matches = 0; + const auto at = FindOnce(rodata, std::span{needle.data(), key.size() + 2}, matches); + if (!at) { + LOG_WARNING(Loader, "Mario Kart World: {} patch skipped ({} matches)", what, matches); + return; + } + std::memcpy(rodata.data() + *at + 1, replacement.data(), replacement.size()); + LOG_INFO(Loader, "Mario Kart World: {} (main+{:#x})", what, rodata_offset + *at + 1); +} + struct MODHeader { u32_le magic; u32_le dynamic_offset; @@ -246,6 +363,58 @@ std::optional AppLoader_NSO::LoadModule(Kernel::KProcess& process, Core:: } } + // Mario Kart World: reduced detail (see MkwDetailOptions). The environment list applies + // with the setting off too, as an opt-in for testing. + if (name == "main" && process.GetProgramId() == Settings::MarioKartWorldProgramId) { + const char* const env = std::getenv("EDEN_NX2_MKW_DETAIL"); + if (Settings::values.mkw_reduce_detail.GetValue() || env != nullptr) { + const MkwDetailOptions options = + env != nullptr ? ParseMkwDetail(env) : MkwDetailOptions{}; + const auto& text = nso_header.segments[0]; + const auto& rodata = nso_header.segments[1]; + const std::span text_bytes{codeset.memory.data() + module_start + text.location, + text.size}; + const std::span rodata_bytes{ + codeset.memory.data() + module_start + rodata.location, rodata.size}; + if (options.cascades < 3) { + // DepthShadow init: ldr w8, [x21, #0x1a48] (the cascade count) -> mov w8, #N. + static constexpr std::array cascades_sequence{ + 0xB95A4AA8, 0x6B09011F, 0x1A89B108, 0xF941B6C9, + 0x7100051F, 0x1A9FC508, 0xB9022AC8}; + PatchMkwWords(text_bytes, text.location, cascades_sequence, + 0x52800008U | (options.cascades << 5), + options.cascades == 1 ? "1 shadow cascade" : "2 shadow cascades"); + } + if (options.lod_key) { + PatchMkwKey(rodata_bytes, rodata.location, "LODDistanceRatio", "LODDistanceQatio", + "LOD distance ratio key renamed (constructor default kept)"); + } + if (options.lod_default != 0) { + // The constructor: mov w8, #1.0f stored to the ratio field. + static constexpr std::array lod_sequence{ + 0x52A7F008, 0xF9001A7F, 0xF803C27F, 0xF804427F, + 0xF807C27F, 0xF808427F, 0x79011A7F, 0xB9003A68}; + PatchMkwWords(text_bytes, text.location, lod_sequence, options.lod_default, + options.lod_default == 0x52A7E808 ? "LOD distance ratio default 0.75" + : "LOD distance ratio default 0.5"); + } + if (options.elink) { + PatchMkwKey(rodata_bytes, rodata.location, "VehicleNumNoDiv", + std::string_view{"VehicleNumDiv2\0", 15}, "vehicle count divided by 2"); + } + if (options.emit) { + PatchMkwKey(rodata_bytes, rodata.location, "NoEmitSingle", + std::string_view{"NoEmitDiv2\0\0", 12}, "single emits divided by 2"); + } + if (options.traffic) { + PatchMkwKey(rodata_bytes, rodata.location, "MobCarDispNum", "MobCarQispNum", + "mob car count key renamed"); + PatchMkwKey(rodata_bytes, rodata.location, "MobCarDispDist", "MobCarQispDist", + "mob car distance key renamed"); + } + } + } + #ifdef HAS_NCE // If we are computing the process code layout and using nce backend, patch. const auto& code = codeset.CodeSegment(); diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 7d020fbce1..633b148c0a 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -296,6 +296,9 @@ std::unique_ptr InitializeTranslations(QObject* parent) { tr("Lets the game adjust between 30 and 60 FPS using whole-frame timing to maintain " "normal game speed. Takes effect after restarting the game; the 30 and 60 FPS modes " "take precedence.")); + INSERT(Settings, mkw_reduce_detail, tr("Mario Kart World: reduced detail (performance)"), + tr("Fewer shadow cascades and shorter LOD distances to cut draw calls. Takes effect " + "after restarting the game.")); INSERT(Settings, barrier_feedback_loops, tr("Barrier feedback loops"), tr("Improves rendering of transparency effects in specific games.")); INSERT(Settings, enable_buffer_history, tr("Enable buffer history"), From 1caf3934c62bb910c406c50a9e7c643e15835ca9 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:35:17 -0500 Subject: [PATCH 125/154] loader: Mario Kart World detail patches as two settings mkw_reduce_detail becomes two independent settings, each with an Android toggle next to the FPS modes and a Qt entry, both off by default: - mkw_lod_distance ("shorter LOD distance"): the LODDistanceRatio key renamed, so the game keeps its default ratio 1.0 instead of the loaded 2.0; - mkw_shadow_cascades ("fewer shadow cascades"): the cascade count 3 -> 2, dropping the farthest dynamic shadow cascade. EDEN_NX2_MKW_DETAIL still replaces the set when present (cascades=N, lod=1.0|0.75|0.5, elink, emit, traffic). Each applied patch is logged with its main+offset as before. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../features/settings/model/BooleanSetting.kt | 3 ++- .../settings/model/view/SettingsItem.kt | 13 +++++++--- .../settings/ui/SettingsFragmentPresenter.kt | 3 ++- .../app/src/main/res/values/strings.xml | 6 +++-- src/common/settings.h | 8 +++--- src/core/loader/nso.cpp | 26 +++++++++++-------- src/qt_common/config/shared_translation.cpp | 9 ++++--- 7 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index 9b1df6b2e3..f552de5473 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -32,7 +32,8 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { RENDERER_NX2_COMMAND_LISTS("nx2_command_lists"), RENDERER_MKW_30FPS("mkw_30fps"), RENDERER_MKW_60FPS("mkw_60fps"), - RENDERER_MKW_REDUCE_DETAIL("mkw_reduce_detail"), + RENDERER_MKW_LOD_DISTANCE("mkw_lod_distance"), + RENDERER_MKW_SHADOW_CASCADES("mkw_shadow_cascades"), RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), ENABLE_BUFFER_HISTORY("enable_buffer_history"), USE_OPTIMIZED_VERTEX_BUFFERS("use_optimized_vertex_buffers"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 86808533b9..20b498e00a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -822,9 +822,16 @@ abstract class SettingsItem( ) put( SwitchSetting( - BooleanSetting.RENDERER_MKW_REDUCE_DETAIL, - titleId = R.string.renderer_mkw_reduce_detail, - descriptionId = R.string.renderer_mkw_reduce_detail_description + BooleanSetting.RENDERER_MKW_LOD_DISTANCE, + titleId = R.string.renderer_mkw_lod_distance, + descriptionId = R.string.renderer_mkw_lod_distance_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_MKW_SHADOW_CASCADES, + titleId = R.string.renderer_mkw_shadow_cascades, + descriptionId = R.string.renderer_mkw_shadow_cascades_description ) ) put( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 4c4e37b56c..c4b45a6765 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -564,7 +564,8 @@ class SettingsFragmentPresenter( add(BooleanSetting.RENDERER_NX2_COMMAND_LISTS.key) add(BooleanSetting.RENDERER_MKW_30FPS.key) add(BooleanSetting.RENDERER_MKW_60FPS.key) - add(BooleanSetting.RENDERER_MKW_REDUCE_DETAIL.key) + add(BooleanSetting.RENDERER_MKW_LOD_DISTANCE.key) + add(BooleanSetting.RENDERER_MKW_SHADOW_CASCADES.key) add(IntSetting.ANDROID_PIPELINE_WORKERS.key) add(BooleanSetting.RENDERER_ASYNCHRONOUS_GPU_EMULATION.key) add(SettingsItem.GPU_UNSWIZZLE_COMBINED) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5e4c813b55..8f1de87976 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -620,8 +620,10 @@ Forces the game to use its native 30 FPS mode at normal game speed. Takes effect after restarting the game. Mario Kart World: 60 FPS mode Always renders at 60 fps. Slower hardware runs the game in slow motion. Takes effect after restarting the game; the 30 FPS mode takes precedence. - Mario Kart World: reduced detail (performance) - Fewer shadow cascades and shorter LOD distances to cut draw calls. Takes effect after restarting the game. + Mario Kart World: shorter LOD distance + Objects switch to lower detail at half the distance (the game\'s default ratio instead of 2.0). Fewer, lighter draws. Takes effect after restarting the game. + Mario Kart World: fewer shadow cascades + Drops the farthest dynamic shadow cascade (160-250 m). Takes effect after restarting the game. Frees the RAM copy of Switch 2 GPU-only memory once its textures are on the GPU, saving RAM. Experimental: may cause missing or corrupted textures. GPU Unswizzle Settings Configure GPU-based texture unswizzling parameters or disable it entirely. Adjust these settings to balance performance and texture loading quality. diff --git a/src/common/settings.h b/src/common/settings.h index c2afa7ed9c..1a40451059 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -633,9 +633,11 @@ struct Values { SwitchableSetting mkw_60fps{linkage, false, "mkw_60fps", Category::RendererAdvanced}; SwitchableSetting mkw_dynamic_fps{linkage, false, "mkw_dynamic_fps", Category::RendererAdvanced}; - // Mario Kart World: fewer shadow cascades and shorter LOD distances (loader/nso.cpp). - SwitchableSetting mkw_reduce_detail{linkage, false, "mkw_reduce_detail", - Category::RendererAdvanced}; + // Mario Kart World: shorter LOD distances and fewer shadow cascades (loader/nso.cpp). + SwitchableSetting mkw_lod_distance{linkage, false, "mkw_lod_distance", + Category::RendererAdvanced}; + SwitchableSetting mkw_shadow_cascades{linkage, false, "mkw_shadow_cascades", + Category::RendererAdvanced}; SwitchableSetting use_reactive_flushing{linkage, #ifdef __ANDROID__ diff --git a/src/core/loader/nso.cpp b/src/core/loader/nso.cpp index 34aaad8ed5..b8088370ec 100644 --- a/src/core/loader/nso.cpp +++ b/src/core/loader/nso.cpp @@ -38,17 +38,17 @@ namespace Loader { namespace { -// Mario Kart World: reduced detail (Settings::values.mkw_reduce_detail). Pattern-matched text -// and rodata patches to the module before it is mapped, each applied only when its pattern -// occurs exactly once. The default set is two shadow cascades and the LOD distance ratio key -// renamed (the game then keeps the constructor's 1.0 instead of the loaded 2.0). -// EDEN_NX2_MKW_DETAIL, when set, replaces the set: a comma list of "cascades=1|2|3" (3: no +// Mario Kart World: reduced detail (Settings::values.mkw_lod_distance, mkw_shadow_cascades). +// Pattern-matched text and rodata patches to the module before it is mapped, each applied only +// when its pattern occurs exactly once. The settings select two shadow cascades and the LOD +// distance ratio key renamed (the game then keeps the constructor's 1.0 instead of the loaded +// 2.0). EDEN_NX2_MKW_DETAIL, when set, replaces the set: a comma list of "cascades=1|2|3" (3: no // patch), "lod=1.0|0.75|0.5" (below 1.0 the constructor default is patched too), "elink" // (vehicle count divisor key), "emit" (single emit key), "traffic" (mob car count and distance // keys), e.g. "cascades=2,lod=1.0,elink,emit,traffic". struct MkwDetailOptions { - u32 cascades{2}; - bool lod_key{true}; + u32 cascades{3}; + bool lod_key{}; u32 lod_default{0}; ///< 0: keep the constructor's 1.0; else the mov w8 immediate to use bool elink{}; bool emit{}; @@ -363,13 +363,17 @@ std::optional AppLoader_NSO::LoadModule(Kernel::KProcess& process, Core:: } } - // Mario Kart World: reduced detail (see MkwDetailOptions). The environment list applies - // with the setting off too, as an opt-in for testing. + // Mario Kart World: reduced detail (see MkwDetailOptions). The environment list, when set, + // replaces the settings (with both off too, as an opt-in for testing). if (name == "main" && process.GetProgramId() == Settings::MarioKartWorldProgramId) { const char* const env = std::getenv("EDEN_NX2_MKW_DETAIL"); - if (Settings::values.mkw_reduce_detail.GetValue() || env != nullptr) { + const bool lod_setting = Settings::values.mkw_lod_distance.GetValue(); + const bool cascades_setting = Settings::values.mkw_shadow_cascades.GetValue(); + if (lod_setting || cascades_setting || env != nullptr) { const MkwDetailOptions options = - env != nullptr ? ParseMkwDetail(env) : MkwDetailOptions{}; + env != nullptr ? ParseMkwDetail(env) + : MkwDetailOptions{.cascades = cascades_setting ? 2U : 3U, + .lod_key = lod_setting}; const auto& text = nso_header.segments[0]; const auto& rodata = nso_header.segments[1]; const std::span text_bytes{codeset.memory.data() + module_start + text.location, diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 633b148c0a..f9fd523232 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -296,9 +296,12 @@ std::unique_ptr InitializeTranslations(QObject* parent) { tr("Lets the game adjust between 30 and 60 FPS using whole-frame timing to maintain " "normal game speed. Takes effect after restarting the game; the 30 and 60 FPS modes " "take precedence.")); - INSERT(Settings, mkw_reduce_detail, tr("Mario Kart World: reduced detail (performance)"), - tr("Fewer shadow cascades and shorter LOD distances to cut draw calls. Takes effect " - "after restarting the game.")); + INSERT(Settings, mkw_lod_distance, tr("Mario Kart World: shorter LOD distance"), + tr("Objects switch to lower detail at half the distance (the game's default ratio " + "instead of 2.0). Fewer, lighter draws. Takes effect after restarting the game.")); + INSERT(Settings, mkw_shadow_cascades, tr("Mario Kart World: fewer shadow cascades"), + tr("Drops the farthest dynamic shadow cascade (160-250 m). Takes effect after " + "restarting the game.")); INSERT(Settings, barrier_feedback_loops, tr("Barrier feedback loops"), tr("Improves rendering of transparency effects in specific games.")); INSERT(Settings, enable_buffer_history, tr("Enable buffer history"), From c1a97b7c4afac492aec36f7fdda02cedc982e7bf Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:56 -0500 Subject: [PATCH 126/154] shader: array-cube TEX lowering packs FP16 results and binds its buffer The specialised lowering of the array-cube vector TEX (dimension 7, LOD from a register) copied the FP32 result registers to the destinations whatever bit 79 said; with FP16 output the general path packs (c0, c1) and (c2, c3) into half pairs, so these shaders got the wrong bit pattern. It packs the same way now. It also set the program's texture descriptor buffer to 2 directly, over a different buffer an earlier texture operation required; it goes through bind_texture_buffer and fails the lowering on a conflict, as the other paths. CodeRabbit on #503: lower_to_maxwell.h:2219 (FP16) and :2225 (binding). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../frontend/ampere/lower_to_maxwell.h | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h index 811f461ddd..474d494561 100644 --- a/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h +++ b/src/shader_recompiler/frontend/ampere/lower_to_maxwell.h @@ -2222,13 +2222,28 @@ inline LoweredProgram LowerToMaxwellWith(std::span code, u32 (components > 1 && ((dest & 1) || dest >= 254)) || (components == 4 && ((second_dest & 1) || second_dest >= 254))) return fail(index, "native array-cube vector sample mode"); - result.required_texture_buffer = 2; + if (!bind_texture_buffer(2)) + return fail(index, "native texture descriptor buffer conflict"); // Vector coordinates already use layer,X,Y,Z order. LOD is // supplied separately, matching the Maxwell TEX operand ABI. emit(0xc000000000000000ULL | predicate | 250 | (u64{source_a} << 8) | (u64{source_b} << 20) | (u64{7} << 28) | (u64{mask} << 31) | (u64{insn.Bits(77, 1)} << 35) | (u64{insn.Bits(40, 14)} << 36) | (u64{insn.Bits(90, 1)} << 49) | (u64{7} << 51) | (u64{lod} << 55)); + if (insn.Bits(79, 1)) { + // FP16 results, packed as the general path below does: (c0, c1) into the + // first destination, (c2, c3) into the second (F2F writes the half in the + // low 16 bits; ISCADD merges, op_a shifted up as the high half). + const auto pack = [&](u32 target, u32 low, u32 high) { + emit(0x5ca8000000000000ULL | predicate | 246 | (u64{1} << 8) | (u64{2} << 10) | (u64{low} << 20)); + emit(0x5ca8000000000000ULL | predicate | 247 | (u64{1} << 8) | (u64{2} << 10) | (u64{high} << 20)); + emit(0x5c18000000000000ULL | predicate | target | (u64{247} << 8) | (u64{246} << 20) | (u64{16} << 39)); + }; + pack(static_cast(dest), 250, components > 1 ? 251U : 255U); + if (components > 2) + pack(second_dest, 252, components > 3 ? 253U : 255U); + break; + } for (u32 i = 0; i < components; ++i) emit(0x5c98000000000000ULL | predicate | (i < 2 ? dest + i : second_dest + i - 2) | From f9a674c0ce751ce92b4567397219986149f36196 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:57 -0500 Subject: [PATCH 127/154] vulkan: emulated shared memory within the device's storage range, one scratch per tick The emulated shared memory buffer spanned up to 256 MiB, while Vulkan only guarantees a 128 MiB maxStorageBufferRange: on a device with the smaller limit the descriptor range was invalid and the upper slices could lose the device. The limit is min(256 MiB, maxStorageBufferRange) now, given to the shader through the profile (its slice clamp) and used by the host binding, so both agree. Each indirect dispatch, and each direct one with no workgroups, also took a full-size device-local region from the staging pool, which hands out a new one per request until the tick completes: several in one tick could exhaust a phone's memory. A direct dispatch with no workgroups binds one slice (nothing runs); a dispatch that needs every slice reuses the pipeline's region within the tick, with a compute-to-compute barrier between the dispatches sharing it. CodeRabbit on #503: shader_info.h:273 and vk_compute_pipeline.cpp:302. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../backend/spirv/spirv_emit_context.cpp | 3 +- src/shader_recompiler/profile.h | 3 ++ src/shader_recompiler/shader_info.h | 16 ++++++-- .../renderer_vulkan/vk_compute_pipeline.cpp | 39 +++++++++++++++++-- .../renderer_vulkan/vk_compute_pipeline.h | 7 ++++ .../renderer_vulkan/vk_pipeline_cache.cpp | 4 +- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp index ce46b55bf4..babd2ef67e 100644 --- a/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp +++ b/src/shader_recompiler/backend/spirv/spirv_emit_context.cpp @@ -528,7 +528,8 @@ Id EmitContext::SharedWordPointer(Id word_index) { // Never past the buffer, which a write there can turn into a GPU fault (device lost): an // indirect dispatch can have more workgroups than slices, and a program can address past // its shared memory size. The host binds at least this many slices. - const u32 slices{EmulatedSharedMemorySlices(shared_memory_words * 4)}; + const u32 slices{EmulatedSharedMemorySlices( + shared_memory_words * 4, EmulatedSharedMemoryMaxBytes(profile.emulated_shared_memory_max_bytes))}; const Id slice{OpUMin(U32[1], linear, Const(slices - 1))}; const Id word{OpUMin(U32[1], word_index, Const(shared_memory_words - 1))}; const Id base{OpIMul(U32[1], slice, Const(shared_memory_words))}; diff --git a/src/shader_recompiler/profile.h b/src/shader_recompiler/profile.h index 8e9c83f888..29da75a299 100644 --- a/src/shader_recompiler/profile.h +++ b/src/shader_recompiler/profile.h @@ -113,6 +113,9 @@ struct Profile { u64 min_ssbo_alignment{}; u32 max_user_clip_distances{}; + /// Bytes an emulated shared memory buffer may span on this host (EmulatedSharedMemoryMaxBytes); + /// 0: the fixed limit. + u32 emulated_shared_memory_max_bytes{}; bool SupportsSubgroupStage(Stage stage) const { return (supported_subgroup_stages & (1u << static_cast(stage))) != 0; diff --git a/src/shader_recompiler/shader_info.h b/src/shader_recompiler/shader_info.h index 4f4ba36714..e38437a768 100644 --- a/src/shader_recompiler/shader_info.h +++ b/src/shader_recompiler/shader_info.h @@ -272,10 +272,18 @@ struct MeshInfo { /// and the host binds at least min(workgroups, slices that fit) slices. constexpr u32 EMULATED_SHARED_MEMORY_MAX_BYTES = 256U << 20; -[[nodiscard]] constexpr u32 EmulatedSharedMemorySlices(u32 bytes_per_workgroup) { - return bytes_per_workgroup == 0 - ? 1U - : (std::max)(EMULATED_SHARED_MEMORY_MAX_BYTES / bytes_per_workgroup, 1U); +/// The limit on a host whose storage buffer range is `max_storage_buffer_range` (0: unknown, +/// the fixed limit): the shader's clamp and the host's binding must agree on it. +[[nodiscard]] constexpr u32 EmulatedSharedMemoryMaxBytes(u64 max_storage_buffer_range) { + return max_storage_buffer_range == 0 + ? EMULATED_SHARED_MEMORY_MAX_BYTES + : static_cast((std::min)(u64{EMULATED_SHARED_MEMORY_MAX_BYTES}, + max_storage_buffer_range)); +} + +[[nodiscard]] constexpr u32 EmulatedSharedMemorySlices(u32 bytes_per_workgroup, + u32 max_bytes = EMULATED_SHARED_MEMORY_MAX_BYTES) { + return bytes_per_workgroup == 0 ? 1U : (std::max)(max_bytes / bytes_per_workgroup, 1U); } struct Info { diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 3343140cf4..a3930d0f75 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -291,15 +291,46 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, // Emulated shared memory, a slice per workgroup, as many as the shader may address (it // clamps to them). An indirect dispatch's size is only known on the GPU: all of them. const auto& qmd{kepler_compute.launch_description}; - const u64 slices = Shader::EmulatedSharedMemorySlices(info.emulated_shared_memory_size); + // As many slices as the shader clamps to, within the host's storage buffer range (the + // same limit the shader was compiled with). + const u32 max_bytes = + Shader::EmulatedSharedMemoryMaxBytes(device.GetMaxStorageBufferRange()); + const u64 slices = + Shader::EmulatedSharedMemorySlices(info.emulated_shared_memory_size, max_bytes); + const bool indirect = static_cast(kepler_compute.GetIndirectComputeAddress()); u64 workgroups = static_cast(qmd.grid_dim_x.Value()) * qmd.grid_dim_y.Value() * qmd.grid_dim_z.Value(); - if (workgroups == 0 || kepler_compute.GetIndirectComputeAddress()) { + if (indirect) { workgroups = slices; + } else if (workgroups == 0) { + workgroups = 1; // Nothing runs: one slice keeps the binding valid } const u64 size = (std::min)(workgroups, slices) * info.emulated_shared_memory_size; - const StagingBufferRef scratch{staging_pool.Request(size, MemoryUsage::DeviceLocal)}; - guest_descriptor_queue.AddBuffer(scratch.buffer, scratch.offset, size); + if (workgroups >= slices) { + // The whole range: one region per tick for this pipeline, not one per dispatch (an + // indirect dispatch does not know its workgroup count; several in one tick took a + // full-size region each). Dispatches that share it are ordered by a barrier. + if (shared_scratch_tick != scheduler.CurrentTick() || shared_scratch_size < size) { + shared_scratch = staging_pool.Request(size, MemoryUsage::DeviceLocal); + shared_scratch_tick = scheduler.CurrentTick(); + shared_scratch_size = size; + } else { + scheduler.Record([](vk::CommandBuffer cmdbuf) { + static constexpr VkMemoryBarrier barrier{ + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, + .pNext = nullptr, + .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, + }; + cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, barrier); + }); + } + guest_descriptor_queue.AddBuffer(shared_scratch.buffer, shared_scratch.offset, size); + } else { + const StagingBufferRef scratch{staging_pool.Request(size, MemoryUsage::DeviceLocal)}; + guest_descriptor_queue.AddBuffer(scratch.buffer, scratch.offset, size); + } } if (!is_built.load(std::memory_order::relaxed)) { diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.h b/src/video_core/renderer_vulkan/vk_compute_pipeline.h index 9f16ed59ca..517bdcaec4 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.h +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.h @@ -17,6 +17,7 @@ #include "video_core/renderer_vulkan/vk_buffer_cache.h" #include "video_core/renderer_vulkan/vk_descriptor_buffer.h" #include "video_core/renderer_vulkan/vk_descriptor_pool.h" +#include "video_core/renderer_vulkan/vk_staging_buffer_pool.h" #include "video_core/renderer_vulkan/vk_texture_cache.h" #include "video_core/renderer_vulkan/vk_update_descriptor.h" #include "video_core/vulkan_common/vulkan_wrapper.h" @@ -69,6 +70,12 @@ class ComputePipeline { VideoCommon::ComputeUniformBufferSizes uniform_buffer_sizes{}; + /// Emulated shared memory for the dispatches that need every slice (indirect, or at least + /// as many workgroups as slices): one region per tick, reused with a barrier between them. + StagingBufferRef shared_scratch{}; + u64 shared_scratch_tick{}; + u64 shared_scratch_size{}; + vk::ShaderModule spv_module; vk::DescriptorSetLayout descriptor_set_layout; bool uses_push_descriptor{false}; diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 4dc5dfb4ff..81fe8a94bd 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -566,7 +566,9 @@ PipelineCache::PipelineCache(Tegra::MaxwellDeviceMemoryManager& device_memory_, .has_broken_robust = device.IsNvidia() && device.GetNvidiaArch() <= NvidiaArchitecture::Arch_Pascal, .min_ssbo_alignment = device.GetStorageBufferAlignment(), - .max_user_clip_distances = device.GetMaxUserClipDistances() + .max_user_clip_distances = device.GetMaxUserClipDistances(), + .emulated_shared_memory_max_bytes = + Shader::EmulatedSharedMemoryMaxBytes(device.GetMaxStorageBufferRange()) }; host_info = Shader::HostTranslateInfo{ From 5193b7147623a2d92d3a02f7f471cb9017c66346 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:57 -0500 Subject: [PATCH 128/154] vulkan: query-result uploads count as GPU writes of their buffers QueryCacheRuntime::SyncValues obtained the destination buffers with DoNothing and then copied the values into them on the GPU, so the buffer's GpuWriteTick did not move: a direct host read (an integrated GPU reading the buffer's memory after waiting for its last write) could wait on an older tick and read before the copy had landed. The buffers are obtained with MarkAsWritten, which records the write tick and marks the range GPU-modified like any other GPU write. CodeRabbit on #503: buffer_cache.h:2597. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_query_cache.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_query_cache.cpp b/src/video_core/renderer_vulkan/vk_query_cache.cpp index f63c924e39..aa54028056 100644 --- a/src/video_core/renderer_vulkan/vk_query_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_query_cache.cpp @@ -1643,7 +1643,9 @@ void QueryCacheRuntime::SyncValues(std::span values, VkBuffer ba impl->buffers_to_upload_to.clear(); for (auto& pair : impl->little_cache) { static constexpr auto sync_info = VideoCommon::ObtainBufferSynchronize::FullSynchronize; - const auto post_op = VideoCommon::ObtainBufferOperation::DoNothing; + // The copies below write the buffer on the GPU: marked as such, so a host read of + // it waits for them (GpuWriteTick) and flushes them, as after any other GPU write. + const auto post_op = VideoCommon::ObtainBufferOperation::MarkAsWritten; const auto [buffer, offset] = impl->buffer_cache.ObtainCPUBuffer( pair.first, static_cast(pair.second - pair.first), sync_info, post_op); impl->buffers_to_upload_to.emplace_back(buffer->Handle(), offset); From 4d6759799edd6159763fd346cc1b2dd5e46f4025 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:57 -0500 Subject: [PATCH 129/154] video_core: an illegal inline-mode header leaves no block active The guard for host method 1 set method_count to 1 so the caller's decrement would end the block. An inline header calls it with no block active and no decrement, then switches to non-incrementing mode: the next header was read as a data word of method 1 and the stream stayed a word out of alignment, the very failure the guard prevents. method_count stays 0 when no block is active. CodeRabbit on #503: dma_pusher.cpp:460. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/dma_pusher.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/video_core/dma_pusher.cpp b/src/video_core/dma_pusher.cpp index 2eb265fd32..bc70afc74c 100644 --- a/src/video_core/dma_pusher.cpp +++ b/src/video_core/dma_pusher.cpp @@ -493,7 +493,9 @@ void DmaPusher::CallMethod(u32 argument) { dma_state.subchannel, remaining); } dma_state.skip_words = remaining; - dma_state.method_count = 1; + // The caller's decrement then ends the block. An inline header (no block active, + // method_count 0) must stay at 0: at 1 its next word would be read as data. + dma_state.method_count = dma_state.method_count > 0 ? 1 : 0; dma_state.non_incrementing = false; return; } From bf1aea0ac171f6c06fea63410a04473f944ad217 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:58 -0500 Subject: [PATCH 130/154] macro: the ARM64 JIT records the failing PC like the x86-64 one Run always reported pc 0 on Android because the ARM64 code never wrote State::fail_pc; the x86-64 code stores the instruction's PC at its start. Two instructions per macro instruction (a move and a store), a few milliseconds a second at Mario Kart World's ~1.7 million macro calls a second. CodeRabbit on #503: macro_turing_jit_arm64.h:482. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/macro_turing_jit_arm64.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/video_core/macro_turing_jit_arm64.h b/src/video_core/macro_turing_jit_arm64.h index ffdbdc6448..d517fb15e3 100644 --- a/src/video_core/macro_turing_jit_arm64.h +++ b/src/video_core/macro_turing_jit_arm64.h @@ -486,6 +486,9 @@ class TuringMacroJit { } void Instruction(Word pc, Word count, const Decoded& d) { + // Keep the failing PC available for diagnostics, as the x86-64 code does. + MOV(W16, pc); + STR(W16, st, offsetof(State, fail_pc)); // FIFO loads, in lane order. for (Word i = 0; i < 2; ++i) { const WReg load = i == 0 ? load0 : load1; From 17779dbdba20b8db461b4f1a2a0c3aa0e15270ea Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:58 -0500 Subject: [PATCH 131/154] vulkan: failed-pipeline SPIR-V dumps are named by the pipeline too Pipelines that share a stage's program (the same Vertex B with another Vertex A) dumped to the same file and overwrote each other. The pipeline key's hash comes first in the name. CodeRabbit on #503: vk_graphics_pipeline.cpp:283. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index d1792bce77..096a76775f 100644 --- a/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -280,7 +280,10 @@ void DumpFailedPipeline(const GraphicsPipelineCacheKey& key, continue; } const u64 hash = key.unique_hashes[stage + 1]; - const auto path = dir / fmt::format("{:016x}_{}.spv", hash, stage_names[stage]); + // The pipeline's hash first: pipelines sharing a stage's program (Vertex B with another + // Vertex A) would otherwise overwrite each other's dump. + const auto path = + dir / fmt::format("{:016x}_{:016x}_{}.spv", key.Hash(), hash, stage_names[stage]); Common::FS::IOFile file{path, Common::FS::FileAccessMode::Write, Common::FS::FileType::BinaryFile}; if (file.IsOpen()) { From 7e288ae0898338c7748c8b8e75afc54496f600fe Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:58 -0500 Subject: [PATCH 132/154] vulkan: a Switch 2 draw waits for its asynchronous translation BuiltPipeline keeps a Switch 2 program's draw while its pipeline builds (Mario Kart World raced in another scene's lighting after skipped draws), but the asynchronous translation path skipped the same draws: when the 3 ms wait budget ran out, behind a backlog, and on every later draw of a key still translating. As the asynchronous path only takes Switch 2 programs, the exemption never held with it on. A Switch 2 draw now waits for the translation (which still runs on a worker, alongside the others), and a later draw of a key still translating waits on the same shared future instead of being dropped. Other programs keep the budget and backlog rules. CodeRabbit on #503: vk_pipeline_cache.cpp:1511. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../renderer_vulkan/vk_pipeline_cache.cpp | 49 +++++++++++++------ .../renderer_vulkan/vk_pipeline_cache.h | 7 +-- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 81fe8a94bd..98e2b8a2a6 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1060,15 +1060,24 @@ void ReportNewPipelineKey(const GraphicsPipelineCacheKey& key) { GraphicsPipeline* PipelineCache::CurrentGraphicsPipelineSlowPath() { auto it{graphics_cache.find(graphics_key)}; if (it == graphics_cache.end()) { - if (translating_graphics.contains(graphics_key)) { - // Its translation is still running on a worker thread. - return nullptr; + if (const auto translating = translating_graphics.find(graphics_key); + translating != translating_graphics.end()) { + // Its translation is still running on a worker thread: a Switch 2 draw waits for + // it (BuiltPipeline keeps those draws too), others are skipped meanwhile. + const std::shared_future done = translating->second; + if (!WaitForGraphicsTranslation(done)) { + return nullptr; + } + } else { + ReportNewPipelineKey(graphics_key); + if (!BuildNewGraphicsPipeline()) { + return nullptr; + } } - ReportNewPipelineKey(graphics_key); - if (!BuildNewGraphicsPipeline()) { + it = graphics_cache.find(graphics_key); + if (it == graphics_cache.end()) { return nullptr; } - it = graphics_cache.find(graphics_key); } auto& pipeline{it->second}; if (!pipeline) { @@ -1181,7 +1190,7 @@ bool PipelineCache::CanSkipDraw() const noexcept { bool PipelineCache::BuildNewGraphicsPipeline() { if (CanTranslateAsynchronously()) { - if (std::optional> done{QueueGraphicsTranslation()}) { + if (std::optional> done{QueueGraphicsTranslation()}) { return WaitForGraphicsTranslation(*done); } } @@ -1522,7 +1531,7 @@ bool PipelineCache::CanTranslateAsynchronously() const { return true; } -std::optional> PipelineCache::QueueGraphicsTranslation() { +std::optional> PipelineCache::QueueGraphicsTranslation() { const auto begin = std::chrono::steady_clock::now(); GraphicsEnvironments environments; GetGraphicsEnvironments(environments, graphics_key.unique_hashes); @@ -1540,8 +1549,8 @@ std::optional> PipelineCache::QueueGraphicsTranslation() { } std::promise done; - std::future result{done.get_future()}; - translating_graphics.insert(graphics_key); + std::shared_future result{done.get_future().share()}; + translating_graphics.emplace(graphics_key, result); translation_workers.QueueWork([this, key = graphics_key, envs = std::move(environments.envs), done = std::move(done)]() mutable { TranslateGraphicsPipeline(key, std::move(envs), std::move(done)); @@ -1550,15 +1559,23 @@ std::optional> PipelineCache::QueueGraphicsTranslation() { return result; } -bool PipelineCache::WaitForGraphicsTranslation(std::future& done) { - // Waiting only helps when the job starts right away: behind a backlog it would spend the - // whole budget without getting the pipeline. - if (translation_wait_budget.count() == 0 || - translating_graphics.size() > num_translation_threads) { +bool PipelineCache::WaitForGraphicsTranslation(std::shared_future done) { + // A Switch 2 program's draw is never skipped (BuiltPipeline: Mario Kart World raced in + // another scene's lighting after skipped draws): it waits for the translation, which still + // runs on a worker alongside the others. Otherwise waiting only helps when the job starts + // right away: behind a backlog it would spend the whole budget without getting the pipeline. + const bool must_wait = Settings::CurrentProgramIsSwitch2(); + if (!must_wait && (translation_wait_budget.count() == 0 || + translating_graphics.size() > num_translation_threads)) { return false; } const auto begin = std::chrono::steady_clock::now(); - const bool in_time = done.wait_for(translation_wait_budget) == std::future_status::ready; + bool in_time = true; + if (must_wait) { + done.wait(); + } else { + in_time = done.wait_for(translation_wait_budget) == std::future_status::ready; + } GetTranslationReport().Waited(std::chrono::steady_clock::now() - begin, in_time); if (!in_time) { return false; diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.h b/src/video_core/renderer_vulkan/vk_pipeline_cache.h index 6d3e30f58f..699096c069 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.h +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.h @@ -161,10 +161,10 @@ class PipelineCache : public VideoCommon::ShaderCache { /// Queues the translation of graphics_key's pipeline on a worker thread. Returns a future /// that becomes ready when the result is waiting to be adopted, or nothing when the /// environments could not be detached from the engine. - std::optional> QueueGraphicsTranslation(); + std::optional> QueueGraphicsTranslation(); /// Waits up to the wait budget for a queued translation. True if it was adopted in time. - bool WaitForGraphicsTranslation(std::future& done); + bool WaitForGraphicsTranslation(std::shared_future done); /// Runs on a translation worker thread. void TranslateGraphicsPipeline(const GraphicsPipelineCacheKey& key, @@ -254,7 +254,8 @@ class PipelineCache : public VideoCommon::ShaderCache { DynamicFeatures dynamic_features; /// Keys whose pipeline is being translated on a worker (GPU thread only). - ::Common::unordered_set translating_graphics; + /// Keys whose translation runs on a worker, with what to wait on for them. + ::Common::unordered_map> translating_graphics; /// Finished translations, handed from the workers to the GPU thread. std::mutex translated_mutex; std::vector translated_graphics; From a0e8fded463583472688cb57bd8a1684812ed4ff Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:59 -0500 Subject: [PATCH 133/154] vulkan: a written sparse-layers image whose layers failed is backed, not left unwritten On a phone (backable) a SparseLayers image whose InitSparseLayers failed skipped the backing branch (!backable) and, below 2^28 texels, was marked UnwrittenSparse without asking whether it had been written: an already-written array (the ASTC terrain arrays) stayed unbacked and sampled black. The unwritten mark is only for images not yet written; a written layered-sparse one reaches the backable path and is backed whole. CodeRabbit on #503: vk_texture_cache.cpp:2798. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_texture_cache.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 18c866b53d..a08f7b3564 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -2776,10 +2776,10 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu HostImageBytes(runtime_.device, host_info, 0) <= SparseLayersCap(runtime_.device); const bool layered_sparse = UsesSparseLayers(runtime_.device, info, gpu_addr_); + const bool unwritten = IsUnwrittenSparse(runtime_.device, info, gpu_addr_); if (layered_sparse && InitSparseLayers()) { flags |= VideoCommon::ImageFlagBits::SparseLayers; - } else if (layered_sparse && !backable && - !IsUnwrittenSparse(runtime_.device, info, gpu_addr_)) { + } else if (layered_sparse && !backable && !unwritten) { // The driver's sparse block can't be bound by: back it as before SparseLayers (on a // phone, the `backable` path below does, past the size other images are capped at). original_image = MakeImage( @@ -2794,10 +2794,11 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu } } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && InitSparseTiles()) { flags |= VideoCommon::ImageFlagBits::SparseTiles; - } else if (texels <= (u64{1} << 28) || - (backable && IsUnwrittenSparse(runtime_.device, info, gpu_addr_))) { + } else if ((texels <= (u64{1} << 28) && (!layered_sparse || unwritten)) || + (backable && unwritten)) { // Not too big to back (see MemoryAllocator::CreateImage): unbacked because nothing - // was written there yet, so the first write gets it replaced. + // was written there yet, so the first write gets it replaced. A layered-sparse image + // already written (its sparse layers could not be set up) is backed below instead. flags |= VideoCommon::ImageFlagBits::UnwrittenSparse; } else if (backable) { vk::Image backed = MakeImage( From 689ced100836d0c8bed5866475006d1910f81bb0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:59 -0500 Subject: [PATCH 134/154] video_core: the detached shader environment converts texture handles like the live one The live path turns a handle into a header index with TextureHeaderIndex (a Switch 2 handle may hold the header's byte offset); the detached path used for asynchronous translation used the raw value, so such a handle read a different TIC entry or tripped the limit assert. Detach records whether the memory uses native tiling and the detached read converts the same way. CodeRabbit on #503: shader_environment.cpp:748. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/shader_environment.cpp | 7 ++++++- src/video_core/shader_environment.h | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/video_core/shader_environment.cpp b/src/video_core/shader_environment.cpp index e441d3718f..f53055741c 100644 --- a/src/video_core/shader_environment.cpp +++ b/src/video_core/shader_environment.cpp @@ -744,7 +744,11 @@ Tegra::Texture::TICEntry GraphicsEnvironment::ReadTextureDescriptor(u32 handle) return ReadTextureInfo(regs.tex_header.Address(), regs.tex_header.limit, via_header_index, handle); } - const u32 index{Tegra::Texture::TexturePair(handle, snapshot->via_header_index).first}; + // The same conversion as the live path (ReadTextureInfo): a Switch 2 handle may hold the + // header's byte offset instead of its index. + const u32 index{Tegra::Texture::TextureHeaderIndex( + Tegra::Texture::TexturePair(handle, snapshot->via_header_index).first, + snapshot->texture_pool_limit, snapshot->native_tiling)}; ASSERT(index <= snapshot->texture_pool_limit); Tegra::Texture::TICEntry entry{}; if (index <= snapshot->texture_pool_limit) { @@ -795,6 +799,7 @@ bool GraphicsEnvironment::Detach(Tegra::MaxwellDeviceMemoryManager& device_memor copy.texture_pool = *pool_device_address; copy.texture_pool_limit = regs.tex_header.limit; copy.via_header_index = regs.sampler_binding == Maxwell::SamplerBinding::ViaHeaderBinding; + copy.native_tiling = gpu_memory->UsesNativeTiling(); copy.viewport_scale_offset_enabled = regs.viewport_scale_offset_enabled; if (has_hle_engine_state) { copy.replace_table = maxwell3d->replace_table; diff --git a/src/video_core/shader_environment.h b/src/video_core/shader_environment.h index 79d884224c..6057653165 100644 --- a/src/video_core/shader_environment.h +++ b/src/video_core/shader_environment.h @@ -193,6 +193,7 @@ class GraphicsEnvironment final : public GenericEnvironment { DAddr texture_pool{}; u32 texture_pool_limit{}; bool via_header_index{}; + bool native_tiling{}; ///< Switch 2: handles may hold a header byte offset (TextureHeaderIndex) u32 viewport_scale_offset_enabled{}; ::Common::unordered_map From bc5a2219d24e4063802e53e7d788981ae443bbae Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:59 -0500 Subject: [PATCH 135/154] loader: list a package's directory once for its sidecars FindPackageSidecar looked each extension up on its own (four for the icon); a content directory on Android is listed by every lookup. One listing serves all of them. CodeRabbit on #503 (nitpick): nsp.cpp:47-51. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/core/loader/nsp.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/loader/nsp.cpp b/src/core/loader/nsp.cpp index 48198ea922..66c77de355 100644 --- a/src/core/loader/nsp.cpp +++ b/src/core/loader/nsp.cpp @@ -44,9 +44,15 @@ FileSys::VirtualFile FindPackageSidecar(const FileSys::VirtualFile& package, return nullptr; } const auto stem = PackageStem(package); + // One listing of the directory for all the extensions (a content directory on Android is + // listed by every lookup). + const auto files = dir->GetFiles(); for (const auto extension : extensions) { - if (auto sidecar = dir->GetFile(stem + '.' + std::string{extension})) { - return sidecar; + const std::string wanted = stem + '.' + std::string{extension}; + for (const auto& file : files) { + if (file != nullptr && file->GetName() == wanted) { + return file; + } } } return nullptr; From b16ae17521a18e79feb4ab3f016648dfa3b7a07c Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:14:59 -0500 Subject: [PATCH 136/154] loader: read a packed dump's RomFS version only when asked for The loader extracted the RomFS of a packed dump in its constructor to read the product version, which the game list and title lookups never need. It is read in ReadDisplayVersion, and only when the control data has no version string. CodeRabbit on #503 (nitpick): deconstructed_rom_directory.cpp:376-379. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/core/loader/deconstructed_rom_directory.cpp | 10 ++++++++-- src/core/loader/deconstructed_rom_directory.h | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index d2f3463ac2..d00f49e14d 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -374,8 +374,10 @@ void AppLoader_DeconstructedRomDirectory::ReadDumpMetadata(const FileSys::Virtua if (const auto title_dir = file_dir->GetParentDirectory()) { romfs_version = RomFSProductVersion(FindLooseRomFSDirectory(title_dir)); } else if (LooseRomFS() != nullptr) { - // A packed dump (.nx2) has no parent directory: its RomFS is the game.romfs image in it. - romfs_version = RomFSProductVersion(FileSys::ExtractRomFS(romfs)); + // A packed dump (.nx2) has no parent directory: its RomFS is the game.romfs image in it, + // extracted only if the version is asked for and the control data has none + // (ReadDisplayVersion): most loaders are made for the game list and never need it. + romfs_version_pending = true; } } @@ -599,6 +601,10 @@ ResultStatus AppLoader_DeconstructedRomDirectory::ReadDisplayVersion(std::string version = control->GetVersionString(); return ResultStatus::Success; } + if (romfs_version_pending) { + romfs_version_pending = false; + romfs_version = RomFSProductVersion(FileSys::ExtractRomFS(romfs)); + } if (!romfs_version.empty()) { version = romfs_version; return ResultStatus::Success; diff --git a/src/core/loader/deconstructed_rom_directory.h b/src/core/loader/deconstructed_rom_directory.h index f2dfd7848a..a4cc5f1265 100644 --- a/src/core/loader/deconstructed_rom_directory.h +++ b/src/core/loader/deconstructed_rom_directory.h @@ -76,6 +76,7 @@ class AppLoader_DeconstructedRomDirectory final : public AppLoader { std::string name; std::optional control; ///< control.nacp next to the dump, if it has one std::string romfs_version; ///< "1.8.0" from the RomFS's Product.180 tables, if any + bool romfs_version_pending{}; ///< A packed dump's RomFS version, read when asked for u64 title_id{}; bool override_update; From e42ce2b4d24ec7cb4ddcca00c370cd098d7bd6ef Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:15:00 -0500 Subject: [PATCH 137/154] nx2:gsv: a retired decoder mapping is dropped only if it is the one registered A mapping 426 made is dropped a second after its pool, memory or image goes (RetireDecoderMapping); the drop matched the registry by address and size, so a mapping made at the same address and size meanwhile (a 211 mapping, or a pool made again that 426 registered anew) was removed in its place. Every registration gets a generation; the image keeps its mapping's, it travels with the mapping when another image or a remade pool takes it over, and the registry undoes a registration only when the generation still matches. CodeRabbit on #503 (security note): RetireDelay retirement vs command 211. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../service/nvdrv/devices/nx2_stub_device.h | 21 +++++--- src/core/hle/service/nx2_gsv.h | 49 ++++++++++++------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h index a1dc8d0342..83fe8a7b31 100644 --- a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h +++ b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h @@ -54,17 +54,21 @@ class Nx2GpuMemoryRegistry { return registry; } - void Register(u64 address, u64 size) { - MapAt(address, address, size); + /// Returns the mapping's generation: what UnregisterIfUnchanged needs to undo this very + /// registration and not one made since over the range. + u64 Register(u64 address, u64 size) { + return MapAt(address, address, size); } - void MapAt(u64 gpu_address, u64 cpu_address, u64 size) { + u64 MapAt(u64 gpu_address, u64 cpu_address, u64 size) { std::scoped_lock lk{mutex}; TrimMappings(gpu_address, size); - mappings.push_back({gpu_address, cpu_address, size}); + const u64 generation = ++next_generation; + mappings.push_back({gpu_address, cpu_address, size, generation}); for (auto& [owner, listener] : listeners) { listener.map(gpu_address, cpu_address, size); } + return generation; } // NormalUnmapGpuMemory4K, or destroying the memory: the GPU range stops reaching it. @@ -80,12 +84,15 @@ class Nx2GpuMemoryRegistry { // thread, after the command lists already submitted) and releases its backing unless another // mapping of the same range shares it. A mapping made since over the range replaced it, and // its GPU range and backing belong to that one now: then nothing is done (false). - bool UnregisterIfUnchanged(u64 address, u64 size) { + /// `generation`: Register's result for the mapping to undo. A mapping made since over the + /// range (another Register, or 211 at the address) has a newer one and is left alone. + bool UnregisterIfUnchanged(u64 address, u64 size, u64 generation) { bool shared{}; { std::scoped_lock lk{mutex}; const auto it = std::ranges::find_if(mappings, [&](const Mapping& m) { - return m.gpu_address == address && m.cpu_address == address && m.size == size; + return m.gpu_address == address && m.cpu_address == address && m.size == size && + m.generation == generation; }); if (it == mappings.end()) { return false; @@ -280,7 +287,9 @@ class Nx2GpuMemoryRegistry { u64 gpu_address; u64 cpu_address; u64 size; + u64 generation; ///< Which registration this is (MapAt's result) }; + u64 next_generation{}; struct Listeners { Listener map; diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 94c3152a7c..fc1d02a5de 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1624,12 +1624,15 @@ struct Nx2NativeObjects { bool gpu_read{}; u32 gpu_maps{}; // outstanding 426 maps bool mapped_here{}; // 426 added its GPU mapping (no other mapping covered it) + u64 map_generation{}; // that mapping's registration (Nx2GpuMemoryRegistry::Register) }; // Mappings 426 made for decoder images that went while the client still had them mapped, // dropped a while later (INx2GraphicsSystemSession::RetireDecoderMapping). + // Also a mapping 426 made, taken from its image (TakeDecoderMapping) to be dropped. struct RetiredMapping { u64 address; u64 size; + u64 generation; ///< Its registration: a newer one at the address is kept when it goes }; std::mutex mutex; std::map memories; @@ -3888,7 +3891,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework>> unmaps; + std::vector> unmaps; { std::scoped_lock lk{m_objects->mutex}; for (auto& [image_id, image] : m_objects->images) { @@ -4059,7 +4062,12 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmutex}; + if (const auto image = m_objects->images.find(handle); image != m_objects->images.end()) { + image->second.map_generation = generation; + } } LOG_INFO(Service, "nx2:gsv {} decoder image {} at {:#x}+{:#x}{}", ctx.GetCommand(), handle, address, size, register_mapping ? ", mapped for the GPU" : ""); @@ -4091,7 +4099,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework> unmap; + std::optional unmap; { std::scoped_lock lk{m_objects->mutex}; if (const auto image = m_objects->images.find(handle); @@ -4110,8 +4118,8 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmutex held. // `freed`: the memory being destroyed (112), whose images can't keep the mapping. - std::optional> TakeDecoderMapping(Nx2NativeObjects::Image& image, - std::optional freed = {}) { + std::optional TakeDecoderMapping( + Nx2NativeObjects::Image& image, std::optional freed = {}) { image.gpu_maps = 0; if (!std::exchange(image.mapped_here, false)) { return std::nullopt; @@ -4132,21 +4140,21 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkmemories.end() && other_memory->second.address + other.offset == address) { other.mapped_here = true; + other.map_generation = image.map_generation; return std::nullopt; } } - return std::pair{address, image.size}; + return Nx2NativeObjects::RetiredMapping{address, image.size, image.map_generation}; } // Drops a mapping 426 made. The GPU thread unmaps it in order, after the command lists // already submitted, and the texture cache frees its images once the GPU is past them. A // mapping made since over the range owns it now and is left alone. - static void DropDecoderMapping(const std::optional>& range) { - if (range && - !Nvidia::Devices::Nx2GpuMemoryRegistry::Get().UnregisterIfUnchanged(range->first, - range->second)) { + static void DropDecoderMapping(const std::optional& range) { + if (range && !Nvidia::Devices::Nx2GpuMemoryRegistry::Get().UnregisterIfUnchanged( + range->address, range->size, range->generation)) { LOG_INFO(Service, "nx2:gsv decoder image mapping {:#x}+{:#x} was replaced, kept", - range->first, range->second); + range->address, range->size); } } @@ -4156,13 +4164,15 @@ class INx2GraphicsSystemSession final : public ServiceFramework>& range) { + void RetireDecoderMapping(const std::optional& range) { if (!range) { return; } + // With its registration's generation: one made since at the address (a pool made again, + // or a 211 mapping) is a newer one and stays when this one is dropped. { std::scoped_lock lk{m_objects->mutex}; - m_objects->retired.push_back({range->first, range->second}); + m_objects->retired.push_back(*range); } if (!m_retire_event) { m_retire_event = Core::Timing::CreateEvent( @@ -4175,12 +4185,12 @@ class INx2GraphicsSystemSession final : public ServiceFrameworkfirst, - range->second, RetireDelay.count()); + LOG_INFO(Service, "nx2:gsv decoder image mapping {:#x}+{:#x} goes in {} ms", range->address, + range->size, RetireDelay.count()); } static void DropRetiredMapping(Nx2NativeObjects& objects) { - std::optional> drop; + std::optional drop; { std::scoped_lock lk{objects.mutex}; if (objects.retired.empty()) { @@ -4188,7 +4198,7 @@ class INx2GraphicsSystemSession final : public ServiceFrameworksecond.address + image.offset == retired.address) { image.mapped_here = true; + image.map_generation = retired.generation; drop.reset(); break; } @@ -4265,7 +4276,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework> unmap; + std::optional unmap; { std::scoped_lock lk{m_objects->mutex}; if (const auto image = m_objects->images.find(id); image != m_objects->images.end()) { @@ -4284,7 +4295,7 @@ class INx2GraphicsSystemSession final : public ServiceFramework>> unmaps; + std::vector> unmaps; u32 count{}; { std::scoped_lock lk{m_objects->mutex}; From 97f658bef24b579a461a14ecc5c63c67581e6a57 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:26:48 -0500 Subject: [PATCH 138/154] vulkan: back out backing a written sparse-layers image whose layers failed e7502d45ee let a written layered-sparse image on a phone fall through to the "back it whole" path instead of the UnwrittenSparse mark. Mario Kart World then crashed ~11 s into boot, three runs, in the Vulkan worker: the fault is in BlitImageHelper::BlitColor's layout transition (vulkan_wrapper.h:1504 via blit_image.cpp:558/794), an image barrier on an image with no valid handle, which only such an image can present (the mark is what keeps every user off an unbacked image). Back to the mark; the sampled-black case the review named needs a fix verified on the device, not this one. CodeRabbit on #503: vk_texture_cache.cpp:2798 (deferred). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_texture_cache.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index a08f7b3564..18c866b53d 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -2776,10 +2776,10 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu HostImageBytes(runtime_.device, host_info, 0) <= SparseLayersCap(runtime_.device); const bool layered_sparse = UsesSparseLayers(runtime_.device, info, gpu_addr_); - const bool unwritten = IsUnwrittenSparse(runtime_.device, info, gpu_addr_); if (layered_sparse && InitSparseLayers()) { flags |= VideoCommon::ImageFlagBits::SparseLayers; - } else if (layered_sparse && !backable && !unwritten) { + } else if (layered_sparse && !backable && + !IsUnwrittenSparse(runtime_.device, info, gpu_addr_)) { // The driver's sparse block can't be bound by: back it as before SparseLayers (on a // phone, the `backable` path below does, past the size other images are capped at). original_image = MakeImage( @@ -2794,11 +2794,10 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu } } else if (UsesSparseTiles(runtime_.device, info, gpu_addr_) && InitSparseTiles()) { flags |= VideoCommon::ImageFlagBits::SparseTiles; - } else if ((texels <= (u64{1} << 28) && (!layered_sparse || unwritten)) || - (backable && unwritten)) { + } else if (texels <= (u64{1} << 28) || + (backable && IsUnwrittenSparse(runtime_.device, info, gpu_addr_))) { // Not too big to back (see MemoryAllocator::CreateImage): unbacked because nothing - // was written there yet, so the first write gets it replaced. A layered-sparse image - // already written (its sparse layers could not be set up) is backed below instead. + // was written there yet, so the first write gets it replaced. flags |= VideoCommon::ImageFlagBits::UnwrittenSparse; } else if (backable) { vk::Image backed = MakeImage( From 467c02103e762b68feb441e7226c015f9bf02d43 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:26:49 -0500 Subject: [PATCH 139/154] vulkan: the shared compute scratch's barrier leaves the render pass; EDEN_NX2_SCRATCH_REUSE The compute-to-compute barrier between dispatches sharing a pipeline's emulated shared memory region (7187c7772f) was recorded from Configure, which the rasterizer runs before it leaves the render pass for the dispatch: a memory barrier inside a render pass. It asks for the outside-render-pass context first, as the dispatch does. EDEN_NX2_SCRATCH_REUSE=0 takes a region per dispatch as before, to tell the reuse apart on the device. CodeRabbit on #503: vk_compute_pipeline.cpp:302 (follow-up). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_compute_pipeline.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index a3930d0f75..28e846f14a 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -306,7 +306,12 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, workgroups = 1; // Nothing runs: one slice keeps the binding valid } const u64 size = (std::min)(workgroups, slices) * info.emulated_shared_memory_size; - if (workgroups >= slices) { + // EDEN_NX2_SCRATCH_REUSE=0: a region per dispatch, as before. + static const bool reuse = [] { + const char* value = std::getenv("EDEN_NX2_SCRATCH_REUSE"); + return value == nullptr || value[0] != '0'; + }(); + if (reuse && workgroups >= slices) { // The whole range: one region per tick for this pipeline, not one per dispatch (an // indirect dispatch does not know its workgroup count; several in one tick took a // full-size region each). Dispatches that share it are ordered by a barrier. @@ -315,6 +320,9 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, shared_scratch_tick = scheduler.CurrentTick(); shared_scratch_size = size; } else { + // The dispatch runs outside the render pass; so must its barrier (the + // rasterizer leaves the pass after Configure). + scheduler.RequestOutsideRenderPassOperationContext(); scheduler.Record([](vk::CommandBuffer cmdbuf) { static constexpr VkMemoryBarrier barrier{ .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, From 593d38faacb26dc463cd412002de41eb2cef24e0 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:26:49 -0500 Subject: [PATCH 140/154] vulkan: query-result uploads record their write tick only 12b1b5d87e obtained SyncValues' destination buffers with MarkAsWritten, which besides the write tick marks the range GPU-modified, arms read traps on it and marks its pages GPU-written for the command list series: the query cache writes the values to guest memory itself when the fence passes, so none of that applies to these words (and every semaphore word the CPU polls would have trapped). A new ObtainBufferOperation::NoteGpuWrite records the tick alone, which is what a direct host read of the buffer waits on. CodeRabbit on #503: buffer_cache.h:2597 (narrowed). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/buffer_cache/buffer_cache.h | 3 +++ src/video_core/buffer_cache/buffer_cache_base.h | 3 +++ src/video_core/renderer_vulkan/vk_query_cache.cpp | 8 +++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/video_core/buffer_cache/buffer_cache.h b/src/video_core/buffer_cache/buffer_cache.h index 6539174e45..909086dcd5 100644 --- a/src/video_core/buffer_cache/buffer_cache.h +++ b/src/video_core/buffer_cache/buffer_cache.h @@ -448,6 +448,9 @@ std::pair BufferCache

::ObtainCPUBuffer( case ObtainBufferOperation::MarkAsWritten: MarkWrittenBuffer(buffer_id, device_addr, size); break; + case ObtainBufferOperation::NoteGpuWrite: + NoteGpuWrite(slot_buffers[buffer_id]); + break; case ObtainBufferOperation::DiscardWrite: { ClearDownload(device_addr, size); gpu_modified_ranges.Subtract(device_addr, size); diff --git a/src/video_core/buffer_cache/buffer_cache_base.h b/src/video_core/buffer_cache/buffer_cache_base.h index 23fbeeb7ab..f3b8026832 100644 --- a/src/video_core/buffer_cache/buffer_cache_base.h +++ b/src/video_core/buffer_cache/buffer_cache_base.h @@ -81,6 +81,9 @@ enum class ObtainBufferOperation : u32 { MarkAsWritten = 1, DiscardWrite = 2, MarkQuery = 3, + /// The buffer is written on the GPU by what follows: record the write tick (a direct host + /// read waits for it) without marking the range modified or trapping its reads. + NoteGpuWrite = 4, }; static constexpr BufferId NULL_BUFFER_ID{0}; diff --git a/src/video_core/renderer_vulkan/vk_query_cache.cpp b/src/video_core/renderer_vulkan/vk_query_cache.cpp index aa54028056..dfd385630c 100644 --- a/src/video_core/renderer_vulkan/vk_query_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_query_cache.cpp @@ -1643,9 +1643,11 @@ void QueryCacheRuntime::SyncValues(std::span values, VkBuffer ba impl->buffers_to_upload_to.clear(); for (auto& pair : impl->little_cache) { static constexpr auto sync_info = VideoCommon::ObtainBufferSynchronize::FullSynchronize; - // The copies below write the buffer on the GPU: marked as such, so a host read of - // it waits for them (GpuWriteTick) and flushes them, as after any other GPU write. - const auto post_op = VideoCommon::ObtainBufferOperation::MarkAsWritten; + // The copies below write the buffer on the GPU: its write tick moves, so a direct + // host read of it waits for them. (Not MarkAsWritten: the query cache writes the + // values to guest memory itself when the fence passes, so the range is not marked + // GPU-modified nor its reads trapped.) + const auto post_op = VideoCommon::ObtainBufferOperation::NoteGpuWrite; const auto [buffer, offset] = impl->buffer_cache.ObtainCPUBuffer( pair.first, static_cast(pair.second - pair.first), sync_info, post_op); impl->buffers_to_upload_to.emplace_back(buffer->Handle(), offset); From 815e9ef2d0142799c04e2dfa91eb93f306b898e5 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:21:07 -0500 Subject: [PATCH 141/154] vulkan: Switch 2 draws wait for their translation on Android only On desktop the NVIDIA driver took up to 6.8 s to build one of Mario Kart World's pipelines (RTX 4060 Laptop), and waiting for it froze races for up to 16 s: 72-114 frames over 100 ms in 600. Phone drivers build these pipelines in milliseconds, and there waiting removed the skipped-draw flicker. Desktop keeps upstream's wait budget (EDEN_NX2_ASYNC_TRANSLATION_WAIT_MS) and skips the draw meanwhile. EDEN_NX2_SWITCH2_TRANSLATION_WAIT=1 always waits for Switch 2 draws, =0 never. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- .../renderer_vulkan/vk_pipeline_cache.cpp | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp index 98e2b8a2a6..a6b0484779 100644 --- a/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_pipeline_cache.cpp @@ -1560,11 +1560,25 @@ std::optional> PipelineCache::QueueGraphicsTranslation( } bool PipelineCache::WaitForGraphicsTranslation(std::shared_future done) { - // A Switch 2 program's draw is never skipped (BuiltPipeline: Mario Kart World raced in - // another scene's lighting after skipped draws): it waits for the translation, which still - // runs on a worker alongside the others. Otherwise waiting only helps when the job starts - // right away: behind a backlog it would spend the whole budget without getting the pipeline. - const bool must_wait = Settings::CurrentProgramIsSwitch2(); + // On Android a Switch 2 program's draw is never skipped (BuiltPipeline: Mario Kart World + // raced in another scene's lighting after skipped draws, and flickered): it waits for the + // translation, which still runs on a worker alongside the others. Phone drivers build these + // pipelines in milliseconds. On desktop the NVIDIA driver took up to 6.8 s for one of Mario + // Kart World's, and waiting froze races for up to 16 s, so desktop keeps the wait budget + // below. EDEN_NX2_SWITCH2_TRANSLATION_WAIT=1 always waits for Switch 2 draws, =0 never. + // Otherwise waiting only helps when the job starts right away: behind a backlog it would + // spend the whole budget without getting the pipeline. + static const bool wait_for_switch2 = [] { + if (const char* value = std::getenv("EDEN_NX2_SWITCH2_TRANSLATION_WAIT")) { + return value[0] != '0'; + } +#ifdef __ANDROID__ + return true; +#else + return false; +#endif + }(); + const bool must_wait = wait_for_switch2 && Settings::CurrentProgramIsSwitch2(); if (!must_wait && (translation_wait_budget.count() == 0 || translating_graphics.size() > num_translation_threads)) { return false; From 8800c68986b3e0b92c0977eb1d88cccf896cce71 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:25:00 -0500 Subject: [PATCH 142/154] nx2:vi2: cap vsync games' presents at the swap interval instead of not pacing them Games that bind a vsync event were left unpaced, so as not to pace them to a grid of another phase than Eden's vblank timer (Mario Kart World held at 27-30 fps on an Odin 3). On a fast PC that let Mario Kart World run past 60 fps, and its game time follows its frames. Their presents are now capped at the swap interval on average, with one present of slack: a game slower than the cap never sleeps, a faster one is held to it. Fortune's Weave binds no vsync event and keeps the grid pacing. Assisted-by: Claude Opus 5.5 (implementation and debugging) --- src/core/hle/service/nx2_gsv.h | 35 +++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index fc1d02a5de..cdb8999e6c 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2051,22 +2051,20 @@ class INx2Vi2Session final : public ServiceFramework { return; } // A game that waits on the vsync event between frames already runs on the vblank timer. - // Pacing its presents too, to a clock of another phase, made it miss that timer's next + // Pacing its presents to a grid too, of another phase, made it miss that timer's next // vblank: Mario Kart World, which binds one, ran at 27-30 fps with 4-14 ms of sleep a - // frame while Eden's GPU threads were idle half the time. Fortune's Weave binds none - // and keeps its pacing (at every vblank it ran at double speed). - // EDEN_NX2_PACE_VSYNC_GAMES=1 paces them anyway. + // frame while Eden's GPU threads were idle half the time. Not pacing it at all let it run + // past 60 fps on a fast PC, and its game time follows its frames. So such a game's + // presents are only capped at the swap interval on average, one present of slack + // allowed, which never sleeps a game that runs slower than that. Fortune's Weave binds no + // vsync event and keeps the grid (at every vblank it ran at double speed). + // EDEN_NX2_PACE_VSYNC_GAMES=1 paces them to the grid anyway. static const bool pace_vsync_games = [] { const char* value = std::getenv("EDEN_NX2_PACE_VSYNC_GAMES"); return value != nullptr && value[0] == '1'; }(); - if (m_game_waits_for_vsync && !pace_vsync_games && forced_interval == 0 && - !(is_mkw && mkw_pacing == 1)) { - if (!std::exchange(m_logged_vsync_pacing, true)) { - LOG_INFO(Service, "nx2:vi2 presents not paced: the game waits on the vsync event"); - } - return; - } + const bool cap_only = m_game_waits_for_vsync && !pace_vsync_games && + forced_interval == 0 && !(is_mkw && mkw_pacing == 1); u32 swap_interval = std::clamp(timing >> 16, 1, 4); if (mkw_dynamic || (is_mkw && mkw_pacing == 1)) { swap_interval = 1; @@ -2082,6 +2080,21 @@ class INx2Vi2Session final : public ServiceFramework { const auto interval = std::chrono::nanoseconds{ 100'000'000'000LL * swap_interval / refresh_rate / Settings::SpeedLimit()}; const auto now = std::chrono::steady_clock::now(); + if (cap_only) { + if (!std::exchange(m_logged_vsync_pacing, true)) { + LOG_INFO(Service, "nx2:vi2 the game waits on the vsync event: presents capped at " + "the swap interval, not paced to a grid"); + } + // m_next_vblank is the earliest time the next present is due at the capped rate. + m_next_vblank = (std::max)(m_next_vblank, now); + if (const auto early = m_next_vblank - now; early > interval) { + const auto wait = std::chrono::duration_cast(early - interval); + static_cast(Kernel::GetCurrentThread(kernel).Sleep( + kernel, kernel.HardwareTimer().GetTick() + wait.count() + 2)); + } + m_next_vblank += interval; + return; + } if (now > m_next_vblank + interval) { // Running slower than the swap interval: show at once and don't bank the lost time. m_next_vblank = now; From 5f88e4764f5e97a0e4b256f0e2b3a9d1ca0d0f5b Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:52 -0500 Subject: [PATCH 143/154] nx2:gsv: the codec-name fallback knows the AV1 names The scan for a codec name elsewhere in the configure buffer listed AVC, VP9, VP8 and HEVC only: a buffer naming AV1 off its start left the codec empty and the AV1 path off. Both spellings Nx2IsAv1Mime accepts are looked for. CodeRabbit on #503: nx2_gsv.h:2802. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/core/hle/service/nx2_gsv.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index cdb8999e6c..e2eecdf19e 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -2784,7 +2784,10 @@ class INx2VideoSession final : public ServiceFramework { if (!name_buf.empty()) { if (codec.empty()) { const std::string_view sv{reinterpret_cast(name_buf.data()), name_buf.size()}; - for (const auto& candidate : {"video/avc", "video/vp9", "video/vp8", "video/hevc"}) { + // Both AV1 spellings Nx2IsAv1Mime accepts, so a buffer naming AV1 off its start + // still configures the AV1 path. + for (const auto& candidate : {"video/avc", "video/vp9", "video/vp8", "video/hevc", + "video/av01", "video/av1"}) { if (sv.find(candidate) != std::string_view::npos) { codec = candidate; break; From b0c84fb5226603ec15d1cb84377cabc8e2322e3b Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:52 -0500 Subject: [PATCH 144/154] texture_cache: no render target for a zero-sized one, not the null view FindRenderTargetView returned NULL_IMAGE_VIEW_ID for a zero-sized target: the null view is slot 0, a valid id, so BindRenderTarget attached it, and it has no format. ImageViewId{} (no render target), as for a garbage-sized one. CodeRabbit on #503: texture_cache.h:3035. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/texture_cache/texture_cache.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index c25716b929..f6bae2da23 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -3032,7 +3032,9 @@ ImageViewId TextureCache

::FindDepthBuffer() { template ImageViewId TextureCache

::FindRenderTargetView(const ImageInfo& info, GPUVAddr gpu_addr) { if (info.size.width == 0 || info.size.height == 0 || info.size.depth == 0) { - return NULL_IMAGE_VIEW_ID; + // No render target, as for a garbage one below: NULL_IMAGE_VIEW_ID is the null view + // (slot 0, a valid id), which BindRenderTarget would attach, and it has no format. + return ImageViewId{}; } // Render target registers filled by garbage commands (sizes like 2147487260x2147485323, or // 0x0 with 36232 layers): creating the image would abort. From a52713238e20f78a098a11a91f6eb26cbb9d7aa9 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:53 -0500 Subject: [PATCH 145/154] vulkan: report a dispatch with more workgroups than emulated shared memory slices The shader clamps its slice index, so workgroups past the last slice share it within one dispatch. Splitting the dispatch would change the workgroup ids the program reads, so it is not done here; a dispatch that would alias is logged (the first few) so it is known whether any game reaches the limit (256 MiB or the host's storage range, per workgroup size). CodeRabbit on #503: spirv_emit_context.cpp:532 (deferred; detection only). Assisted-by: Claude Opus 5.5 (profiling and implementation) --- .../renderer_vulkan/vk_compute_pipeline.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp index 28e846f14a..7176d45eec 100644 --- a/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/src/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -304,6 +304,17 @@ bool ComputePipeline::Configure(Tegra::Engines::KeplerCompute& kepler_compute, workgroups = slices; } else if (workgroups == 0) { workgroups = 1; // Nothing runs: one slice keeps the binding valid + } else if (workgroups > slices) { + // The shader clamps its slice index: workgroups past the last slice share it within + // this one dispatch (the limit is the host's storage buffer range). Not split here + // (the program reads its workgroup id); reported so it is known to happen. + static std::atomic reported{}; + if (reported.fetch_add(1, std::memory_order_relaxed) < 8) { + LOG_WARNING(Render_Vulkan, + "Compute dispatch of {} workgroups with {} bytes of emulated shared " + "memory each: only {} slices fit, the rest share the last", + workgroups, info.emulated_shared_memory_size, slices); + } } const u64 size = (std::min)(workgroups, slices) * info.emulated_shared_memory_size; // EDEN_NX2_SCRATCH_REUSE=0: a region per dispatch, as before. From 5977a9b646a48d8efc347f62c41754663156c9bb Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:53 -0500 Subject: [PATCH 146/154] buffer_cache: a small uniform buffer's snapshot is the slot's copy With the guest bytes compared in place, the stream was filled from guest memory and the slot's copy taken from it again afterwards: a guest write in between left the two different, and a later bind that matched the copy could rebind stream data that never held those bytes. The slot's copy is taken once and the stream is written from it. CodeRabbit on #503: uniform_stream_reuse.h:121. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/buffer_cache/uniform_stream_reuse.h | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/video_core/buffer_cache/uniform_stream_reuse.h b/src/video_core/buffer_cache/uniform_stream_reuse.h index a3e23d4c69..9fd6496b20 100644 --- a/src/video_core/buffer_cache/uniform_stream_reuse.h +++ b/src/video_core/buffer_cache/uniform_stream_reuse.h @@ -117,15 +117,18 @@ template runtime.RebindStreamUniform(slot.ref, size); return true; } - const std::span span = runtime.BindMappedUniformBuffer(stage, binding_index, size); - std::memcpy(span.data(), bytes, 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) { + // One snapshot, the slot's copy, which the stream is then written from: the guest may + // write the range meanwhile, and two reads of it could leave the stream and the copy + // (what the next bind compares against) different. slot.size = size; std::memcpy(slot.bytes.data(), bytes, size); } + const std::span span = runtime.BindMappedUniformBuffer(stage, binding_index, size); + std::memcpy(span.data(), slot.bytes.data(), 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(); return true; } From 4bc2c5d0cff0003a69b5eda66f6ebf1fa6f709a1 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:54 -0500 Subject: [PATCH 147/154] macro: the front's same-value elision is cleared after each 3D macro SetElision left the 3D engine's elision tables in the JIT the compute engine's front macros can share: a compute emit could have been skipped against the 3D registers. The pointer is cleared once the macro ran. CodeRabbit on #503: maxwell_3d.cpp:1286. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/engines/maxwell_3d.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/video_core/engines/maxwell_3d.cpp b/src/video_core/engines/maxwell_3d.cpp index f8872e0a2a..dca140409d 100644 --- a/src/video_core/engines/maxwell_3d.cpp +++ b/src/video_core/engines/maxwell_3d.cpp @@ -1290,6 +1290,9 @@ void Maxwell3D::FrontRunMacro(Core::System& system, VideoCommon::GpuSplit& split emit) : engine.native_interpreter.Execute(engine.native_code, start, fs.parameters, read, emit); #if defined(ARCHITECTURE_arm64) && defined(__ANDROID__) + // Not left behind: the compute engine's front macros can run through the same JIT, and + // must never elide against this engine's registers. + engine.native_jit->SetElision(nullptr); if (const u32 skipped = fs.elision.skipped; skipped != 0) { fs.elision.skipped = 0; if (VideoCommon::Nx2Profile::Enabled()) [[unlikely]] { From a83c95f491cd1ea82f46eab0293c3bcedaa10909 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:54 -0500 Subject: [PATCH 148/154] nx2:split: the semaphore ledger and the queued words go with the split GpuSplit::Stop restarts the ring positions at 0, but the ledger kept entries whose orders were positions of the stopped session: after a restart they read as still queued and could meet an acquire early. Stop clears the ledger and the words queued for a multi-method entry. CodeRabbit on #503: puller.cpp:106. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/gpu_split.cpp | 4 ++++ src/video_core/semaphore_ledger.h | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/video_core/gpu_split.cpp b/src/video_core/gpu_split.cpp index d171844449..c7bc8fa38b 100644 --- a/src/video_core/gpu_split.cpp +++ b/src/video_core/gpu_split.cpp @@ -35,6 +35,7 @@ #include "core/core.h" #include "video_core/engines/engine_interface.h" #include "video_core/gpu_split.h" +#include "video_core/semaphore_ledger.h" #include "video_core/nx2.h" #include "video_core/nx2_profile.h" @@ -251,6 +252,9 @@ void GpuSplit::Stop() { front_engine = nullptr; back_engine = nullptr; front_macros = false; + pending_count = 0; + // Its entries carry ring positions (orders) of this session: stale once they restart. + Tegra::Nx2SemaphoreLedger::Get().Clear(); published.store(0, std::memory_order_relaxed); consumed.store(0, std::memory_order_relaxed); back_waiting.store(false, std::memory_order_relaxed); diff --git a/src/video_core/semaphore_ledger.h b/src/video_core/semaphore_ledger.h index fc5c77b1b0..db03a7cce8 100644 --- a/src/video_core/semaphore_ledger.h +++ b/src/video_core/semaphore_ledger.h @@ -57,6 +57,11 @@ class Nx2SemaphoreLedger { entries.erase(device_address); } + /// The split stopped: its ring positions restart at 0, so the orders recorded are stale. + void Clear() { + entries.clear(); + } + [[nodiscard]] const Entry* Find(u64 device_address) const { const auto it = entries.find(device_address); return it == entries.end() ? nullptr : &it->second; From dd251d0367094f6a85b6f7e634163bd87e53452a Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:54 -0500 Subject: [PATCH 149/154] nx2:split: queued words go before the engine is compared A multi-method entry's words could stay queued for one engine while the next entry was for another: SelectEngine compared and switched the engine, then Reserve flushed the queue, whose entry switched the engine back, and the new entry went to the wrong engine on the back. SelectEngine flushes first. CodeRabbit on #503: gpu_split.h:191. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/gpu_split.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/video_core/gpu_split.h b/src/video_core/gpu_split.h index 1cb8a7dd5e..8a17b5ad6e 100644 --- a/src/video_core/gpu_split.h +++ b/src/video_core/gpu_split.h @@ -209,6 +209,11 @@ class GpuSplit { /// Back side: hands the ring up to `position` back, waking a sleeping front. void HandBack(u64 position); void SelectEngine(Tegra::Engines::EngineInterface* engine) { + // Words queued (QueueWord) go first, before the engine is compared: their entry selects + // their own engine, which may differ from this one, and from front_engine as it is now. + if (pending_count != 0) [[unlikely]] { + FlushPending(); + } if (engine != front_engine) [[unlikely]] { SwitchEngine(engine); } From a1613b22da4d20cd3b8eb9e66437b068b214791e Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:55 -0500 Subject: [PATCH 150/154] vulkan: the submit threshold is checked at every draw With a long dispatch interval (EDEN_NX2_DISPATCH_DRAWS) the 128-draw submit only happened at a dispatch, so recorded work waited for the next one to reach the GPU. The two are checked independently. CodeRabbit on #503: vk_rasterizer.cpp:1590. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/renderer_vulkan/vk_rasterizer.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_rasterizer.cpp b/src/video_core/renderer_vulkan/vk_rasterizer.cpp index 1543b2697a..1076e25407 100644 --- a/src/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/src/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -1587,9 +1587,8 @@ void RasterizerVulkan::FlushWork() { } return static_cast(parsed - 1); }(); - if ((++draw_counter & check_mask) != 0) { - return; - } + ++draw_counter; + const bool dispatch_now = (draw_counter & check_mask) == 0; // Keep the host GPU working while native commands are still being recorded. static const u32 native_flush_draws = [] { constexpr u32 fallback = 128; @@ -1607,8 +1606,13 @@ void RasterizerVulkan::FlushWork() { const u32 draws_to_dispatch = Tegra::g_nx2_ampere.load(std::memory_order_relaxed) ? native_flush_draws : DRAWS_TO_DISPATCH; + // The submit threshold is checked at every draw, whatever the dispatch interval: with a + // long one (EDEN_NX2_DISPATCH_DRAWS), recorded work would otherwise wait for the next + // dispatch to reach the GPU. if (draw_counter < draws_to_dispatch) { - scheduler.DispatchWork(); + if (dispatch_now) { + scheduler.DispatchWork(); + } return; } VideoCommon::Nx2Profile::Scope submit_scope{VideoCommon::Nx2Profile::Zone::FlushSubmit}; From d75a1cb057bb078e853197a21e6e9ad853e64ae8 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:55 -0500 Subject: [PATCH 151/154] video_core: a write to a sparse block that got no memory is dropped, not asserted When no spare block was ready in time, BackSparseWrite left the block empty (nx2:gpu logs the drop) and MemoryManager::Write then hit ASSERT(false). The write is dropped with a rate-limited warning instead. CodeRabbit on #503: nx2_gsv.h:1053-1064. Assisted-by: Claude Opus 5.5 (profiling and implementation) --- src/video_core/memory_manager.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/video_core/memory_manager.cpp b/src/video_core/memory_manager.cpp index c12b7f9ec1..e3edb37eca 100644 --- a/src/video_core/memory_manager.cpp +++ b/src/video_core/memory_manager.cpp @@ -322,8 +322,14 @@ void MemoryManager::Write(GPUVAddr addr, T data) { std::memcpy(page_pointer, &data, sizeof(T)); return; } - - ASSERT(false); + // The block got no memory (no spare ready in time, or the pool exhausted: nx2:gpu logged + // which) or the address is unmapped: the write is dropped, which is not an assertion. + static std::atomic dropped{}; + if (const u32 count = dropped.fetch_add(1, std::memory_order_relaxed); + count < 16 || (count & (count - 1)) == 0) { + LOG_WARNING(HW_GPU, "Write of {} bytes to unmapped GPU address {:#x} dropped ({} so far)", + sizeof(T), addr, count + 1); + } } template u8 MemoryManager::Read(GPUVAddr addr) const; From 84a691ab286b34879930bf11d2437b39bbdd1fc1 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:24:37 -0500 Subject: [PATCH 152/154] cpu_manager: run NCE core threads with the PAC keys disabled Android 15 gives every thread its own PAC A/B keys, and NCE runs guest PAC natively. Ocarina's SDK fiber switch signs the context with pacia on one CPU core thread and checks it with autiasp on another, which fails (SIGILL code 2, FPAC, at sdk+0x16d40c); the hang run spins in the scheduler on the same cores. Each CPU core thread disables the IA, IB, DA and DB keys before any guest code runs on it (PR_PAC_SET_ENABLED_KEYS), so PAC and AUT pass pointers through unchanged, as Dynarmic's identity PAC does. PACGA uses the process-wide GA key and stays consistent. The keys are enabled again before the thread returns, for the libc frames signed before. Android arm64 with NCE only; EDEN_NX2_NCE_PAC_KEYS=1 keeps the keys. Assisted-by: Claude Opus 5.5 --- src/core/cpu_manager.cpp | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/core/cpu_manager.cpp b/src/core/cpu_manager.cpp index 23d5ac3dfd..0a8d1f2386 100644 --- a/src/core/cpu_manager.cpp +++ b/src/core/cpu_manager.cpp @@ -18,8 +18,71 @@ #include "core/hle/kernel/physical_core.h" #include "video_core/gpu.h" +#if defined(__ANDROID__) && defined(ARCHITECTURE_arm64) +#include +#include +#include +#include +#include "common/logging.h" +#ifndef PR_PAC_SET_ENABLED_KEYS +#define PR_PAC_SET_ENABLED_KEYS 60 +#endif +#ifndef PR_PAC_APIAKEY +#define PR_PAC_APIAKEY (1UL << 0) +#define PR_PAC_APIBKEY (1UL << 1) +#define PR_PAC_APDAKEY (1UL << 2) +#define PR_PAC_APDBKEY (1UL << 3) +#endif +#endif + namespace Core { +#if defined(__ANDROID__) && defined(ARCHITECTURE_arm64) +namespace { +// Android gives every thread its own PAC keys, and NCE runs guest PAC natively: a pointer the +// guest signs on one core thread (a fiber context, a return address) fails to authenticate on +// another. NCE core threads run with the instruction and data keys disabled, so PAC/AUT pass +// pointers through unchanged as under Dynarmic. PACGA keeps the process-wide GA key. +// EDEN_NX2_NCE_PAC_KEYS=1 keeps the keys. +constexpr unsigned long NcePacKeys = + PR_PAC_APIAKEY | PR_PAC_APIBKEY | PR_PAC_APDAKEY | PR_PAC_APDBKEY; + +bool DisableNcePacKeys() { + if (!Settings::IsNceEnabled()) { + return false; + } + static const bool keep_keys = [] { + const char* const value = std::getenv("EDEN_NX2_NCE_PAC_KEYS"); + return value != nullptr && *value != '\0' && *value != '0'; + }(); + static std::once_flag logged; + if (keep_keys) { + std::call_once(logged, [] { + LOG_INFO(Core, "NCE: PAC keys kept on the CPU core threads (EDEN_NX2_NCE_PAC_KEYS)"); + }); + return false; + } + const int result = prctl(PR_PAC_SET_ENABLED_KEYS, NcePacKeys, 0, 0, 0); + const int error = errno; + if (result != 0) { + LOG_WARNING(Core, "NCE: could not disable the PAC keys on this CPU core thread (errno {})", + error); + return false; + } + std::call_once(logged, [] { + LOG_INFO(Core, "NCE: PAC instruction and data keys disabled on the CPU core threads"); + }); + return true; +} + +// Before the thread returns, so the libc frames signed before the keys were disabled +// authenticate. +void RestoreNcePacKeys() { + prctl(PR_PAC_SET_ENABLED_KEYS, NcePacKeys, NcePacKeys, 0, 0); +} +} // namespace +#endif + CpuManager::CpuManager(System& system_) : system{system_} {} CpuManager::~CpuManager() = default; @@ -192,6 +255,16 @@ void CpuManager::RunThread(std::stop_token token, std::size_t core) { system.GPU().ObtainContext(); } +#if defined(__ANDROID__) && defined(ARCHITECTURE_arm64) + // Before any guest code runs on this thread (see DisableNcePacKeys). + const bool pac_keys_disabled = DisableNcePacKeys(); + SCOPE_EXIT { + if (pac_keys_disabled) { + RestoreNcePacKeys(); + } + }; +#endif + auto& kernel = system.Kernel(); auto& scheduler = *kernel.CurrentScheduler(); auto* thread = scheduler.GetSchedulerCurrentThread(); From 89d68bfce2f0854b8f3bb391f30f1bf8578098d3 Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:28:45 -0500 Subject: [PATCH 153/154] video_core: drop sink writes past the engine register files Ocarina of Time sends method 0x1001 on the inline-to-memory subchannel. The method is not executable, so it lands in the method sink, and KeplerMemory::ConsumeSinkImpl wrote it to reg_array[0x1001] with reg_array 0x7F words long. On Android this hits a guard page and the GPU back thread dies with SIGSEGV; on desktop it silently corrupts memory. Check the bound in the sink consumers of KeplerMemory, Fermi2D and MaxwellDMA, as KeplerCompute already does, and ignore out-of-range methods in KeplerMemory::CallMethod and Fermi2D::CallMethod instead of asserting and writing anyway, as MaxwellDMA::CallMethod already does. Assisted-by: Claude Opus 5.5 --- src/video_core/engines/fermi_2d.cpp | 10 ++++++++-- src/video_core/engines/kepler_memory.cpp | 10 ++++++++-- src/video_core/engines/maxwell_dma.cpp | 4 +++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/video_core/engines/fermi_2d.cpp b/src/video_core/engines/fermi_2d.cpp index 996661f8dd..a9efada227 100644 --- a/src/video_core/engines/fermi_2d.cpp +++ b/src/video_core/engines/fermi_2d.cpp @@ -40,7 +40,11 @@ void Fermi2D::BindRasterizer(VideoCore::RasterizerInterface* rasterizer_) { } void Fermi2D::CallMethod(Core::System& system, u32 method, u32 method_argument, bool is_last_call) { - ASSERT_MSG(method < Regs::NUM_REGS, "Invalid Fermi2D register, increase the size of the Regs structure"); + if (method >= Regs::NUM_REGS) { + // Switch 2 games can send methods past this register file; don't write past it. + LOG_DEBUG(HW_GPU, "Ignoring Fermi2D method {:#x} past the register file", method); + return; + } regs.reg_array[method] = method_argument; if (method == FERMI2D_REG_INDEX(pixels_from_memory.src_y0) + 1) { @@ -56,7 +60,9 @@ void Fermi2D::CallMultiMethod(Core::System& system, u32 method, const u32* base_ void Fermi2D::ConsumeSinkImpl(Core::System& system) { for (auto [method, value] : method_sink) { - regs.reg_array[method] = value; + if (method < Regs::NUM_REGS) { + regs.reg_array[method] = value; + } } method_sink.clear(); } diff --git a/src/video_core/engines/kepler_memory.cpp b/src/video_core/engines/kepler_memory.cpp index 3af5f91271..3c5cc76f36 100644 --- a/src/video_core/engines/kepler_memory.cpp +++ b/src/video_core/engines/kepler_memory.cpp @@ -30,13 +30,19 @@ void KeplerMemory::BindRasterizer(VideoCore::RasterizerInterface* rasterizer_) { void KeplerMemory::ConsumeSinkImpl(Core::System& system) { for (auto [method, value] : method_sink) { - regs.reg_array[method] = value; + if (method < Regs::NUM_REGS) { + regs.reg_array[method] = value; + } } method_sink.clear(); } void KeplerMemory::CallMethod(Core::System& system, u32 method, u32 method_argument, bool is_last_call) { - ASSERT_MSG(method < Regs::NUM_REGS, "Invalid KeplerMemory register, increase the size of the Regs structure"); + if (method >= Regs::NUM_REGS) { + // Switch 2 games can send methods past this register file; don't write past it. + LOG_DEBUG(HW_GPU, "Ignoring KeplerMemory method {:#x} past the register file", method); + return; + } regs.reg_array[method] = method_argument; diff --git a/src/video_core/engines/maxwell_dma.cpp b/src/video_core/engines/maxwell_dma.cpp index 0b0541799b..875ff2af9e 100644 --- a/src/video_core/engines/maxwell_dma.cpp +++ b/src/video_core/engines/maxwell_dma.cpp @@ -37,7 +37,9 @@ void MaxwellDMA::BindRasterizer(VideoCore::RasterizerInterface* rasterizer_) { void MaxwellDMA::ConsumeSinkImpl(Core::System& system) { for (auto [method, value] : method_sink) { - regs.reg_array[method] = value; + if (method < NUM_REGS) { + regs.reg_array[method] = value; + } } method_sink.clear(); } From 7584d618f8dec5fc1fa8c76fde68489ab5f76c9c Mon Sep 17 00:00:00 2001 From: godpow <17231911+godpow@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:39:23 -0500 Subject: [PATCH 154/154] android: command list fixes on by default, except Mario Kart World nx2_command_lists was off on Android because on an Odin 3 the copies cost Mario Kart World ~45 ms of GPU-thread waiting a second and caused flicker. Without them Ocarina of Time deadlocks about 20 s in: the GPU reads command memory the game is already refilling, a semaphore release is lost and channels 2, 3 and 5 wait on it forever. Default the setting to on everywhere. On Android, Mario Kart World runs without the copies unless its own custom settings set the option; EDEN_NX2_COMMAND_LISTS still overrides both. Assisted-by: Claude Opus 5.5 --- src/android/app/src/main/res/values/strings.xml | 2 +- src/common/settings.h | 14 +++++--------- src/core/hle/service/nvdrv/nvdrv.cpp | 10 ++++++++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 520f47ed5c..656584f290 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -613,7 +613,7 @@ Keeps large compressed textures at half resolution, using about a quarter of their video memory. Helps games that run out of memory, such as Switch 2 titles. Textures look slightly blurrier up close. Takes effect when the game starts. Release GPU-only memory (experimental) Switch 2 command list fixes - Upstream\'s fixes for Switch 2 command lists the game rewrites after submitting. Off on phones: on an Odin 3 they cost ~45 ms of GPU-thread waiting per second in Mario Kart World and caused flicker. Takes effect when a game starts. + Upstream\'s fixes for Switch 2 command lists the game rewrites after submitting. Without them games such as Ocarina of Time can freeze. Mario Kart World runs without them unless its own custom settings turn them on: on an Odin 3 they cost ~45 ms of GPU-thread waiting per second there and caused flicker. Takes effect when a game starts. Mario Kart World: 30 FPS mode Forces the game to use its native 30 FPS mode at normal game speed. Takes effect after restarting the game. Mario Kart World: 60 FPS mode diff --git a/src/common/settings.h b/src/common/settings.h index 411ba0ffda..d02dfef07d 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -365,15 +365,11 @@ struct Values { true, true}; // Upstream's command list series (copies of lists at submit, and of lists the GPU writes). - // Off by default on Android: on an Odin 3 it cost ~45 ms of GPU-thread waiting a second in - // Mario Kart World and caused flicker. - SwitchableSetting nx2_command_lists{linkage, -#ifdef __ANDROID__ - false, -#else - true, -#endif - "nx2_command_lists", Category::RendererHacks}; + // On Android, Mario Kart World runs without it unless its own custom settings turn it on + // (nvdrv.cpp): on an Odin 3 it cost ~45 ms of GPU-thread waiting a second there and caused + // flicker. + SwitchableSetting nx2_command_lists{linkage, true, "nx2_command_lists", + Category::RendererHacks}; SwitchableSetting fsr_sharpening_slider{linkage, #ifdef __ANDROID__ 0, diff --git a/src/core/hle/service/nvdrv/nvdrv.cpp b/src/core/hle/service/nvdrv/nvdrv.cpp index f038489f9c..0770c53200 100644 --- a/src/core/hle/service/nvdrv/nvdrv.cpp +++ b/src/core/hle/service/nvdrv/nvdrv.cpp @@ -9,6 +9,7 @@ #include #include +#include "common/game_presets.h" #include "common/settings.h" #include "core/core.h" #include "core/hle/kernel/k_event.h" @@ -145,6 +146,15 @@ Module::Module(Core::System& system) { // Read once per game start; EDEN_NX2_COMMAND_LISTS=0/1 overrides the setting. bool command_lists = Settings::values.nx2_command_lists.GetValue(); +#ifdef __ANDROID__ + // On an Odin 3 the copies cost Mario Kart World ~45 ms of GPU-thread waiting a second + // and caused flicker, so it runs without them unless its own custom settings turn them + // on. Other games need them: Ocarina of Time deadlocks on the lists it rewrites. + if (system.GetApplicationProcessProgramID() == Settings::MarioKartWorldProgramId && + Settings::values.nx2_command_lists.UsingGlobal()) { + command_lists = false; + } +#endif const char* const override_value = std::getenv("EDEN_NX2_COMMAND_LISTS"); if (override_value != nullptr && override_value[0] != '\0') { command_lists = override_value[0] != '0';