diff --git a/pcsx2/CMakeLists.txt b/pcsx2/CMakeLists.txt index 7c49be0c77..08f0589307 100644 --- a/pcsx2/CMakeLists.txt +++ b/pcsx2/CMakeLists.txt @@ -707,6 +707,59 @@ if(USE_VULKAN) COMPILE_OPTIONS "-fexceptions" SKIP_PRECOMPILE_HEADERS ON) + # Frame generation ported from Eden (eden-emu PR #4263). + # + # Gated on ARMSX2_ENABLE_LSFG rather than on ARMSX2_HAVE_LSFG, and the difference matters: + # ARMSX2_ENABLE_LSFG arrives as a -D on the CMake command line from the Android gradle + # flavour, so it is in the cache before any subdirectory is processed. ARMSX2_HAVE_LSFG is + # set by 3rdparty/lsfg, which is added AFTER this file — testing it here would silently + # evaluate false and drop every source below, producing a build that succeeds with the + # feature missing. Desktop builds never define either, so they skip this entirely. + # + # Unlike GSLsfg.cpp above, none of these need -fexceptions. That carve-out exists because the + # lsfg-vk-android library reports failure by throwing; the Eden port has zero throw sites and + # returns status enums instead, so it builds under the emulator's normal -fno-exceptions. + if(ARMSX2_ENABLE_LSFG) + list(APPEND pcsx2GSSources + GS/Renderers/Vulkan/FrameGen/FrameGen.cpp + GS/Renderers/Vulkan/FrameGen/FrameGenPacer.cpp + GS/Renderers/Vulkan/FrameGen/LosslessDll.cpp + GS/Renderers/Vulkan/FrameGen/LsfgAlpha.cpp + GS/Renderers/Vulkan/FrameGen/LsfgBeta.cpp + GS/Renderers/Vulkan/FrameGen/LsfgChain.cpp + GS/Renderers/Vulkan/FrameGen/LsfgCommon.cpp + GS/Renderers/Vulkan/FrameGen/LsfgDelta.cpp + GS/Renderers/Vulkan/FrameGen/LsfgGamma.cpp + GS/Renderers/Vulkan/FrameGen/LsfgGenerate.cpp + GS/Renderers/Vulkan/FrameGen/LsfgMipmaps.cpp + GS/Renderers/Vulkan/FrameGen/LsfgShaders.cpp + GS/Renderers/Vulkan/FrameGen/LsfgTranslate.cpp + GS/Renderers/Vulkan/FrameGen/LsfgUtil.cpp + GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.cpp + ) + list(APPEND pcsx2GSHeaders + GS/Renderers/Vulkan/FrameGen/FrameGen.h + GS/Renderers/Vulkan/FrameGen/FrameGenPacer.h + GS/Renderers/Vulkan/FrameGen/FrameGenTypes.h + GS/Renderers/Vulkan/FrameGen/LosslessDll.h + GS/Renderers/Vulkan/FrameGen/LsfgAlpha.h + GS/Renderers/Vulkan/FrameGen/LsfgBeta.h + GS/Renderers/Vulkan/FrameGen/LsfgChain.h + GS/Renderers/Vulkan/FrameGen/LsfgCommon.h + GS/Renderers/Vulkan/FrameGen/LsfgDelta.h + GS/Renderers/Vulkan/FrameGen/LsfgGamma.h + GS/Renderers/Vulkan/FrameGen/LsfgGenerate.h + GS/Renderers/Vulkan/FrameGen/LsfgMipmaps.h + GS/Renderers/Vulkan/FrameGen/LsfgShaders.h + GS/Renderers/Vulkan/FrameGen/LsfgTranslate.h + GS/Renderers/Vulkan/FrameGen/LsfgUtil.h + GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.h + ) + # The ported sources include each other by bare filename, as they did upstream. + target_include_directories(PCSX2_FLAGS INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/GS/Renderers/Vulkan/FrameGen") + endif() + # VKLibretro.cpp needs the libretro Vulkan HW-render interface header. # Link it PRIVATE to PCSX2 (not the shared PCSX2_FLAGS) so the header path # doesn't leak onto every target that consumes PCSX2_FLAGS. diff --git a/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.cpp b/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.cpp new file mode 100644 index 0000000000..516fa517a7 --- /dev/null +++ b/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.cpp @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/frame_gen.cpp. +// The orchestration is verbatim; see FrameGen.h for the three structural differences and for why +// the debug image dump is gone. Only the PORT-marked spots below deviate. + +#include +#include +#include + +#include "FrameGen.h" +#include "LsfgChain.h" +#include "LsfgCommon.h" +#include "LsfgShaders.h" +#include "LsfgVkCompat.h" + +#include "GS/Renderers/Vulkan/GSDeviceVK.h" + +namespace Vulkan { + +namespace { + +constexpr u64 LSFG_REQUIRED_FRAMES = 2; +constexpr u32 LSFG_RECURRENCE_FRAMES = 2; + +[[nodiscard]] f32 ManualFlowScale() { + // The clamp is not Eden's: their setting type enforces 25..100 on the way in, ours is a plain + // u8 that a hand-edited INI can hold anything in, and LsfgResources feeds this straight into + // 1.0f / flow_scale. GSLsfg.cpp clamps the same way for the same reason. + return static_cast(std::clamp(GSConfig.LsfgFlowScale, 25, 100)) / 100.0f; +} + +[[nodiscard]] f32 ConfiguredFlowScale(VkExtent2D guest_extent, VkExtent2D presented_extent) { + // PORT: Eden gates the automatic path on frame_gen_flow_scale_auto, a toggle that defaults on + // and that PCSX2 has no equivalent for. 100% — our default, and the top of the 25..100 range + // the UI offers — reads as "do not reduce the flow resolution", and the automatic result is + // clamped to 1.0 anyway, so treating it as Eden's auto mode preserves both projects' default + // behaviour. Any explicit value below 100 pins the scale exactly where the user put it. + if (GSConfig.LsfgFlowScale < 100) { + return ManualFlowScale(); + } + if (guest_extent.width == 0 || presented_extent.width == 0) { + return 1.0f; + } + + // PORT: Eden scales by resolution_info.up_factor because its guest_extent is the console's own + // resolution. Ours is the size the game was really rendered at, upscale already applied. + const f32 rendered_width = static_cast(guest_extent.width); + const f32 ratio = rendered_width / static_cast(presented_extent.width); + + constexpr f32 FLOW_SCALE_STEPS = 20.0f; + const f32 stepped = std::ceil(ratio * FLOW_SCALE_STEPS) / FLOW_SCALE_STEPS; + return std::clamp(stepped, 0.25f, 1.0f); +} + +VkImageMemoryBarrier MakeTransitionBarrier(VkImage image, VkAccessFlags src_access, + VkAccessFlags dst_access, VkImageLayout old_layout, + VkImageLayout new_layout) { + return VkImageMemoryBarrier{ + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .pNext = nullptr, + .srcAccessMask = src_access, + .dstAccessMask = dst_access, + .oldLayout = old_layout, + .newLayout = new_layout, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image, + .subresourceRange{ + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .baseMipLevel = 0, + .levelCount = 1, + .baseArrayLayer = 0, + .layerCount = 1, + }, + }; +} + +VkImageCopy MakeCopyRegion(VkExtent2D extent) { + return VkImageCopy{ + .srcSubresource{ + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1, + }, + .srcOffset = {}, + .dstSubresource{ + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1, + }, + .dstOffset = {}, + .extent = {.width = extent.width, .height = extent.height, .depth = 1}, + }; +} + +void CopyPresentedFrame(vk::CommandBuffer cmdbuf, VkImage source, LsfgImage& destination, + VkExtent2D extent) { + const auto make_barrier = MakeTransitionBarrier; + + const std::array before{ + make_barrier(source, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL), + make_barrier(destination.Handle(), VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + destination.Layout(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL), + }; + cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, {}, {}, before); + + // PORT: yuzu's vk::Span takes a lone VkImageCopy; the shim's std::span needs a range. + const std::array regions{MakeCopyRegion(extent)}; + cmdbuf.CopyImage(source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination.Handle(), + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, regions); + + const std::array after{ + make_barrier(source, VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL), + make_barrier(destination.Handle(), VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL), + }; + cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + 0, {}, {}, after); + + destination.SetLayout(VK_IMAGE_LAYOUT_GENERAL); +} + +} // Anonymous namespace + +FrameGen::FrameGen(MemoryAllocator& memory_allocator_, GSDeviceVK* device_) + : memory_allocator{memory_allocator_}, gs_device{device_} {} + +FrameGen::~FrameGen() { + if (chain) { + // ★ Device idle before teardown — see WaitForIdle. The chain is about to be destroyed by + // the member destructor, and its images and pipelines go with it immediately. + WaitForIdle(); + } +} + +void FrameGen::Process(const Device& device, vk::CommandBuffer cmdbuf, VkImage image, + VkImageView storage_view, VkExtent2D extent, VkFormat format, + VkExtent2D guest_extent) { + generated = false; + + if (unavailable || !GSConfig.LsfgEnabled) { + if (chain) { + // ★ Device idle before teardown — see WaitForIdle. Stands in for Eden's + // scheduler.Finish() ahead of the same chain.reset(). + WaitForIdle(); + chain.reset(); + } + warm_streak = 0; + return; + } + + if (storage_view == VK_NULL_HANDLE) { + unavailable = true; + return; + } + + if (!shaders) { + shaders.emplace(device); + if (!shaders->IsValid()) { + unavailable = true; + return; + } + } + + peak_guest_extent.width = std::max(peak_guest_extent.width, guest_extent.width); + peak_guest_extent.height = std::max(peak_guest_extent.height, guest_extent.height); + + const f32 flow_scale = ConfiguredFlowScale(peak_guest_extent, extent); + if (!chain || built_extent.width != extent.width || built_extent.height != extent.height || + built_format != format || built_flow_scale != flow_scale) { + Rebuild(device, extent, format, flow_scale); + } + + const u64 count = frame_count++; + last_count = count; + last_generations = plan.generations; + + const bool warm = plan.warm && count + 1 >= LSFG_REQUIRED_FRAMES; + warm_streak = warm ? warm_streak + 1 : 0; + generated = warm && warm_streak >= LSFG_RECURRENCE_FRAMES && plan.generations > 0; + + // PORT: Eden asks the scheduler for an outside-render-pass context and defers the recording + // into a callback. Here the caller is already outside any render pass and hands us the buffer + // to record into — see the note on Process() in the header for why it must not be the frame's. + CopyPresentedFrame(cmdbuf, image, chain->Input(count), extent); + if (warm) { + chain->DispatchShared(cmdbuf, count); + } +} + +size_t FrameGen::WantedGenerations(size_t capacity) { + if (unavailable) { + plan = {}; + return 0; + } + plan = pacer.Plan(capacity); + return plan.generations; +} + +size_t FrameGen::GeneratedFrameCount() const { + return generated ? last_generations : 0; +} + +void FrameGen::GenerateInto(const Device& device, vk::CommandBuffer cmdbuf, VkImage image, + VkImageView storage_view, size_t generation) { + const u32 target = TargetIndex(storage_view); + chain->SetTarget(device, last_generations, generation, target, storage_view); + + // PORT: Eden reads the destination Frame's own size. Everything we generate into is a + // presented image, so it is the extent the chain was built for. + const VkExtent2D extent = built_extent; + + // PORT: recorded into the caller's buffer, as in Process. + chain->DispatchGeneration(cmdbuf, last_count, last_generations, generation, target, image, + extent); +} + +void FrameGen::Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale) { + // ★ Device idle before teardown — see WaitForIdle. This is Eden's scheduler.Finish(). + WaitForIdle(); + chain.reset(); + + // PORT: the slot table keys on VkImageView handles, and a rebuild is exactly when the + // presented images are recreated. Dropping the stale handles keeps a recycled one from + // matching an entry that belongs to a view that no longer exists. + targets.fill(VK_NULL_HANDLE); + target_count = 0; + + built_flow_scale = flow_scale; + + chain.emplace(device, memory_allocator, *shaders, extent, format, built_flow_scale); + built_extent = extent; + built_format = format; + frame_count = 0; + warm_streak = 0; + generated = false; +} + +void FrameGen::WaitForIdle() { + // ★ LOAD-BEARING, and the reason every teardown path calls it first. + // + // LsfgVkCompat's wrappers call vkDestroy* the moment they go out of scope instead of routing + // through GSDeviceVK's deferred-destruction queue. Releasing the chain while a submitted + // command buffer still references its images, pipelines or descriptor pool is therefore a + // use-after-free, and one that surfaces as a random GPU fault rather than as an obvious bug. + // Eden gets the guarantee from Scheduler::Finish(); ours has to be explicit. + // + // vkDeviceWaitIdle covers everything submitted, which is sufficient here because every site + // that calls this runs before that frame's chain work is recorded, and the previous frame's + // command buffer was submitted by the present that ended it. Note it is deliberately not + // GSDeviceVK::ExecuteCommandBuffer: that submits the frame's command buffer, which is the + // wrong thing to do halfway through a present. + gs_device->WaitForGPUIdle(); +} + +u32 FrameGen::TargetIndex(VkImageView view) { + for (size_t i = 0; i < targets.size(); ++i) { + if (targets[i] == view) { + return static_cast(i); + } + } + + // Unseen view: claim the next slot. Wrapping is not expected — a present path draws from a + // handful of images and there are LSFG_MAX_TARGETS of these — and costs only a descriptor + // rewrite if it ever happens. + const u32 index = static_cast(target_count++ % targets.size()); + targets[index] = view; + return index; +} + +} // namespace Vulkan diff --git a/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.h b/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.h new file mode 100644 index 0000000000..3328846085 --- /dev/null +++ b/pcsx2/GS/Renderers/Vulkan/FrameGen/FrameGen.h @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/frame_gen.h. +// The orchestration is unchanged — warm-up counting, the rebuild conditions, the flow-scale +// derivation and the pacer plumbing are all Eden's. What differs is structural: +// +// * Eden's methods take a `Frame*` out of PresentManager's pool. PCSX2 has no such pool, so the +// three fields they read (image, storage view, extent) arrive as explicit parameters instead. +// * Eden records through a `Scheduler`. We hold GSDeviceVK and record into its current command +// buffer directly. +// * Eden's debug image dump — `DumpDebugImages` plus the WritePortablePixmap / WriteGrayscalePgm +// / WriteRaw / WriteColorPpm writers and the `dumped` flag — is not carried over. It needs +// , which the GS backend does not pull in, and a buffer readback the compat shim +// does not implement. +// +// See FrameGenTypes.h and LsfgVkCompat.h. + +#pragma once + +#include +#include + +#include "FrameGenPacer.h" +#include "FrameGenTypes.h" +#include "LsfgChain.h" +#include "LsfgShaders.h" +#include "LsfgVkCompat.h" + +namespace Vulkan { + +class FrameGen { +public: + explicit FrameGen(MemoryAllocator& memory_allocator, GSDeviceVK* device); + ~FrameGen(); + + /// Feeds the frame about to be presented into the chain. + /// + /// ★ The caller supplies [cmdbuf]. It is NOT taken from GSDeviceVK::GetCurrentCommandBuffer(): + /// frame generation runs from the present hook, which fires AFTER the frame's command buffer + /// has been submitted, so that buffer is either in flight or already belongs to the next + /// frame. Recording into it is undefined, and the visible symptom would be interpolation + /// running a frame late rather than anything that looks like an error. GSLsfg owns its own + /// one-shot buffers and passes them in, which is also how Eden's scheduler supplies one. + /// + /// `image` / `storage_view` / `extent` describe that presented image; `guest_extent` is the + /// size the game was actually rendered at, upscale included — the flow-scale heuristic + /// compares the two. Must be called outside a render pass. + void Process(const Device& device, vk::CommandBuffer cmdbuf, VkImage image, + VkImageView storage_view, VkExtent2D extent, VkFormat format, + VkExtent2D guest_extent); + + [[nodiscard]] size_t WantedGenerations(size_t capacity); + + [[nodiscard]] size_t GeneratedFrameCount() const; + + /// Writes interpolated frame `generation` into `image`. Only valid while + /// GeneratedFrameCount() is non-zero, and for `generation` below it. + void GenerateInto(const Device& device, vk::CommandBuffer cmdbuf, VkImage image, + VkImageView storage_view, size_t generation); + +private: + void Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale); + void WaitForIdle(); + [[nodiscard]] u32 TargetIndex(VkImageView view); + + MemoryAllocator& memory_allocator; + GSDeviceVK* gs_device; + + std::optional shaders; + std::optional chain; + FrameGenPacer pacer; + FrameGenPlan plan{}; + /// PORT: stands in for Eden's `Frame::index`. LsfgGenerate keys its descriptor sets by target + /// slot and only rewrites them when the view in that slot changes, so a destination image + /// needs a *stable* index — Eden gets one from its frame pool, we recover it by remembering + /// which view we handed to which slot. + std::array targets{}; + size_t target_count{}; + VkExtent2D peak_guest_extent{}; + VkExtent2D built_extent{}; + VkFormat built_format{VK_FORMAT_UNDEFINED}; + f32 built_flow_scale{}; + u64 frame_count{}; + u64 last_count{}; + size_t last_generations{}; + u32 warm_streak{}; + bool generated{}; + bool unavailable{}; +}; + +} // namespace Vulkan diff --git a/pcsx2/GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.cpp b/pcsx2/GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.cpp index 727bc4c0e4..1881ebc9b1 100644 --- a/pcsx2/GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.cpp +++ b/pcsx2/GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.cpp @@ -255,26 +255,20 @@ namespace Vulkan m_logical = vk::LogicalDevice(dev->GetDevice()); - // Both features are queried straight from the driver rather than trusted from PCSX2's - // own feature struct: the LSFG shaders need them specifically, and PCSX2 does not enable - // or track either for its own rendering, so there is nothing cached to read. - // Value-init then assign, rather than a braced initialiser: these structs carry more - // members than the two being set, and naming only the first leaves the rest to - // -Wmissing-field-initializers. Same idiom as the rest of the Vulkan backend. - VkPhysicalDeviceRobustness2FeaturesEXT robustness = {}; - robustness.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ROBUSTNESS_2_FEATURES_EXT; - - VkPhysicalDeviceVulkanMemoryModelFeatures memory_model = {}; - memory_model.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_MEMORY_MODEL_FEATURES; - memory_model.pNext = &robustness; - - VkPhysicalDeviceFeatures2 features = {}; - features.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2; - features.pNext = &memory_model; - vkGetPhysicalDeviceFeatures2(dev->GetPhysicalDevice(), &features); - - m_vulkan_memory_model = (memory_model.vulkanMemoryModel == VK_TRUE); - m_null_descriptor = (robustness.nullDescriptor == VK_TRUE); + // ★ Read what the LOGICAL device actually ENABLED, not what the physical device reports. + // + // This originally called vkGetPhysicalDeviceFeatures2 and believed the answer, which is + // wrong in the direction that hurts: a driver can report both features as supported while + // GSDeviceVK never requested either at vkCreateDevice, and then declaring the Vulkan + // memory model in a shader module, or relying on a null descriptor, is invalid usage. + // On Adreno that surfaces as device-lost mid-frame rather than as a clean failure, so + // the check would have looked like it passed right up until it took the renderer down. + // + // GSDeviceVK already probes both against the driver and clears the flag when a feature is + // advertised but absent, so these are true only when the feature was really enabled. + const GSDeviceVK::OptionalExtensions& ext = dev->GetOptionalExtensions(); + m_vulkan_memory_model = ext.vk_khr_vulkan_memory_model; + m_null_descriptor = ext.vk_ext_robustness2_null_descriptor; } // --- MemoryAllocator ------------------------------------------------------------------- diff --git a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp index c6dfe17f19..1925ce5907 100644 --- a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp +++ b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp @@ -529,6 +529,10 @@ bool GSDeviceVK::SelectDeviceExtensions(ExtensionList* extension_list, bool enab #endif m_optional_extensions.vk_ext_fragment_shader_interlock = SupportsExtension(VK_EXT_FRAGMENT_SHADER_INTERLOCK_EXTENSION_NAME, false); + // LSFG frame generation only. PCSX2 targets Vulkan 1.1, where neither is core — the memory + // model is 1.2 and nullDescriptor never became core at all — so both come in as extensions. + m_optional_extensions.vk_khr_vulkan_memory_model = SupportsExtension(VK_KHR_VULKAN_MEMORY_MODEL_EXTENSION_NAME, false); + m_optional_extensions.vk_ext_robustness2_null_descriptor = SupportsExtension(VK_EXT_ROBUSTNESS_2_EXTENSION_NAME, false); return true; } @@ -718,6 +722,10 @@ bool GSDeviceVK::CreateDevice(VkSurfaceKHR surface, bool enable_validation_layer VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SWAPCHAIN_MAINTENANCE_1_FEATURES_KHR}; VkPhysicalDeviceFragmentShaderInterlockFeaturesEXT fragment_shader_interlock_ext_feature = { VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_SHADER_INTERLOCK_FEATURES_EXT}; + VkPhysicalDeviceVulkanMemoryModelFeatures vulkan_memory_model_feature = { + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_MEMORY_MODEL_FEATURES}; + VkPhysicalDeviceRobustness2FeaturesEXT robustness2_feature = { + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ROBUSTNESS_2_FEATURES_EXT}; // An advertised EXTENSION does not guarantee its FEATURE bit, and asking for a feature the // driver does not have fails vkCreateDevice outright with VK_ERROR_FEATURE_NOT_PRESENT — @@ -743,6 +751,10 @@ bool GSDeviceVK::CreateDevice(VkSurfaceKHR surface, bool enable_validation_layer VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SWAPCHAIN_MAINTENANCE_1_FEATURES_KHR}; VkPhysicalDeviceFragmentShaderInterlockFeaturesEXT probe_fsi = { VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_SHADER_INTERLOCK_FEATURES_EXT}; + VkPhysicalDeviceVulkanMemoryModelFeatures probe_vmm = { + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_MEMORY_MODEL_FEATURES}; + VkPhysicalDeviceRobustness2FeaturesEXT probe_r2 = { + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ROBUSTNESS_2_FEATURES_EXT}; // Only chain what we would actually enable: querying a struct whose extension is absent is // not something the spec promises anything about. @@ -759,6 +771,10 @@ bool GSDeviceVK::CreateDevice(VkSurfaceKHR surface, bool enable_validation_layer Vulkan::AddPointerToChain(&probe, &probe_sm1); if (m_optional_extensions.vk_ext_fragment_shader_interlock) Vulkan::AddPointerToChain(&probe, &probe_fsi); + if (m_optional_extensions.vk_khr_vulkan_memory_model) + Vulkan::AddPointerToChain(&probe, &probe_vmm); + if (m_optional_extensions.vk_ext_robustness2_null_descriptor) + Vulkan::AddPointerToChain(&probe, &probe_r2); vkGetPhysicalDeviceFeatures2(m_physical_device, &probe); // Returns the flag rather than taking it by reference: m_optional_extensions members are @@ -792,6 +808,13 @@ bool GSDeviceVK::CreateDevice(VkSurfaceKHR surface, bool enable_validation_layer m_optional_extensions.vk_ext_fragment_shader_interlock = keep("VK_EXT_fragment_shader_interlock", m_optional_extensions.vk_ext_fragment_shader_interlock, probe_fsi.fragmentShaderPixelInterlock == VK_TRUE); + m_optional_extensions.vk_khr_vulkan_memory_model = keep("VK_KHR_vulkan_memory_model", + m_optional_extensions.vk_khr_vulkan_memory_model, probe_vmm.vulkanMemoryModel == VK_TRUE); + // The FEATURE we want is nullDescriptor specifically. VK_EXT_robustness2 also carries + // robustBufferAccess2/robustImageAccess2, which cost performance and which nothing here + // needs — they are deliberately left VK_FALSE below. + m_optional_extensions.vk_ext_robustness2_null_descriptor = keep("VK_EXT_robustness2 (nullDescriptor)", + m_optional_extensions.vk_ext_robustness2_null_descriptor, probe_r2.nullDescriptor == VK_TRUE); // Depth ROAA is an optional sub-feature: a driver can offer the extension and colour // access yet not depth. @@ -832,6 +855,17 @@ bool GSDeviceVK::CreateDevice(VkSurfaceKHR surface, bool enable_validation_layer fragment_shader_interlock_ext_feature.fragmentShaderPixelInterlock = VK_TRUE; Vulkan::AddPointerToChain(&device_info, &fragment_shader_interlock_ext_feature); } + if (m_optional_extensions.vk_khr_vulkan_memory_model) + { + vulkan_memory_model_feature.vulkanMemoryModel = VK_TRUE; + Vulkan::AddPointerToChain(&device_info, &vulkan_memory_model_feature); + } + if (m_optional_extensions.vk_ext_robustness2_null_descriptor) + { + // nullDescriptor ONLY — see the note by the probe above. + robustness2_feature.nullDescriptor = VK_TRUE; + Vulkan::AddPointerToChain(&device_info, &robustness2_feature); + } VkResult res = vkCreateDevice(m_physical_device, &device_info, nullptr, &m_device); if (res != VK_SUCCESS) diff --git a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h index ff8465a359..63a9cb4d6e 100644 --- a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h +++ b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h @@ -49,6 +49,11 @@ public: bool vk_khr_shader_non_semantic_info : 1; bool vk_ext_attachment_feedback_loop_layout : 1; bool vk_ext_fragment_shader_interlock : 1; + /// Both are required by the LSFG frame-generation shaders and by NOTHING else in the + /// renderer. They are requested anyway whenever the driver really has them, because the + /// alternative is recreating the device when frame generation is switched on. + bool vk_khr_vulkan_memory_model : 1; ///< shaders declare the Vulkan memory model + bool vk_ext_robustness2_null_descriptor : 1; ///< nullDescriptor only; not the robust-access bits }; // Global state accessors diff --git a/pcsx2/GS/Renderers/Vulkan/GSLsfg.cpp b/pcsx2/GS/Renderers/Vulkan/GSLsfg.cpp index 5577d978d4..54c82c36e5 100644 --- a/pcsx2/GS/Renderers/Vulkan/GSLsfg.cpp +++ b/pcsx2/GS/Renderers/Vulkan/GSLsfg.cpp @@ -21,19 +21,15 @@ #include "GS/Renderers/Vulkan/GSDeviceVK.h" #include "GS/Renderers/Vulkan/VKSwapChain.h" -#include "armsx2_lsfg_shim.h" -#include "extract/trans.hpp" +#include "GS/Renderers/Vulkan/FrameGen/FrameGen.h" +#include "GS/Renderers/Vulkan/FrameGen/LosslessDll.h" +#include "GS/Renderers/Vulkan/FrameGen/LsfgVkCompat.h" -#include - -#include -#include +#include "common/Timer.h" #include -#include +#include #include -#include -#include #include #endif @@ -225,443 +221,41 @@ namespace GSLsfg { namespace { - // --- the backend, loaded at runtime --------------------------------------------------- - // - // framegen lives in libarmsx2_lsfg.so and is reached only through the C entry points in - // armsx2_lsfg_shim.h. It is NOT linked, because it carries its own volk whose 759 - // vkCreateImage-style globals would otherwise collide with (or, worse, silently alias) - // the identically named ones in PCSX2's VKLoader. See armsx2_lsfg_shim.h for the full - // reasoning. dlopen also means a build or a device missing that .so degrades to "frame - // generation unavailable" instead of failing to start the emulator. + // --- state ---------------------------------------------------------------------------- + // Frame generation now runs as ordinary compute on the emulator's OWN device (ported from + // Eden, PR #4263). The previous implementation drove a separate library on a second + // VkDevice and shared images through AHardwareBuffer, which forced a full device idle + // twice per frame because Android offers no cross-device semaphore. None of that is here. + std::optional s_device; + std::optional s_allocator; + std::optional s_frame_gen; - struct Backend - { - void* handle = nullptr; - pfn_armsx2_lsfg_abi_version abi_version = nullptr; - // v2 signature: is_hdr / flow_scale / performance joined the argument list, which is - // exactly why the ABI check below exists — the old layout would misread every one. - pfn_armsx2_lsfg_initialize initialize = nullptr; - pfn_armsx2_lsfg_create_context create_context = nullptr; - pfn_armsx2_lsfg_present present = nullptr; - pfn_armsx2_lsfg_wait_idle wait_idle = nullptr; - pfn_armsx2_lsfg_delete_context delete_context = nullptr; - pfn_armsx2_lsfg_finalize finalize = nullptr; - pfn_armsx2_lsfg_last_error last_error = nullptr; - }; - - Backend s_backend; - bool s_backend_tried = false; - - const char* BackendError() - { - const char* msg = s_backend.last_error ? s_backend.last_error() : nullptr; - return (msg && *msg) ? msg : "no detail"; - } - - bool LoadBackend() - { - if (s_backend.handle) - return true; - if (s_backend_tried) - return false; // one attempt; a missing .so will still be missing next frame - s_backend_tried = true; - - void* handle = dlopen("libarmsx2_lsfg.so", RTLD_NOW | RTLD_LOCAL); - if (!handle) - { - Console.ErrorFmt("@@ANDROID_LSFG@@ libarmsx2_lsfg.so not loadable: {}", dlerror()); - return false; - } - - Backend b; - b.handle = handle; - auto sym = [handle](const char* name) { return dlsym(handle, name); }; - b.abi_version = reinterpret_cast(sym("armsx2_lsfg_abi_version")); - b.initialize = reinterpret_cast(sym("armsx2_lsfg_initialize")); - b.create_context = reinterpret_cast(sym("armsx2_lsfg_create_context")); - b.present = reinterpret_cast(sym("armsx2_lsfg_present")); - b.wait_idle = reinterpret_cast(sym("armsx2_lsfg_wait_idle")); - b.delete_context = reinterpret_cast(sym("armsx2_lsfg_delete_context")); - b.finalize = reinterpret_cast(sym("armsx2_lsfg_finalize")); - b.last_error = reinterpret_cast(sym("armsx2_lsfg_last_error")); - - if (!b.abi_version || !b.initialize || !b.create_context || !b.present || !b.wait_idle || - !b.delete_context || !b.finalize || !b.last_error) - { - Console.Error("@@ANDROID_LSFG@@ libarmsx2_lsfg.so is missing entry points"); - dlclose(handle); - return false; - } - // A stale .so left behind by an older install would otherwise be called with the - // wrong argument layout, which is a crash with no useful backtrace. - if (b.abi_version() != ARMSX2_LSFG_ABI_VERSION) - { - Console.ErrorFmt("@@ANDROID_LSFG@@ libarmsx2_lsfg.so is ABI v{}, expected v{}", - b.abi_version(), ARMSX2_LSFG_ABI_VERSION); - dlclose(handle); - return false; - } - - s_backend = b; - return true; - } - - // --- shader extraction --------------------------------------------------------------- - // - // framegen does not read Lossless.dll itself: it asks for shaders by name and wants - // SPIR-V back, so the whole chain is ours. Upstream does this in the layer .so we do not - // build, and its extract.cpp hunts through Steam install paths and pulls in a TOML config - // system — neither of which means anything here, where the path comes from a SAF pick. - // So the resource walk is reimplemented and only the DXBC->SPIR-V translation is taken - // from upstream, where their binding-rewrite fixes live. - - // name -> SPIR-V, translated once and then held. The DXBC below is scratch: it exists - // only while a DLL is being read and is dropped the moment every shader is translated. - // - // This used to hold DXBC instead, with the translation done inside ShaderCallback — so - // all 26 (now 52) DXBC->SPIR-V compiles ran on the GS thread, inside EndPresent, on the - // first frame after every enable, resolution change and multiplier change. - std::map> s_shader_spirv; - std::unordered_map> s_shader_blobs; - // Which DLL s_shader_spirv was built from. Without it, picking a different Lossless.dll - // kept serving the previous file's shaders for the rest of the session. - std::string s_shader_source; - // Which families that DLL turned out to carry. A given Lossless Scaling version ships - // one or the other, so this is what lets the 3.1p request fall back instead of failing. - bool s_have_standard = false; - bool s_have_performance = false; - - int OnResource(void*, const peparse::resource& res) - { - if (res.type != peparse::RT_RCDATA || res.buf == nullptr || res.buf->bufLen <= 0) - return 0; - std::vector data(static_cast(res.buf->bufLen)); - std::copy_n(res.buf->buf, res.buf->bufLen, data.data()); - s_shader_blobs[res.name] = std::move(data); - return 0; - } - - /// True for the LSFG 3.1p (performance) family. The p_ prefix is upstream's own naming. - bool IsPerformanceShader(const std::string& name) { return name.compare(0, 2, "p_") == 0; } - - // Resource IDs, from upstream's extract.cpp — they track Lossless Scaling's own resource - // layout. Only the names framegen asks for are listed; anything else fails the load - // cleanly rather than feeding it a wrong shader. - // - // Two families: the plain names are LSFG 3.1 and the p_ prefixed ones are 3.1p, the - // lighter pipeline. p_mipmaps and p_generate deliberately share 255/256 with 3.1 — those - // two resources are common to both, so they are extracted twice under the two names - // framegen asks for rather than special-cased. - const std::map& ShaderNameTable() - { - static const std::map table = { - {"mipmaps", 255}, - {"alpha[0]", 267}, {"alpha[1]", 268}, {"alpha[2]", 269}, {"alpha[3]", 270}, - {"beta[0]", 275}, {"beta[1]", 276}, {"beta[2]", 277}, {"beta[3]", 278}, - {"beta[4]", 279}, - {"gamma[0]", 257}, {"gamma[1]", 259}, {"gamma[2]", 260}, {"gamma[3]", 261}, - {"gamma[4]", 262}, - {"delta[0]", 257}, {"delta[1]", 263}, {"delta[2]", 264}, {"delta[3]", 265}, - {"delta[4]", 266}, {"delta[5]", 258}, {"delta[6]", 271}, {"delta[7]", 272}, - {"delta[8]", 273}, {"delta[9]", 274}, - {"generate", 256}, - {"p_mipmaps", 255}, - {"p_alpha[0]", 290}, {"p_alpha[1]", 291}, {"p_alpha[2]", 292}, {"p_alpha[3]", 293}, - {"p_beta[0]", 298}, {"p_beta[1]", 299}, {"p_beta[2]", 300}, {"p_beta[3]", 301}, - {"p_beta[4]", 302}, - {"p_gamma[0]", 280}, {"p_gamma[1]", 282}, {"p_gamma[2]", 283}, {"p_gamma[3]", 284}, - {"p_gamma[4]", 285}, - {"p_delta[0]", 280}, {"p_delta[1]", 286}, {"p_delta[2]", 287}, {"p_delta[3]", 288}, - {"p_delta[4]", 289}, {"p_delta[5]", 281}, {"p_delta[6]", 294}, {"p_delta[7]", 295}, - {"p_delta[8]", 296}, {"p_delta[9]", 297}, - {"p_generate", 256}, - }; - return table; - } - - // --- the SPIR-V cache ------------------------------------------------------------------ - // - // Translated SPIR-V only, never the DLL — that file is the user's own property and stays - // where they put it. Reading it back skips both the PE walk and 52 DXBC compiles. - - constexpr u32 k_cache_magic = 0x4746534Cu; // "LSFG" - constexpr u32 k_cache_version = 1; - // Bounds on what the file may claim, so a truncated or garbage cache stops cleanly at the - // first bad field instead of trying to allocate whatever the bytes happened to say. - constexpr u32 k_max_name_len = 64; - constexpr u32 k_max_shader_size = 4u * 1024u * 1024u; - constexpr u32 k_max_shader_count = 256; - - std::string ShaderCachePath() { return Path::Combine(EmuFolders::Cache, "lsfg_shaders.bin"); } - - void AppendU32(std::vector& out, u32 value) - { - out.insert(out.end(), reinterpret_cast(&value), reinterpret_cast(&value) + 4); - } - - void AppendU64(std::vector& out, u64 value) - { - out.insert(out.end(), reinterpret_cast(&value), reinterpret_cast(&value) + 8); - } - - /// Size and mtime of the file the cache was built from. Upstream's own equivalent has no - /// invalidation at all: update Lossless Scaling and the stale shaders are used forever, - /// silently. We keep the DLL, so we can just ask. - bool StatSourceDll(u64* size, u64* mtime) - { - FILESYSTEM_STAT_DATA sd = {}; - if (!FileSystem::StatFile(s_dll_path.c_str(), &sd)) - return false; - *size = static_cast(sd.Size); - *mtime = static_cast(sd.ModificationTime); - return true; - } - - void SaveShaderCache() - { - u64 dll_size = 0, dll_mtime = 0; - if (!StatSourceDll(&dll_size, &dll_mtime)) - return; // no way to invalidate it later, so do not write one - - std::vector out; - AppendU32(out, k_cache_magic); - AppendU32(out, k_cache_version); - AppendU64(out, dll_size); - AppendU64(out, dll_mtime); - AppendU32(out, static_cast(s_shader_spirv.size())); - for (const auto& [name, spirv] : s_shader_spirv) - { - AppendU32(out, static_cast(name.size())); - out.insert(out.end(), name.begin(), name.end()); - AppendU32(out, static_cast(spirv.size())); - out.insert(out.end(), spirv.begin(), spirv.end()); - } - - const std::string path = ShaderCachePath(); - if (!FileSystem::WriteBinaryFile(path.c_str(), out.data(), out.size())) - { - Console.WarningFmt("@@ANDROID_LSFG@@ could not write {} — shaders will be translated again next time", path); - return; - } - Console.WriteLnFmt("@@ANDROID_LSFG@@ cached {} translated shaders", s_shader_spirv.size()); - } - - /// Fills s_shader_spirv from disk. False for every ordinary reason a cache is not usable - /// — absent, from another build, or from a DLL the user has since replaced — none of - /// which is an error, they just mean "extract". - bool LoadShaderCache() - { - u64 dll_size = 0, dll_mtime = 0; - if (!StatSourceDll(&dll_size, &dll_mtime)) - return false; - - const std::optional> data = FileSystem::ReadBinaryFile(ShaderCachePath().c_str()); - if (!data.has_value()) - return false; - - const u8* p = data->data(); - size_t left = data->size(); - const auto read_u32 = [&p, &left](u32* value) { - if (left < 4) - return false; - std::memcpy(value, p, 4); - p += 4; - left -= 4; - return true; - }; - const auto read_u64 = [&p, &left](u64* value) { - if (left < 8) - return false; - std::memcpy(value, p, 8); - p += 8; - left -= 8; - return true; - }; - - u32 magic = 0, version = 0, count = 0; - u64 cached_size = 0, cached_mtime = 0; - if (!read_u32(&magic) || !read_u32(&version) || !read_u64(&cached_size) || - !read_u64(&cached_mtime) || !read_u32(&count)) - return false; - if (magic != k_cache_magic || version != k_cache_version) - return false; - if (cached_size != dll_size || cached_mtime != dll_mtime) - { - Console.WriteLn("@@ANDROID_LSFG@@ Lossless.dll changed since the shader cache was written"); - return false; - } - if (count == 0 || count > k_max_shader_count) - return false; - - std::map> loaded; - for (u32 i = 0; i < count; i++) - { - u32 name_len = 0, size = 0; - if (!read_u32(&name_len) || name_len == 0 || name_len > k_max_name_len || left < name_len) - break; - std::string name(reinterpret_cast(p), name_len); - p += name_len; - left -= name_len; - - if (!read_u32(&size) || size == 0 || size > k_max_shader_size || left < size) - break; - loaded[std::move(name)].assign(p, p + size); - p += size; - left -= size; - } - - if (loaded.size() != count) - { - Console.Warning("@@ANDROID_LSFG@@ shader cache is truncated — translating again"); - return false; - } - - s_shader_spirv = std::move(loaded); - Console.WriteLnFmt("@@ANDROID_LSFG@@ restored {} shaders from the cache", s_shader_spirv.size()); - return true; - } - - /// Note which families the loaded SPIR-V actually covers, and reject a set that covers - /// neither. "Every listed name present" was right when only 3.1 existed and is wrong now: - /// a DLL legitimately ships one family, so requiring both would reject every one of them. - /// A HALF-present family must still fail here, though, rather than inside framegen's - /// initialise where the only message is "Shader hash not found". - void ClassifyShaderFamilies() - { - s_have_standard = true; - s_have_performance = true; - for (const auto& [name, idx] : ShaderNameTable()) - { - if (s_shader_spirv.find(name) != s_shader_spirv.end()) - continue; - if (IsPerformanceShader(name)) - s_have_performance = false; - else - s_have_standard = false; - } - } - - /// Pull every RCDATA resource out of the user's DLL, translate the ones framegen asks for, - /// and keep only the SPIR-V. Throws with a message the settings screen can show verbatim. - void ExtractShaders() - { - // A different pick invalidates what is held, and holding it anyway is how the old - // code served the previous DLL's shaders after the user replaced the file. - if (s_shader_source != s_dll_path) - s_shader_spirv.clear(); - if (!s_shader_spirv.empty()) - return; - - s_shader_source = s_dll_path; - const bool from_cache = LoadShaderCache(); - if (!from_cache) - { - // A previous attempt that threw before the clear at the bottom would otherwise - // leave its resources here to be merged with this DLL's. - s_shader_blobs.clear(); - peparse::parsed_pe* dll = peparse::ParsePEFromFile(s_dll_path.c_str()); - if (!dll) - throw std::runtime_error("could not read Lossless.dll"); - peparse::IterRsrc(dll, OnResource, nullptr); - peparse::DestructParsedPE(dll); - - // Eagerly, and here rather than in the callback: this is the one point in the - // feature's life where a multi-hundred-millisecond stall is acceptable, and the - // callback runs inside a present. - for (const auto& [name, idx] : ShaderNameTable()) - { - const auto blob = s_shader_blobs.find(idx); - if (blob == s_shader_blobs.end()) - continue; // the other family; ClassifyShaderFamilies decides if that matters - // Individually guarded because a resource id shared between the families can - // be present while its sibling shaders are not, and one bad translation must - // not lose the family that did translate. - try - { - std::vector spirv = Extract::translateShader(blob->second); - if (!spirv.empty()) - s_shader_spirv[name] = std::move(spirv); - } - catch (const std::exception& ex) - { - Console.ErrorFmt("@@ANDROID_LSFG@@ shader '{}' failed to translate: {}", name, ex.what()); - } - } - // The DXBC has done its job. It is several megabytes and nothing reads it again. - s_shader_blobs.clear(); - } - - ClassifyShaderFamilies(); - if (!s_have_standard && !s_have_performance) - { - s_shader_spirv.clear(); - s_shader_source.clear(); - s_no_shaders.store(true, std::memory_order_relaxed); - throw std::runtime_error( - "Lossless.dll has no complete shader set — is Lossless Scaling up to date?"); - } - s_no_shaders.store(false, std::memory_order_relaxed); - if (!from_cache) - SaveShaderCache(); - } - - /// The C callback framegen drives during initialise. A lookup and nothing else — the - /// translation happened in ExtractShaders. The returned pointer is into s_shader_spirv, - /// which outlives the whole initialise, so it comfortably satisfies the shim's - /// valid-until-the-next-call contract. Still guarded: an exception must not unwind - /// through the shim's shared object, whatever the reason for it. - int ShaderCallback(void*, const char* name, const uint8_t** out_data, uint32_t* out_size) - { - try - { - const auto hit = s_shader_spirv.find(name); - if (hit == s_shader_spirv.end() || hit->second.empty()) - { - Console.ErrorFmt( - "@@ANDROID_LSFG@@ framegen asked for shader '{}', which this DLL does not have", name); - return -1; - } - *out_data = hit->second.data(); - *out_size = static_cast(hit->second.size()); - return 0; - } - catch (...) - { - return -1; - } - } - - // --- AHardwareBuffer-backed images ---------------------------------------------------- - // - // The interpolator runs on its own VkDevice, so the only images both sides can touch are - // ones backed by an AHardwareBuffer: we allocate the AHB, wrap it in a VkImage on OUR - // device, and hand the raw AHB across, where it is wrapped again on theirs. - - struct AhbImage - { - AHardwareBuffer* ahb = nullptr; - VkImage image = VK_NULL_HANDLE; - VkDeviceMemory memory = VK_NULL_HANDLE; - }; - - VkDevice s_device = VK_NULL_HANDLE; - VkPhysicalDevice s_physical_device = VK_NULL_HANDLE; - VkQueue s_queue = VK_NULL_HANDLE; - VkCommandPool s_cmd_pool = VK_NULL_HANDLE; - - AhbImage s_frame[2]; // previous and current real frames - std::vector s_generated; // multiplier - 1 interpolated outputs - - s32 s_context_id = -1; bool s_active = false; u32 s_multiplier = 1; - // What the SETTING said at the last successful bring-up, not the family that ended up - // running — the two differ when a DLL ships only one, and comparing the resolved value - // against the setting would tear the whole thing down and rebuild it every frame. - bool s_performance_requested = false; u8 s_flow_scale_percent = 100; + bool s_performance_requested = false; VkExtent2D s_extent = {}; VkFormat s_format = VK_FORMAT_UNDEFINED; + VkDevice s_vk_device = VK_NULL_HANDLE; + + /// One storage view per swap chain image. Generated frames are written straight into a + /// swap chain image through these, which is why the swap chain has to carry + /// VK_IMAGE_USAGE_STORAGE_BIT — see VKSwapChain::IsStorageUsageAvailable. + std::vector s_storage_views; + + /// One command buffer for the whole frame's generation work, and one semaphore per + /// present that will be issued from it. + /// + /// ★ Everything is recorded into ONE buffer and submitted ONCE, signalling N+1 + /// semaphores. The obvious alternative — a submit per generated frame — reintroduces the + /// binary-semaphore hazard the old implementation was bitten by, because the real + /// present and the first generated present would both want to wait on the semaphore that + /// says "the source copy has landed". A binary semaphore may be waited exactly once. + VkCommandPool s_cmd_pool = VK_NULL_HANDLE; + VkCommandBuffer s_cmd = VK_NULL_HANDLE; + std::array s_acquire_sems = {}; + std::array s_done_sems = {}; + u64 s_frame_index = 0; // The one-second display-rate window. Reset with everything else in Shutdown so a stale @@ -695,219 +289,103 @@ namespace GSLsfg s_fps_generated = 0; } - // One command buffer + one semaphore per generated frame, plus one of each for the - // pre-copy. Recycled across frames rather than pooled: the Android path is fully - // synchronous (framegen has no exportable cross-device semaphore, so an idle wait is the - // only barrier available), which means last frame's work is provably finished before - // this frame records anything. - VkCommandBuffer s_pre_copy_cmd = VK_NULL_HANDLE; - VkSemaphore s_pre_copy_sem = VK_NULL_HANDLE; - std::vector s_post_copy_cmds; - std::vector s_post_copy_sems; - std::vector s_acquire_sems; - - void DestroyAhbImage(AhbImage& img) + /// A plain layout transition on a whole colour image. + /// + /// The ported passes handle their own barriers, but they speak Eden's convention where a + /// presentable image lives in GENERAL. PCSX2's swap chain images are in PRESENT_SRC_KHR + /// when we get them and must be back in it to be presented, so these bracket the calls. + void TransitionImage(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to, + VkAccessFlags src_access, VkAccessFlags dst_access) { - if (img.image != VK_NULL_HANDLE) - vkDestroyImage(s_device, img.image, nullptr); - if (img.memory != VK_NULL_HANDLE) - vkFreeMemory(s_device, img.memory, nullptr); - if (img.ahb) - AHardwareBuffer_release(img.ahb); - img = {}; - } - - bool CreateAhbImage(AhbImage& out, VkExtent2D extent, VkFormat format) - { - u32 ahb_format = 0; - switch (format) - { - case VK_FORMAT_R8G8B8A8_UNORM: ahb_format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM; break; - case VK_FORMAT_R16G16B16A16_SFLOAT: ahb_format = AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT; break; - default: - Console.ErrorFmt("@@ANDROID_LSFG@@ unsupported swapchain format {}", static_cast(format)); - return false; - } - - const AHardwareBuffer_Desc desc = { - extent.width, extent.height, 1, ahb_format, - AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT, - 0, 0, 0}; - if (AHardwareBuffer_allocate(&desc, &out.ahb) != 0 || !out.ahb) - { - Console.Error("@@ANDROID_LSFG@@ AHardwareBuffer_allocate failed"); - return false; - } - - VkExternalMemoryImageCreateInfo ext_info = {VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, - nullptr, VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID}; - VkImageCreateInfo image_info = {VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, &ext_info, 0, VK_IMAGE_TYPE_2D, - format, {extent.width, extent.height, 1}, 1, 1, VK_SAMPLE_COUNT_1_BIT, VK_IMAGE_TILING_OPTIMAL, - VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | - VK_IMAGE_USAGE_TRANSFER_DST_BIT, - VK_SHARING_MODE_EXCLUSIVE, 0, nullptr, VK_IMAGE_LAYOUT_UNDEFINED}; - if (vkCreateImage(s_device, &image_info, nullptr, &out.image) != VK_SUCCESS) - { - Console.Error("@@ANDROID_LSFG@@ vkCreateImage failed for the shared image"); - DestroyAhbImage(out); - return false; - } - - // Upstream deliberately skips vkGetAndroidHardwareBufferPropertiesANDROID here and - // takes the requirements off the image instead, because the wrapper ICDs some hosts - // use do not forward that entry point. The image's own requirements are correct - // either way, so this follows them. - VkMemoryRequirements reqs = {}; - vkGetImageMemoryRequirements(s_device, out.image, &reqs); - - VkPhysicalDeviceMemoryProperties mem_props = {}; - vkGetPhysicalDeviceMemoryProperties(s_physical_device, &mem_props); - - u32 type_index = UINT32_MAX; - for (u32 i = 0; i < mem_props.memoryTypeCount; i++) - { - if ((reqs.memoryTypeBits & (1u << i)) && - (mem_props.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)) - { - type_index = i; - break; - } - } - if (type_index == UINT32_MAX) - { - for (u32 i = 0; i < mem_props.memoryTypeCount; i++) - { - if (reqs.memoryTypeBits & (1u << i)) - { - type_index = i; - break; - } - } - } - if (type_index == UINT32_MAX) - { - Console.Error("@@ANDROID_LSFG@@ no memory type accepts the shared image"); - DestroyAhbImage(out); - return false; - } - - VkMemoryDedicatedAllocateInfo dedicated = { - VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, nullptr, out.image, VK_NULL_HANDLE}; - VkImportAndroidHardwareBufferInfoANDROID import_info = { - VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, &dedicated, out.ahb}; - VkMemoryAllocateInfo alloc_info = { - VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, &import_info, reqs.size, type_index}; - if (vkAllocateMemory(s_device, &alloc_info, nullptr, &out.memory) != VK_SUCCESS) - { - Console.Error("@@ANDROID_LSFG@@ could not import the AHardwareBuffer"); - DestroyAhbImage(out); - return false; - } - if (vkBindImageMemory(s_device, out.image, out.memory, 0) != VK_SUCCESS) - { - Console.Error("@@ANDROID_LSFG@@ vkBindImageMemory failed for the shared image"); - DestroyAhbImage(out); - return false; - } - return true; - } - - // --- copy helpers ---------------------------------------------------------------------- - - void ImageBarrier(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to, - VkAccessFlags src_access, VkAccessFlags dst_access, VkPipelineStageFlags src_stage, - VkPipelineStageFlags dst_stage) - { - const VkImageMemoryBarrier barrier = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, nullptr, src_access, - dst_access, from, to, VK_QUEUE_FAMILY_IGNORED, VK_QUEUE_FAMILY_IGNORED, image, - {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}}; - vkCmdPipelineBarrier(cmd, src_stage, dst_stage, 0, 0, nullptr, 0, nullptr, 1, &barrier); - } - - /// Full-surface copy between two images of identical extent and format. `src_layout` is - /// where the source is on entry and must be left; `dst_layout` likewise for the target. - void RecordCopy(VkCommandBuffer cmd, VkImage src, VkImageLayout src_layout, VkImage dst, - VkImageLayout dst_layout, VkExtent2D extent) - { - ImageBarrier(cmd, src, src_layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, - VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - // The destination is fully overwritten, so its previous contents are worthless and - // UNDEFINED is the cheaper source layout — it lets a tiler skip the load. - ImageBarrier(cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, - VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - - const VkImageCopy region = {{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, {0, 0, 0}, - {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, {0, 0, 0}, {extent.width, extent.height, 1}}; - vkCmdCopyImage(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst, - VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); - - ImageBarrier(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, src_layout, VK_ACCESS_TRANSFER_READ_BIT, - 0, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); - ImageBarrier(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, dst_layout, VK_ACCESS_TRANSFER_WRITE_BIT, - 0, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); - } - - bool SubmitOneShot(VkCommandBuffer cmd, VkSemaphore wait, VkSemaphore signal) - { - const VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT; - VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; - submit.waitSemaphoreCount = (wait != VK_NULL_HANDLE) ? 1u : 0u; - submit.pWaitSemaphores = (wait != VK_NULL_HANDLE) ? &wait : nullptr; - submit.pWaitDstStageMask = (wait != VK_NULL_HANDLE) ? &wait_stage : nullptr; - submit.commandBufferCount = 1; - submit.pCommandBuffers = &cmd; - submit.signalSemaphoreCount = (signal != VK_NULL_HANDLE) ? 1u : 0u; - submit.pSignalSemaphores = (signal != VK_NULL_HANDLE) ? &signal : nullptr; - return vkQueueSubmit(s_queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS; + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = src_access; + barrier.dstAccessMask = dst_access; + barrier.oldLayout = from; + barrier.newLayout = to; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); } void DestroyResources() { - if (s_device == VK_NULL_HANDLE) + if (s_vk_device == VK_NULL_HANDLE) return; - for (VkSemaphore s : s_post_copy_sems) - if (s != VK_NULL_HANDLE) - vkDestroySemaphore(s_device, s, nullptr); - for (VkSemaphore s : s_acquire_sems) - if (s != VK_NULL_HANDLE) - vkDestroySemaphore(s_device, s, nullptr); - if (s_pre_copy_sem != VK_NULL_HANDLE) - vkDestroySemaphore(s_device, s_pre_copy_sem, nullptr); - s_post_copy_sems.clear(); - s_acquire_sems.clear(); - s_pre_copy_sem = VK_NULL_HANDLE; + for (VkImageView view : s_storage_views) + { + if (view != VK_NULL_HANDLE) + vkDestroyImageView(s_vk_device, view, nullptr); + } + s_storage_views.clear(); - // The pool owns the buffers; freeing it frees them. + for (VkSemaphore& sem : s_acquire_sems) + { + if (sem != VK_NULL_HANDLE) + vkDestroySemaphore(s_vk_device, sem, nullptr); + sem = VK_NULL_HANDLE; + } + for (VkSemaphore& sem : s_done_sems) + { + if (sem != VK_NULL_HANDLE) + vkDestroySemaphore(s_vk_device, sem, nullptr); + sem = VK_NULL_HANDLE; + } if (s_cmd_pool != VK_NULL_HANDLE) - vkDestroyCommandPool(s_device, s_cmd_pool, nullptr); - s_cmd_pool = VK_NULL_HANDLE; - s_pre_copy_cmd = VK_NULL_HANDLE; - s_post_copy_cmds.clear(); - - for (AhbImage& img : s_generated) - DestroyAhbImage(img); - s_generated.clear(); - DestroyAhbImage(s_frame[0]); - DestroyAhbImage(s_frame[1]); + { + // Frees s_cmd with it. + vkDestroyCommandPool(s_vk_device, s_cmd_pool, nullptr); + s_cmd_pool = VK_NULL_HANDLE; + s_cmd = VK_NULL_HANDLE; + } } - /// Everything Initialize() allocates, released in one place so its several failure exits - /// cannot each forget a different piece. - bool FailInitialize(const char* why) + bool CreateResources(GSDeviceVK* dev, VKSwapChain* swap_chain) { - Console.ErrorFmt("@@ANDROID_LSFG@@ {} — frame generation off", why); - s_init_failed.store(true, std::memory_order_relaxed); - if (s_context_id >= 0 && s_backend.delete_context) - s_backend.delete_context(s_context_id); - s_context_id = -1; - if (s_backend.finalize) - s_backend.finalize(); - DestroyResources(); - s_device = VK_NULL_HANDLE; - s_physical_device = VK_NULL_HANDLE; - s_queue = VK_NULL_HANDLE; - return false; + const VkCommandPoolCreateInfo pool_ci = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, nullptr, + VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, dev->GetGraphicsQueueFamilyIndex()}; + if (vkCreateCommandPool(s_vk_device, &pool_ci, nullptr, &s_cmd_pool) != VK_SUCCESS) + return false; + + const VkCommandBufferAllocateInfo cmd_ai = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, nullptr, + s_cmd_pool, VK_COMMAND_BUFFER_LEVEL_PRIMARY, 1}; + if (vkAllocateCommandBuffers(s_vk_device, &cmd_ai, &s_cmd) != VK_SUCCESS) + return false; + + VkSemaphoreCreateInfo sem_ci = {}; + sem_ci.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + for (VkSemaphore& sem : s_acquire_sems) + { + if (vkCreateSemaphore(s_vk_device, &sem_ci, nullptr, &sem) != VK_SUCCESS) + return false; + } + for (VkSemaphore& sem : s_done_sems) + { + if (vkCreateSemaphore(s_vk_device, &sem_ci, nullptr, &sem) != VK_SUCCESS) + return false; + } + + // A storage view per swap chain image. The swap chain's own views are created for the + // colour-attachment format, which for an sRGB surface cannot be a storage image at all, + // so these are separate rather than borrowed. + const u32 count = swap_chain->GetImageCount(); + s_storage_views.assign(count, VK_NULL_HANDLE); + for (u32 i = 0; i < count; i++) + { + VkImageViewCreateInfo ci = {}; + ci.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + ci.image = swap_chain->GetImage(i); + ci.viewType = VK_IMAGE_VIEW_TYPE_2D; + ci.format = s_format; + ci.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (vkCreateImageView(s_vk_device, &ci, nullptr, &s_storage_views[i]) != VK_SUCCESS) + return false; + } + return true; } } // namespace @@ -917,22 +395,18 @@ namespace GSLsfg void Shutdown() { - if (!s_active && s_context_id < 0 && s_cmd_pool == VK_NULL_HANDLE) + if (!s_active && s_cmd_pool == VK_NULL_HANDLE && !s_frame_gen) return; - // The backend's own device is idled first: it reads our AHBs and we hold no fence on it, - // so on Android this is the only barrier that exists between the two devices. Then our - // own, because our copies touch the same storage. - if (s_context_id >= 0) - { - s_backend.wait_idle(); - s_backend.delete_context(s_context_id); - } - s_backend.finalize(); - s_context_id = -1; + // Everything below is destroyed immediately rather than through PCSX2's deferred path, so + // the device has to be idle first. FrameGen's destructor idles as well; this covers our + // own command buffer and semaphores, which it knows nothing about. + if (s_vk_device != VK_NULL_HANDLE) + vkDeviceWaitIdle(s_vk_device); - if (s_device != VK_NULL_HANDLE) - vkDeviceWaitIdle(s_device); + s_frame_gen.reset(); + s_allocator.reset(); + s_device.reset(); DestroyResources(); s_active = false; @@ -942,9 +416,7 @@ namespace GSLsfg s_extent = {}; s_format = VK_FORMAT_UNDEFINED; s_frame_index = 0; - s_device = VK_NULL_HANDLE; - s_physical_device = VK_NULL_HANDLE; - s_queue = VK_NULL_HANDLE; + s_vk_device = VK_NULL_HANDLE; s_display_fps.store(0.0f, std::memory_order_relaxed); s_fps_window_start = 0; @@ -956,11 +428,10 @@ namespace GSLsfg { if (!swap_chain || !g_gs_device || !IsAvailable()) return false; - if (!LoadBackend()) - { - s_init_failed.store(true, std::memory_order_relaxed); + + GSDeviceVK* dev = GSDeviceVK::GetInstance(); + if (!dev) return false; - } multiplier = std::clamp(multiplier, 2, 4); const u8 flow_scale_percent = std::clamp(GSConfig.LsfgFlowScale, 25, 100); @@ -973,137 +444,73 @@ namespace GSLsfg { return true; // idempotent; nothing changed } - if (s_active || s_cmd_pool != VK_NULL_HANDLE) - Shutdown(); - if (extent.width == 0 || extent.height == 0) + Shutdown(); + + // The one hard prerequisite that cannot be recovered from here. The swap chain decides its + // image usage at creation, and it only asks for STORAGE when frame generation was already + // enabled — so switching the feature on mid-session needs the swap chain to be recreated + // before this can succeed. Reported rather than retried, because recreating a swap chain + // from inside a present is how you get a black screen. + if (!swap_chain->IsStorageUsageAvailable()) + { + Console.Warning("LSFG: swap chain has no STORAGE usage — frame generation needs a " + "renderer restart to take effect."); + s_init_failed.store(true, std::memory_order_relaxed); return false; - - GSDeviceVK* dev = GSDeviceVK::GetInstance(); - s_device = dev->GetDevice(); - s_physical_device = dev->GetPhysicalDevice(); - s_queue = dev->GetGraphicsQueue(); - - if (!CreateAhbImage(s_frame[0], extent, format) || !CreateAhbImage(s_frame[1], extent, format)) - return FailInitialize("could not allocate the shared frame images"); - s_generated.resize(multiplier - 1); - for (u32 i = 0; i < multiplier - 1; i++) - { - if (!CreateAhbImage(s_generated[i], extent, format)) - return FailInitialize("could not allocate the interpolated frame images"); } - const VkCommandPoolCreateInfo pool_info = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, nullptr, - VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, dev->GetGraphicsQueueFamilyIndex()}; - if (vkCreateCommandPool(s_device, &pool_info, nullptr, &s_cmd_pool) != VK_SUCCESS) - return FailInitialize("vkCreateCommandPool failed"); - - { - // One pre-copy plus one post-copy per interpolated frame. - std::vector buffers(multiplier); - const VkCommandBufferAllocateInfo alloc = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, nullptr, - s_cmd_pool, VK_COMMAND_BUFFER_LEVEL_PRIMARY, multiplier}; - if (vkAllocateCommandBuffers(s_device, &alloc, buffers.data()) != VK_SUCCESS) - return FailInitialize("vkAllocateCommandBuffers failed"); - s_pre_copy_cmd = buffers[0]; - s_post_copy_cmds.assign(buffers.begin() + 1, buffers.end()); - } - - { - const VkSemaphoreCreateInfo sem_info = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; - bool ok = vkCreateSemaphore(s_device, &sem_info, nullptr, &s_pre_copy_sem) == VK_SUCCESS; - s_post_copy_sems.assign(multiplier - 1, VK_NULL_HANDLE); - s_acquire_sems.assign(multiplier - 1, VK_NULL_HANDLE); - for (u32 i = 0; ok && i < multiplier - 1; i++) - { - ok = vkCreateSemaphore(s_device, &sem_info, nullptr, &s_post_copy_sems[i]) == VK_SUCCESS && - vkCreateSemaphore(s_device, &sem_info, nullptr, &s_acquire_sems[i]) == VK_SUCCESS; - } - if (!ok) - return FailInitialize("vkCreateSemaphore failed"); - } - - // The extractor throws — pe-parse failures, a DLL missing shaders, a malformed DXBC. It - // is the one part of bring-up that runs user-supplied data, so it is also the part most - // likely to fail, and it must degrade to "feature off" rather than take the GS thread. - try - { - ExtractShaders(); - } - catch (const std::exception& ex) - { - return FailInitialize(ex.what()); - } - catch (...) - { - return FailInitialize("Lossless.dll could not be read"); - } - - // 3.1p when the user asked for it AND their DLL carries it. A version that predates the - // performance family would otherwise fail initialise with "Shader hash not found", which - // reads like a corrupt file and is not — it just means this Lossless Scaling is older. - bool use_performance = GSConfig.LsfgPerformance; - if (use_performance && !s_have_performance) - { - Console.WriteLn("@@ANDROID_LSFG@@ this Lossless.dll has no 3.1p shaders — using 3.1"); - use_performance = false; - } - else if (!use_performance && !s_have_standard) - { - Console.WriteLn("@@ANDROID_LSFG@@ this Lossless.dll has only 3.1p shaders — using 3.1p"); - use_performance = true; - } - - // ★ flowScale is a DIVISOR, not a multiplier: framegen sizes the optical-flow pyramid as - // `inputExtent / flowScale`, and upstream's own layer reaches it by passing - // `1.0 / conf.flowScale` from a [0.25, 1.0] fraction. So the percentage the UI shows has - // to be INVERTED here. Handing it 0.25 for "25%" would make the pyramid four times larger - // per axis — sixteen times the pixels — which is the exact opposite of what a user - // dragging that slider down is asking for. - const float flow_scale = std::clamp(100.0f / static_cast(flow_scale_percent), 1.0f, 4.0f); - - const VkPhysicalDeviceProperties& props = dev->GetDeviceProperties(); - const u64 device_uuid = (static_cast(props.vendorID) << 32) | props.deviceID; - // is_hdr is false and stays false: it tells framegen its images carry HDR primaries, and - // there is no HDR output path in ARMSX2 for that to be true against — the swapchain is - // the 8-bit UNORM or 16-bit float surface CreateAhbImage already restricts us to. - if (s_backend.initialize(device_uuid, /*is_hdr*/ 0, flow_scale, multiplier - 1, - use_performance ? 1 : 0, ShaderCallback, nullptr) != 0) - return FailInitialize(BackendError()); - - std::vector outputs; - outputs.reserve(s_generated.size()); - for (const AhbImage& img : s_generated) - outputs.push_back(img.ahb); - - s_context_id = s_backend.create_context(s_frame[0].ahb, s_frame[1].ahb, outputs.data(), - static_cast(outputs.size()), extent.width, extent.height, static_cast(format)); - if (s_context_id < 0) - return FailInitialize(BackendError()); - + s_vk_device = dev->GetDevice(); s_extent = extent; s_format = format; s_multiplier = multiplier; - s_performance_requested = GSConfig.LsfgPerformance; s_flow_scale_percent = flow_scale_percent; + s_performance_requested = GSConfig.LsfgPerformance; + + if (!CreateResources(dev, swap_chain)) + { + Console.Error("LSFG: failed to create frame-generation resources."); + DestroyResources(); + s_init_failed.store(true, std::memory_order_relaxed); + s_vk_device = VK_NULL_HANDLE; + return false; + } + + s_device.emplace(dev); + // Both are required by the interpolation shaders and neither is core in Vulkan 1.1, so + // GSDeviceVK requests them as extensions. It also clears the flag when a driver advertises + // one without really supporting it, which is why these are asked of the DEVICE rather than + // of vkGetPhysicalDeviceFeatures2 — see the note in LsfgVkCompat.cpp. + if (!s_device->IsVulkanMemoryModelSupported() || !s_device->HasNullDescriptor()) + { + Console.Warning("LSFG: device lacks the Vulkan memory model or nullDescriptor — " + "frame generation unavailable."); + s_device.reset(); + DestroyResources(); + s_init_failed.store(true, std::memory_order_relaxed); + s_vk_device = VK_NULL_HANDLE; + return false; + } + + s_allocator.emplace(dev->GetAllocator()); + s_frame_gen.emplace(*s_allocator, dev); + s_frame_index = 0; s_active = true; - Console.WriteLnFmt("@@ANDROID_LSFG@@ active: {}x{} x{} frames, {}, flow {}%", extent.width, extent.height, - multiplier, use_performance ? "3.1p" : "3.1", flow_scale_percent); + s_init_failed.store(false, std::memory_order_relaxed); + Console.WriteLn("LSFG: frame generation active (%ux, %ux%u).", multiplier, extent.width, extent.height); return true; } bool PresentWithGeneration( VkQueue present_queue, VKSwapChain* swap_chain, VkSemaphore render_finished, bool frame_has_new_content) { - if (!s_active || !swap_chain) + if (!s_active || !swap_chain || !s_frame_gen) return false; // Nothing new to interpolate between. Pause menus, boot screens before the GS has any // output, and the blank frames a fade produces all land here — inventing motion across - // them is wrong AND costs a full generation pass per frame to do it. The history goes - // with them: keeping it would stitch the frame before the gap to the frame after it and - // produce one bogus in-between frame on the way back. + // them is wrong AND costs a full generation pass per frame to do it. if (!frame_has_new_content) { s_frame_index = 0; @@ -1111,9 +518,8 @@ namespace GSLsfg return false; } - // A resize between Initialize and here would have us copying between mismatched extents. - // Decline the frame; the caller presents normally and the next Initialize picks up the - // new size. + // A resize between Initialize and here would have us reading mismatched extents. Decline + // the frame; the caller presents normally and the next Initialize picks up the new size. if (swap_chain->GetWidth() != s_extent.width || swap_chain->GetHeight() != s_extent.height) { NoteFramesDisplayed(1, 0); @@ -1121,134 +527,127 @@ namespace GSLsfg } const u32 real_index = swap_chain->GetCurrentImageIndex(); - VkImage real_image = swap_chain->GetCurrentTexture()->GetImage(); + if (real_index >= s_storage_views.size()) + { + NoteFramesDisplayed(1, 0); + return false; + } + const VkImage real_image = swap_chain->GetImage(real_index); - // The first frame has no predecessor to interpolate from: seed one slot and present it - // plainly. Generation starts on the frame after. - const bool have_previous = s_frame_index > 0; - AhbImage& target = s_frame[s_frame_index % 2]; - - // 1. Copy the frame the caller just rendered into our shared storage. It is in - // PRESENT_SRC because EndPresent transitioned it there, and it has to stay that way - // for the final present below. + // 1. Record the chain work for this frame, then ask how many frames to interpolate. + // WantedGenerations drives the PACER, which is the whole reason a game bouncing + // between 60 and 30fps does not judder here: the count varies to hold the presented + // rate steady rather than blindly multiplying whatever arrived. const VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, nullptr, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr}; - vkResetCommandBuffer(s_pre_copy_cmd, 0); - if (vkBeginCommandBuffer(s_pre_copy_cmd, &begin) != VK_SUCCESS) - return false; - RecordCopy(s_pre_copy_cmd, real_image, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, target.image, - VK_IMAGE_LAYOUT_GENERAL, s_extent); - if (vkEndCommandBuffer(s_pre_copy_cmd) != VK_SUCCESS) + vkResetCommandBuffer(s_cmd, 0); + if (vkBeginCommandBuffer(s_cmd, &begin) != VK_SUCCESS) return false; - // Past this point the caller's semaphore is consumed and there is no way back to its own - // present path, so every later failure must still present something. - if (!SubmitOneShot(s_pre_copy_cmd, render_finished, s_pre_copy_sem)) + // The ported passes expect a presentable image in GENERAL; PCSX2 hands it over in + // PRESENT_SRC_KHR and needs it back that way. + TransitionImage(s_cmd, real_image, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_GENERAL, + VK_ACCESS_MEMORY_READ_BIT, VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT); + + const Vulkan::vk::CommandBuffer cmdbuf{s_cmd}; + s_frame_gen->Process(*s_device, cmdbuf, real_image, s_storage_views[real_index], s_extent, s_format, + s_extent); + + const size_t wanted = s_frame_gen->WantedGenerations( + std::min(s_multiplier - 1u, swap_chain->GetImageCount() - 1u)); + const size_t available = s_frame_gen->GeneratedFrameCount(); + const size_t generations = std::min(wanted, available); + + // 2. Acquire a swap chain image per generated frame and record its generation pass. The + // acquires happen BEFORE the single submit below because that submit has to wait on + // every acquire semaphore at once. + u32 acquired_index[VideoCore::FrameGen::MAX_GENERATIONS] = {}; + size_t acquired = 0; + for (size_t i = 0; i < generations && i < VideoCore::FrameGen::MAX_GENERATIONS; i++) + { + // ★ Bounded, but NOT zero. A zero timeout looks right — an interpolated frame is a + // bonus, so why stall for one — and it silently disables the entire feature: under + // FIFO the presentation engine hands an image back at a vblank, so at steady state + // nothing is EVER free instantly, every acquire returns VK_NOT_READY, and every + // generated frame is dropped. Observed exactly that on an Adreno 740. Waiting for a + // display slot IS the mechanism: presenting two frames per rendered frame means + // waiting for the second slot. + static constexpr u64 kGeneratedAcquireTimeoutNs = 50ull * 1000 * 1000; + u32 image_index = 0; + const VkResult acq = vkAcquireNextImageKHR(s_vk_device, swap_chain->GetSwapChain(), + kGeneratedAcquireTimeoutNs, s_acquire_sems[i], VK_NULL_HANDLE, &image_index); + if (acq != VK_SUCCESS && acq != VK_SUBOPTIMAL_KHR) + break; // nothing free, out of date, or lost — still present the real frame + if (image_index >= s_storage_views.size()) + break; + + s_frame_gen->GenerateInto(*s_device, cmdbuf, swap_chain->GetImage(image_index), + s_storage_views[image_index], i); + // The generation pass leaves its target in GENERAL. + TransitionImage(s_cmd, swap_chain->GetImage(image_index), VK_IMAGE_LAYOUT_GENERAL, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_MEMORY_READ_BIT); + + acquired_index[acquired++] = image_index; + } + + TransitionImage(s_cmd, real_image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_MEMORY_READ_BIT); + + if (vkEndCommandBuffer(s_cmd) != VK_SUCCESS) + return false; + + // 3. ONE submit. It waits on the caller's render-finished semaphore plus every acquire, + // and signals one semaphore per present that follows — see the note by s_cmd_pool for + // why this is not a submit per frame. + VkSemaphore wait_sems[1 + VideoCore::FrameGen::MAX_GENERATIONS] = {}; + VkPipelineStageFlags wait_stages[1 + VideoCore::FrameGen::MAX_GENERATIONS] = {}; + u32 wait_count = 0; + wait_sems[wait_count] = render_finished; + wait_stages[wait_count++] = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + for (size_t i = 0; i < acquired; i++) + { + wait_sems[wait_count] = s_acquire_sems[i]; + wait_stages[wait_count++] = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + } + + const u32 signal_count = static_cast(acquired) + 1u; + VkSubmitInfo submit = {}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.waitSemaphoreCount = wait_count; + submit.pWaitSemaphores = wait_sems; + submit.pWaitDstStageMask = wait_stages; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &s_cmd; + submit.signalSemaphoreCount = signal_count; + submit.pSignalSemaphores = s_done_sems.data(); + + if (vkQueueSubmit(GSDeviceVK::GetInstance()->GetGraphicsQueue(), 1, &submit, VK_NULL_HANDLE) != VK_SUCCESS) return false; s_frame_index++; - if (!have_previous) - { - // Nothing to interpolate from yet — present the real frame, waiting on the copy so - // the shared image is complete before the next frame reads it. - const VkPresentInfoKHR present = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, &s_pre_copy_sem, 1, - swap_chain->GetSwapChainPtr(), &real_index, nullptr}; - swap_chain->ResetImageAcquireResult(); - vkQueuePresentKHR(present_queue, &present); - NoteFramesDisplayed(1, 0); - return true; - } - - // 2. Hand both frames to the interpolator, then block until it is done. There is no - // cross-device semaphore on Android — framegen runs on its own VkDevice and Turnip - // rejects OPAQUE_FD export on AHB-imported memory — so a device idle is the only - // barrier that exists. This is why frame generation costs latency here rather than - // being free. - vkQueueWaitIdle(s_queue); // our pre-copy must land before framegen reads that image - const bool generated = (s_backend.present(s_context_id) == 0); - if (!generated) - { - Console.ErrorFmt("@@ANDROID_LSFG@@ generation failed: {}", BackendError()); - } - else - s_backend.wait_idle(); - - // 3. Present each interpolated frame, then the real one last — the generated frames sit - // between the previous real frame and this one, so they display first. Presents issued - // on one queue are processed in call order, which is what puts them on screen in that - // order without any semaphore between them. - // - // ★ EVERY binary semaphore below is signalled exactly once and waited exactly once, and - // that is a correctness requirement, not tidiness. This loop used to reassign the real - // present's wait to the last post-copy semaphore, which left s_pre_copy_sem signalled and - // never waited — so the next frame signalled an already-signalled binary semaphore — while - // the last post-copy semaphore was waited twice, by its own present and by the real one. - // - // The real present waits on s_pre_copy_sem and nothing else, for a concrete reason: the - // pre-copy READS the real image as TRANSFER_SRC and puts it back in PRESENT_SRC, so - // presenting it before that lands would present an image still being read. The generated - // presents are independent — different swapchain images, each gated by its own post-copy. - // - // Counted rather than assumed to be s_multiplier - 1: every break below drops a frame that - // was generated but never displayed, and the overlay is supposed to report what reached the - // screen, not what we hoped would. + // 4. Generated frames first — they sit between the previous real frame and this one, so + // they display first. Presents issued on one queue are processed in call order, which + // is what puts them on screen in that order without a semaphore between them. u32 presented_generated = 0; - if (generated) + for (size_t i = 0; i < acquired; i++) { - for (u32 i = 0; i < s_multiplier - 1; i++) - { - u32 image_index = 0; - // ★ Bounded, but NOT zero. A zero timeout looks right — an interpolated frame is - // a bonus, so why stall for one — and it silently disables the entire feature: - // under FIFO the presentation engine hands an image back at a vblank, so at - // steady state nothing is EVER free instantly, every acquire returns - // VK_NOT_READY, and every generated frame is dropped. Observed exactly that on - // an Adreno 740: LSFG active, FIFO confirmed, display rate still equal to the - // real rate. Waiting for a display slot IS the mechanism here — presenting two - // frames per rendered frame means waiting for the second slot. - // - // The bound is what keeps a lost surface from wedging the GS thread the way an - // infinite wait would (see VKSwapChain::AcquireNextImage's own timeout, and the - // background/rotate/fold case it documents). Generous next to a refresh interval - // — 6 vblanks at 120Hz — so it only expires when something is actually wrong. - static constexpr u64 kGeneratedAcquireTimeoutNs = 50ull * 1000 * 1000; - const VkResult acq = vkAcquireNextImageKHR(s_device, swap_chain->GetSwapChain(), - kGeneratedAcquireTimeoutNs, s_acquire_sems[i], VK_NULL_HANDLE, &image_index); - if (acq != VK_SUCCESS && acq != VK_SUBOPTIMAL_KHR) - { - break; // nothing free, out of date, or lost — still present the real frame - } - - vkResetCommandBuffer(s_post_copy_cmds[i], 0); - if (vkBeginCommandBuffer(s_post_copy_cmds[i], &begin) != VK_SUCCESS) - break; - RecordCopy(s_post_copy_cmds[i], s_generated[i].image, VK_IMAGE_LAYOUT_GENERAL, - swap_chain->GetImage(image_index), VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, s_extent); - if (vkEndCommandBuffer(s_post_copy_cmds[i]) != VK_SUCCESS) - break; - - if (!SubmitOneShot(s_post_copy_cmds[i], s_acquire_sems[i], s_post_copy_sems[i])) - break; - - const VkPresentInfoKHR present = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, - &s_post_copy_sems[i], 1, swap_chain->GetSwapChainPtr(), &image_index, nullptr}; - // ★ VK_SUBOPTIMAL_KHR IS A SUCCESS CODE — the frame was presented. Treating it as - // failure here broke nothing visible and made the overlay lie: the generated - // frame reached the screen, we broke out before counting it, and the display rate - // read exactly the real rate forever. Suboptimal is routine on Android (rotation, - // insets, a driver preferring a different transform), so this fired every frame. - // The acquire above already gets this right; this did not. - const VkResult pres = vkQueuePresentKHR(present_queue, &present); - if (pres != VK_SUCCESS && pres != VK_SUBOPTIMAL_KHR) - break; - presented_generated++; - } + const VkPresentInfoKHR present = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, &s_done_sems[i], 1, + swap_chain->GetSwapChainPtr(), &acquired_index[i], nullptr}; + // ★ VK_SUBOPTIMAL_KHR IS A SUCCESS CODE — the frame WAS presented. Treating it as + // failure broke nothing visible and made the overlay lie: the generated frame reached + // the screen, we stopped counting, and the display rate read exactly the real rate + // forever. Suboptimal is routine on Android (rotation, insets, a driver preferring a + // different transform), so it fired every frame. + const VkResult pres = vkQueuePresentKHR(present_queue, &present); + if (pres != VK_SUCCESS && pres != VK_SUBOPTIMAL_KHR) + break; + presented_generated++; } - // 4. The real frame goes out last, after whatever generated frames made it. - const VkPresentInfoKHR present = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, &s_pre_copy_sem, 1, - swap_chain->GetSwapChainPtr(), &real_index, nullptr}; + // 5. The real frame goes out last, after whatever generated frames made it. + const VkPresentInfoKHR present = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, + &s_done_sems[acquired], 1, swap_chain->GetSwapChainPtr(), &real_index, nullptr}; swap_chain->ResetImageAcquireResult(); vkQueuePresentKHR(present_queue, &present); NoteFramesDisplayed(1, presented_generated); diff --git a/pcsx2/GS/Renderers/Vulkan/VKSwapChain.cpp b/pcsx2/GS/Renderers/Vulkan/VKSwapChain.cpp index 9e3c29ac44..d91cd10f6e 100644 --- a/pcsx2/GS/Renderers/Vulkan/VKSwapChain.cpp +++ b/pcsx2/GS/Renderers/Vulkan/VKSwapChain.cpp @@ -700,6 +700,33 @@ bool VKSwapChain::CreateSwapChain() return false; } + // Frame generation writes interpolated frames straight into a swap chain image through a + // storage view, so those images need STORAGE usage. Requested only when frame generation is + // actually on, and only when BOTH the surface and the chosen format allow it — asking + // unconditionally would fail swap chain creation outright on drivers that do not, taking the + // whole renderer down for a feature that is off. + // + // The format check is the one that is easy to forget: a surface can report STORAGE in + // supportedUsageFlags while the sRGB format selected for it has no STORAGE_IMAGE feature bit, + // and the mismatch only shows up as a validation error at image-view creation. + m_storage_usage_available = false; + if (GSConfig.LsfgEnabled && (surface_capabilities.supportedUsageFlags & VK_IMAGE_USAGE_STORAGE_BIT) != 0) + { + VkFormatProperties fp = {}; + vkGetPhysicalDeviceFormatProperties(GSDeviceVK::GetInstance()->GetPhysicalDevice(), + surface_format->format, &fp); + if ((fp.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT) != 0) + { + image_usage |= VK_IMAGE_USAGE_STORAGE_BIT; + m_storage_usage_available = true; + } + else + { + Console.Warning("Vulkan: swap chain format has no STORAGE_IMAGE feature; " + "frame generation will report itself unavailable."); + } + } + // Store the old/current swap chain when recreating for resize // Old swap chain is destroyed regardless of whether the create call succeeds VkSwapchainKHR old_swap_chain; diff --git a/pcsx2/GS/Renderers/Vulkan/VKSwapChain.h b/pcsx2/GS/Renderers/Vulkan/VKSwapChain.h index 765d84074f..3598c664c0 100644 --- a/pcsx2/GS/Renderers/Vulkan/VKSwapChain.h +++ b/pcsx2/GS/Renderers/Vulkan/VKSwapChain.h @@ -94,6 +94,12 @@ public: } VkFormat GetTextureFormat() const; + + /// True when the swap chain images carry VK_IMAGE_USAGE_STORAGE_BIT, which frame generation + /// requires in order to write an interpolated frame directly into one. False whenever frame + /// generation was off at creation time, so switching it on needs a swap chain recreate — see + /// GSLsfg::Initialize. + __fi bool IsStorageUsageAvailable() const { return m_storage_usage_available; } VkResult AcquireNextImage(); void ReleaseCurrentImage(); void ResetImageAcquireResult(); @@ -128,6 +134,7 @@ private: VkSwapchainKHR m_swap_chain = VK_NULL_HANDLE; std::vector> m_images; + bool m_storage_usage_available = false; std::array m_semaphores = {}; u32 m_current_image = 0; diff --git a/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt b/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt index 7d131bacd4..034f132d37 100644 --- a/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt +++ b/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt @@ -63,7 +63,7 @@ import java.io.File * * [BuildConfig.LSFG] is false in the Play build and the whole section compiles out of it — * the native side is not there either (ARMSX2_ENABLE_LSFG is off, so GSLsfg answers - * NOT_COMPILED_IN), and build-play-aab.sh fails the build if libarmsx2_lsfg.so ever + * NOT_COMPILED_IN), and build-play-aab.sh fails the build if the ported frame-generation code ever * appears in the bundle. */ diff --git a/platforms/android/app/src/main/cpp/3rdparty/lsfg/CMakeLists.txt b/platforms/android/app/src/main/cpp/3rdparty/lsfg/CMakeLists.txt deleted file mode 100644 index 2e95b49bdc..0000000000 --- a/platforms/android/app/src/main/cpp/3rdparty/lsfg/CMakeLists.txt +++ /dev/null @@ -1,180 +0,0 @@ -# LSFG — Lossless Scaling frame generation, via lsfg-vk-android. -# -# Fetched at configure time from a pinned commit rather than vendored, for the same reason -# librashader is: the source tree plus its four submodules is ~130 MB, most of it test data -# and a Rust UI we never build. -# -# WHAT WE TAKE AND WHAT WE DO NOT -# -# Only the MIT-licensed `lsfg-vk-android` repository, and inside it only the framegen static -# library. Its sibling `LSFG-Android-Application` carries a "No Play Store, No Commercial Use" -# licence and is deliberately NOT fetched — nor is it needed. That app exists to capture the -# screen with MediaProjection and composite over another process, because Android 12+ forbids -# injecting code into a non-debuggable app. ARMSX2 owns its own swapchain, so it drives the -# library's AHardwareBuffer entry points directly: no screen capture, no overlay window, no -# accessibility service. -# -# The `liblsfg-vk.so` Vulkan-layer target from upstream is also skipped. It fails to link -# against the NDK anyway (vkGetAndroidHardwareBufferPropertiesANDROID is an extension entry -# point, not a plain export, so it must come from vkGetDeviceProcAddr) — and a layer is the -# wrong shape for an in-process caller. We link the static framegen lib and resolve Vulkan -# through the loader the GS backend already has. -# -# NOTHING PROPRIETARY IS FETCHED OR SHIPPED. The frame-generation shaders are not in this -# repository or upstream's; they are read at runtime out of the user's own legitimately -# purchased Lossless.dll, which they supply through the Storage Access Framework. This build -# only produces the code that can read one. -# -# OPTIONAL, like librashader and the Discord SDK: ARMSX2_ENABLE_LSFG defaults OFF and the play -# flavour never sets it, so the feature compiles out entirely rather than failing to configure. - -if(NOT ARMSX2_ENABLE_LSFG) - message(STATUS "LSFG: disabled (ARMSX2_ENABLE_LSFG is off) — frame generation compiles out") - set(ARMSX2_HAVE_LSFG OFF PARENT_SCOPE) - return() -endif() - -if(NOT ANDROID) - message(STATUS "LSFG: skipped — the AHardwareBuffer path is Android-only") - set(ARMSX2_HAVE_LSFG OFF PARENT_SCOPE) - return() -endif() - -# Pinned to a COMMIT, which is what the paragraph below actually asks for. -# -# This said `release` — a branch — while the comment claimed it was pinned, so every configure -# re-resolved it and a remote push could silently change what the core links. That is the exact -# failure the comment was written to prevent, and it went unnoticed because a branch name reads -# like a tag at a glance. This library reaches into Vulkan device internals and ships inside our -# APK; it does not get to move on its own. -# -# 3e89e54 "feat: enable shaderFloat16 support in logical device creation" is the tip of `release` -# that ARMSX2 has actually been built and verified against. Bump it deliberately, and re-verify. -set(LSFG_PIN "3e89e5439a98f55d5acb003d20039426ab24e69c" - CACHE STRING "lsfg-vk-android commit to build against") - -include(FetchContent) -FetchContent_Declare(lsfg_src - GIT_REPOSITORY https://github.com/FrankBarretta/lsfg-vk-android.git - GIT_TAG ${LSFG_PIN} - # GIT_SHALLOW cannot fetch an arbitrary commit — a shallow clone only carries branch tips, - # so it works for a ref name and fails for the SHA above. - GIT_SHALLOW FALSE - GIT_SUBMODULES_RECURSE TRUE - # Populate only; its top-level CMakeLists builds the layer .so we do not want, and adding - # it wholesale would drag the Rust UI target in with it. - SOURCE_SUBDIR framegen -) -FetchContent_MakeAvailable(lsfg_src) - -if(NOT EXISTS "${lsfg_src_SOURCE_DIR}/framegen/CMakeLists.txt") - message(WARNING "LSFG: fetch produced no framegen/ — compiling the feature out") - set(ARMSX2_HAVE_LSFG OFF PARENT_SCOPE) - return() -endif() - -# --- the shader path ------------------------------------------------------------------- -# framegen does not read Lossless.dll. Its initialize() takes a loader callback and expects -# SPIR-V back, so the caller owns the whole chain: pull the RCDATA resources out of the PE, -# then translate each one from DXBC to SPIR-V. Upstream does that in its layer .so, which we -# do not build — so we assemble the same two pieces here and drive them from GSLsfg.cpp. -# -# pe-parse reads the resource directory out of the user's DLL -# dxbc DXVK's DXBC->SPIR-V compiler, which does the actual translation -# trans.cpp upstream's thin wrapper over dxbc that also rewrites descriptor bindings -# -# extract.cpp is deliberately NOT taken: it hunts for Steam install paths and pulls in -# upstream's toml11 config system, neither of which means anything on Android. Our path -# comes from the SAF picker, so GSLsfg.cpp does that ~30 lines of pe-parse itself. -foreach(dep dxbc pe-parse/pe-parser-library volk) - if(NOT EXISTS "${lsfg_src_SOURCE_DIR}/thirdparty/${dep}/CMakeLists.txt") - message(WARNING "LSFG: thirdparty/${dep} missing (submodules not fetched?) — compiling out") - set(ARMSX2_HAVE_LSFG OFF PARENT_SCOPE) - return() - endif() -endforeach() -if(NOT TARGET lsfg-vk-framegen) - message(WARNING "LSFG: lsfg-vk-framegen target absent after fetch — compiling the feature out") - set(ARMSX2_HAVE_LSFG OFF PARENT_SCOPE) - return() -endif() - -add_subdirectory("${lsfg_src_SOURCE_DIR}/thirdparty/dxbc" - "${CMAKE_CURRENT_BINARY_DIR}/dxbc" EXCLUDE_FROM_ALL) -add_subdirectory("${lsfg_src_SOURCE_DIR}/thirdparty/pe-parse/pe-parser-library" - "${CMAKE_CURRENT_BINARY_DIR}/pe-parse" EXCLUDE_FROM_ALL) -# framegen links volk PUBLIC. It is NOT in the fetch's SOURCE_SUBDIR, so it has to be added -# by hand or the framegen compile fails on a missing . -add_subdirectory("${lsfg_src_SOURCE_DIR}/thirdparty/volk" - "${CMAKE_CURRENT_BINARY_DIR}/volk" EXCLUDE_FROM_ALL) - -# Upstream's translator, compiled on its own so we inherit their binding-rewrite fixes -# instead of forking a copy of it into our tree. -add_library(lsfg-shader-translate STATIC "${lsfg_src_SOURCE_DIR}/src/extract/trans.cpp") -set_target_properties(lsfg-shader-translate PROPERTIES - CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON POSITION_INDEPENDENT_CODE ON) -target_include_directories(lsfg-shader-translate PUBLIC "${lsfg_src_SOURCE_DIR}/include") -# trans.cpp includes , which lives under dxbc's include/spirv. -target_link_libraries(lsfg-shader-translate PUBLIC dxbc) - -# VK_USE_PLATFORM_ANDROID_KHR is REQUIRED, not cosmetic: without it the Vulkan headers hide -# VkAndroidHardwareBufferFormatPropertiesANDROID and friends, and framegen/src/core/image.cpp -# fails with three "unknown type name" errors. Upstream's own Gradle defines it, which is why -# a bare CMake configure of that repo does not build. -# -# PRIVATE, not PUBLIC: framegen's own sources need it, but its public header does not, and -# PUBLIC leaks -DVK_USE_PLATFORM_ANDROID_KHR onto every consumer — where it collides with -# VKLoader.h's own valueless #define and emits a macro-redefined warning in each of the -# hundreds of GS translation units. -target_compile_definitions(lsfg-vk-framegen PRIVATE VK_USE_PLATFORM_ANDROID_KHR) -# volk needs it too, and for a reason that is not obvious: volk generates its PFN_* globals -# from the same platform macros, so without this it never defines -# vkGetAndroidHardwareBufferPropertiesANDROID while framegen — compiled WITH the macro — -# references it. The result is a single undefined symbol at link, pointing at framegen rather -# than at the volk target that actually dropped it. -target_compile_definitions(volk PUBLIC VK_USE_PLATFORM_ANDROID_KHR) -# The interpolation runs on its own VkDevice, so it needs the AHB import/export entry points -# from libvulkan plus libandroid for AHardwareBuffer itself. -target_link_libraries(lsfg-vk-framegen PUBLIC vulkan android) - -# ARMSX2's own build turns exceptions OFF globally (BuildParameters.cmake, add_compile_options -# at top level, inherited by every subdirectory added after it). Every one of these libraries -# reports failure by throwing — 62 throw sites in framegen, 53 in dxbc — so they do not merely -# warn without this, they fail to compile. Re-enabled per target rather than by editing the -# global flag, so nothing else in the emulator gains exception handling as a side effect. -foreach(lsfg_target lsfg-vk-framegen dxbc lsfg-shader-translate) - target_compile_options(${lsfg_target} PRIVATE -fexceptions) -endforeach() - -# --- the isolation boundary ------------------------------------------------------------- -# -# framegen goes into its OWN shared object, never into libemucore. volk (which framegen links) -# defines 759 globals named vkCreateImage, vkQueueSubmit and so on — precisely the names -# PCSX2's VKLoader.cpp defines. In one library that is a duplicate-symbol error at best; at -# worst the linker merges them and framegen's volkLoadDevice() call, made against ITS OWN -# VkDevice, silently repoints every entry point the GS renderer uses. A separate .so gives -# volk its own copies and is the only arrangement where the two loaders can coexist. -# -# The interface is C, for the second half of the same problem: ANDROID_STL=c++_static means -# each .so carries its own libc++, so an std::vector crossing the boundary would be two -# unrelated types sharing a name. See armsx2_lsfg_shim.h. -add_library(armsx2_lsfg SHARED "${CMAKE_CURRENT_SOURCE_DIR}/armsx2_lsfg_shim.cpp") -set_target_properties(armsx2_lsfg PROPERTIES - CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON) -target_compile_options(armsx2_lsfg PRIVATE -fexceptions) -target_compile_definitions(armsx2_lsfg PRIVATE VK_USE_PLATFORM_ANDROID_KHR) -target_link_libraries(armsx2_lsfg PRIVATE lsfg-vk-framegen) -# Everything is hidden except the handful of armsx2_lsfg_* entry points, which are marked -# visible in the shim itself. In particular framegen's own LSFG_3_1 symbols stay internal, so -# nothing can reach past the boundary by accident. -target_link_options(armsx2_lsfg PRIVATE -Wl,--exclude-libs,ALL) - -set(ARMSX2_HAVE_LSFG ON PARENT_SCOPE) -# Only the shim header and the shader chain reach libemucore. framegen's headers deliberately -# do NOT: nothing in the core should be able to call it directly. -set(ARMSX2_LSFG_INCLUDE - "${CMAKE_CURRENT_SOURCE_DIR}" - "${lsfg_src_SOURCE_DIR}/include" - PARENT_SCOPE) -set(ARMSX2_LSFG_LIBRARIES lsfg-shader-translate pe-parse PARENT_SCOPE) -message(STATUS "LSFG: enabled — framegen isolated in libarmsx2_lsfg.so, core links the shader chain only") diff --git a/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.cpp b/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.cpp deleted file mode 100644 index 6a3c7ffc7d..0000000000 --- a/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.cpp +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team -// SPDX-License-Identifier: GPL-3.0+ - -// Implementation of the C ABI declared in armsx2_lsfg_shim.h. Compiled ONLY into -// libarmsx2_lsfg.so, alongside framegen and its private copy of volk. See the header for why -// this boundary exists at all. - -#include "armsx2_lsfg_shim.h" - -#include "lsfg_3_1.hpp" -#include "lsfg_3_1p.hpp" - -#include -#include -#include - -#define LSFG_EXPORT extern "C" __attribute__((visibility("default"))) - -namespace -{ - // Thread-local so a failure on the GS thread cannot be overwritten by one elsewhere, and so - // the returned pointer stays valid without any locking on the reader's side. - thread_local std::string t_last_error; - - // Which shader family initialise() picked, fixed until finalize(). LSFG_3_1 and LSFG_3_1P - // keep entirely separate device state and context tables, so a context created by one cannot - // be presented or destroyed through the other — which is why every entry point below has to - // dispatch on this rather than only the one that chose it. - bool g_performance = false; - - void ClearError() { t_last_error.clear(); } - void SetError(const char* what) { t_last_error = what ? what : "unknown error"; } -} // namespace - -LSFG_EXPORT uint32_t armsx2_lsfg_abi_version(void) -{ - return ARMSX2_LSFG_ABI_VERSION; -} - -LSFG_EXPORT const char* armsx2_lsfg_last_error(void) -{ - return t_last_error.c_str(); -} - -LSFG_EXPORT int armsx2_lsfg_initialize(uint64_t device_uuid, int is_hdr, float flow_scale, - uint32_t generation_count, int performance, armsx2_lsfg_shader_fn loader, void* user) -{ - ClearError(); - if (!loader) - { - SetError("no shader loader supplied"); - return -1; - } - - // The std::function wrapper is constructed HERE, on this side of the boundary, so the - // std::vector it returns is allocated and freed by the same libc++ that framegen uses. - // Building it on the caller's side would hand framegen a vector from a different - // allocator — the exact hazard this shim exists to prevent. - const auto bridge = [loader, user](const std::string& name) -> std::vector { - const uint8_t* data = nullptr; - uint32_t size = 0; - if (loader(user, name.c_str(), &data, &size) != 0 || !data || size == 0) - throw std::runtime_error("shader '" + name + "' could not be loaded"); - return std::vector(data, data + size); - }; - - // Recorded BEFORE the call, so a throw out of initialise cannot leave the family flag and - // whatever partial state framegen kept disagreeing about which library owns the teardown. - g_performance = (performance != 0); - - try - { - if (g_performance) - LSFG_3_1P::initialize(device_uuid, is_hdr != 0, flow_scale, generation_count, bridge); - else - LSFG_3_1::initialize(device_uuid, is_hdr != 0, flow_scale, generation_count, bridge); - return 0; - } - catch (const std::exception& ex) - { - SetError(ex.what()); - return -1; - } - catch (...) - { - SetError("unknown exception during initialise"); - return -1; - } -} - -LSFG_EXPORT int32_t armsx2_lsfg_create_context(AHardwareBuffer* in0, AHardwareBuffer* in1, - AHardwareBuffer* const* outs, uint32_t out_count, uint32_t width, uint32_t height, uint32_t format) -{ - ClearError(); - try - { - std::vector out_vec(outs, outs + out_count); - const VkExtent2D extent = {width, height}; - if (g_performance) - return LSFG_3_1P::createContextFromAHB(in0, in1, out_vec, extent, static_cast(format)); - return LSFG_3_1::createContextFromAHB(in0, in1, out_vec, extent, static_cast(format)); - } - catch (const std::exception& ex) - { - SetError(ex.what()); - return -1; - } - catch (...) - { - SetError("unknown exception creating the context"); - return -1; - } -} - -LSFG_EXPORT int armsx2_lsfg_present(int32_t context) -{ - ClearError(); - try - { - // No semaphores: on Android framegen has no exportable cross-device semaphore (Turnip - // rejects OPAQUE_FD on AHB-imported memory), so the caller brackets this with idle waits - // instead. Passing -1 and an empty list is upstream's own Android path. - if (g_performance) - LSFG_3_1P::presentContext(context, -1, {}); - else - LSFG_3_1::presentContext(context, -1, {}); - return 0; - } - catch (const std::exception& ex) - { - SetError(ex.what()); - return -1; - } - catch (...) - { - SetError("unknown exception during generation"); - return -1; - } -} - -LSFG_EXPORT void armsx2_lsfg_wait_idle(void) -{ - try - { - if (g_performance) - LSFG_3_1P::waitIdle(); - else - LSFG_3_1::waitIdle(); - } - catch (...) - { - // A failed idle leaves nothing for the caller to do differently, and this is on the - // teardown path where throwing would be worse than continuing. - } -} - -LSFG_EXPORT void armsx2_lsfg_delete_context(int32_t context) -{ - try - { - if (g_performance) - LSFG_3_1P::deleteContext(context); - else - LSFG_3_1::deleteContext(context); - } - catch (...) - { - } -} - -LSFG_EXPORT void armsx2_lsfg_finalize(void) -{ - try - { - if (g_performance) - LSFG_3_1P::finalize(); - else - LSFG_3_1::finalize(); - } - catch (...) - { - } -} diff --git a/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.h b/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.h deleted file mode 100644 index 805fe76088..0000000000 --- a/platforms/android/app/src/main/cpp/3rdparty/lsfg/armsx2_lsfg_shim.h +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team -// SPDX-License-Identifier: GPL-3.0+ - -#pragma once - -// C ABI for libarmsx2_lsfg.so — the frame-generation backend, isolated in its own shared -// object and reached by dlopen/dlsym from GSLsfg.cpp. -// -// WHY A SEPARATE .so, AND WHY C -// -// LSFG's framegen links volk, which DEFINES 759 globals named vkCreateImage, vkQueueSubmit, -// and so on — exactly the names PCSX2's own VKLoader.cpp defines. Linking them into one -// library is a duplicate-symbol error at best. At worst the linker merges them and -// framegen's volkLoadDevice() call, which it makes against ITS OWN VkDevice, silently -// repoints every entry point the GS renderer uses. Every subsequent vkCmdDraw would go to -// the wrong device, and it would present as a driver crash with nothing pointing back here. -// A separate shared object gives volk its own copy of those globals, which is the only -// arrangement where both loaders can coexist. -// -// The interface is C because the app builds with ANDROID_STL=c++_static: each .so carries -// its own libc++, so an std::vector or std::function crossing this boundary would be two -// unrelated types that happen to share a name. Nothing here is richer than a pointer, an -// integer, or a function pointer. -// -// Errors come back as codes, never exceptions. framegen throws (LSFG::vulkan_error) and so -// does the DXBC translator; the shim catches everything and stores the message for -// armsx2_lsfg_last_error(). An exception must never reach the unwinder in the caller's -// shared object. - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -struct AHardwareBuffer; - -#define ARMSX2_LSFG_ABI_VERSION 2 - -/// Fill `out_data`/`out_size` with SPIR-V for the shader framegen asked for by `name`. -/// The buffer must stay valid until the next call to this callback or until initialise -/// returns, whichever comes first — framegen copies it into a VkShaderModule immediately. -/// Return 0 on success, non-zero to fail the initialise. -typedef int (*armsx2_lsfg_shader_fn)( - void* user, const char* name, const uint8_t** out_data, uint32_t* out_size); - -/// ABI version this .so was built against; mismatch means do not use it. -typedef uint32_t (*pfn_armsx2_lsfg_abi_version)(void); - -/// Bring the library up. `generation_count` is the number of frames to interpolate between -/// each pair of real ones (multiplier - 1). Returns 0 on success. -/// -/// `flow_scale` is a DIVISOR applied to the input extent to size the optical-flow pyramid: -/// framegen computes `flowExtent = inputExtent / flow_scale`, so 1.0 is full resolution and -/// larger values are cheaper. Upstream's own layer reaches this by passing `1.0 / conf.flowScale` -/// from a [0.25, 1.0] fraction, which is why the number arriving here is >= 1.0. -/// -/// `performance` selects LSFG 3.1p, a lighter shader family, in place of 3.1. It is fixed for -/// the lifetime of the library: the two keep entirely separate device state and context tables, -/// so a context created by one cannot be presented or destroyed through the other. -typedef int (*pfn_armsx2_lsfg_initialize)(uint64_t device_uuid, int is_hdr, float flow_scale, - uint32_t generation_count, int performance, armsx2_lsfg_shader_fn loader, void* user); - -/// Create the interpolation context over caller-owned AHardwareBuffers. `format` is a -/// VkFormat. Returns the context id, or -1 on failure. -typedef int32_t (*pfn_armsx2_lsfg_create_context)(struct AHardwareBuffer* in0, - struct AHardwareBuffer* in1, struct AHardwareBuffer* const* outs, uint32_t out_count, - uint32_t width, uint32_t height, uint32_t format); - -/// Run generation for one frame. Returns 0 on success. -typedef int (*pfn_armsx2_lsfg_present)(int32_t context); - -/// Block until the library's own device is idle. On Android this is the only barrier that -/// exists between its device and ours — there is no exportable cross-device semaphore. -typedef void (*pfn_armsx2_lsfg_wait_idle)(void); - -typedef void (*pfn_armsx2_lsfg_delete_context)(int32_t context); -typedef void (*pfn_armsx2_lsfg_finalize)(void); - -/// Message from the most recent failure on this thread, or "" if there was none. Valid until -/// the next failing call on the same thread. -typedef const char* (*pfn_armsx2_lsfg_last_error)(void); - -#ifdef __cplusplus -} // extern "C" -#endif diff --git a/platforms/android/app/src/main/cpp/CMakeLists.txt b/platforms/android/app/src/main/cpp/CMakeLists.txt index f99dd82b87..3058323fd8 100644 --- a/platforms/android/app/src/main/cpp/CMakeLists.txt +++ b/platforms/android/app/src/main/cpp/CMakeLists.txt @@ -76,11 +76,15 @@ add_subdirectory(3rdparty/adrenotools) # compiles out, so a missing Rust toolchain can never break the build. add_subdirectory(3rdparty/librashader) -# LSFG: Lossless Scaling frame generation (github flavour only — see the subdir CMakeLists -# for the licence split and why we take only the MIT framegen library). OPTIONAL in exactly -# the same way: ARMSX2_ENABLE_LSFG defaults OFF, and only the github flavour turns it on, so -# a play build never fetches it and the feature compiles out. -add_subdirectory(3rdparty/lsfg) +# LSFG: Lossless Scaling frame generation, github flavour only — ARMSX2_ENABLE_LSFG defaults +# OFF and only the github flavour turns it on, so a play build compiles the feature out. +# +# ★ There is no 3rdparty/lsfg subdirectory any more. The implementation was replaced by the +# port of Eden's (eden-emu PR #4263), which runs as ordinary compute on the emulator's own +# device and needs NONE of what the old one dragged in: no lsfg-vk-android framegen library, +# no DXVK dxbc compiler, no pe-parse, no volk, and so no separate .so to dlopen. The whole +# subtree and its ~130 MB fetch are gone; the ported sources live in +# pcsx2/GS/Renderers/Vulkan/FrameGen and build straight into the core. # Discord Social SDK (rich presence + friends). Staged by hand rather than consumed as the # vendor .aar: that .aar's manifest declares RECORD_AUDIO and four foreground-service @@ -199,13 +203,10 @@ endif() # compile into the separate PCSX2_LTO target. A define placed on PCSX2 would never reach it, # the code behind #ifdef ARMSX2_HAS_LSFG would vanish, and the build would succeed anyway — # shipping the toggle with nothing behind it. Verify with `strings` on the core, not the log. -if(ARMSX2_HAVE_LSFG) - target_link_libraries(PCSX2_FLAGS INTERFACE ${ARMSX2_LSFG_LIBRARIES}) - target_include_directories(PCSX2_FLAGS INTERFACE ${ARMSX2_LSFG_INCLUDE}) +if(ARMSX2_ENABLE_LSFG) + # Only the define is needed now — the sources are part of the core, so there is nothing to + # link and nothing to add_dependencies on. Gated on ARMSX2_ENABLE_LSFG rather than the old + # ARMSX2_HAVE_LSFG, which was set by the subdirectory that no longer exists; leaving that + # test in place would silently drop the define and ship the toggle with nothing behind it. target_compile_definitions(PCSX2_FLAGS INTERFACE ARMSX2_HAS_LSFG=1) - target_link_libraries(${ARMSX2_EMUCORE_LIBRARY_NAME} PRIVATE ${ARMSX2_LSFG_LIBRARIES}) - # libarmsx2_lsfg.so is dlopen'd, not linked, so nothing here would otherwise make the - # build produce it — and a missing .so is invisible until frame generation is switched on - # at runtime. State the dependency so it is built and packaged with every core. - add_dependencies(${ARMSX2_EMUCORE_LIBRARY_NAME} armsx2_lsfg) endif() diff --git a/platforms/android/tools/build-play-aab.sh b/platforms/android/tools/build-play-aab.sh index 6f30c239d3..00d7e1059e 100755 --- a/platforms/android/tools/build-play-aab.sh +++ b/platforms/android/tools/build-play-aab.sh @@ -88,9 +88,14 @@ echo "-- REQUEST_INSTALL_PACKAGES must be ABSENT (self-updating violates Play po if unzip -p "$OUTPUT_AAB" base/manifest/AndroidManifest.xml | strings | grep -q "REQUEST_INSTALL_PACKAGES"; then echo " !! FATAL: REQUEST_INSTALL_PACKAGES present in play AAB (in-app updater leaked into the Play build)" >&2; exit 1 else echo " absent OK"; fi -echo "-- libarmsx2_lsfg.so must be ABSENT (frame generation is github-flavour only) --" -if unzip -l "$OUTPUT_AAB" | grep -q "libarmsx2_lsfg.so"; then - echo " !! FATAL: libarmsx2_lsfg.so present in play AAB (LSFG leaked into the Play build)" >&2; exit 1 +echo "-- no frame-generation code in the core (github flavour only) --" +# There is no separate libarmsx2_lsfg.so any more: frame generation is compiled into the core +# itself, gated on ARMSX2_ENABLE_LSFG which the play flavour sets to OFF. So the check moved +# from "is that file packaged" — which can no longer be true either way, and would therefore +# pass forever without proving anything — to looking inside the core for a symbol only the +# ported implementation defines. +if unzip -p "$OUTPUT_AAB" 'base/lib/arm64-v8a/libemucore_*.so' 2>/dev/null | LC_ALL=C grep -aq "LsfgChain"; then + echo " !! FATAL: frame-generation code present in the play core (ARMSX2_ENABLE_LSFG leaked ON)" >&2; exit 1 else echo " absent OK"; fi echo "-- no frame-generation text at all (the Play build has no LSFG whatsoever) --" # The strongest of these checks, and the one someone looking would actually notice. Every