diff --git a/CMakePresets.json b/CMakePresets.json
index 1aa41db5..402787cc 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -176,6 +176,7 @@
"CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_OSX_SYSROOT": "iphonesimulator",
"CMAKE_OSX_ARCHITECTURES": "arm64",
+ "CMAKE_OSX_DEPLOYMENT_TARGET": "16.0",
"CMAKE_BUILD_TYPE": "Debug",
"UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES": true,
"UNLEASHED_RECOMP_HOST_TOOLS_DIR": "${sourceDir}/out/build/macos-release",
@@ -207,6 +208,7 @@
"CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_OSX_SYSROOT": "iphoneos",
"CMAKE_OSX_ARCHITECTURES": "arm64",
+ "CMAKE_OSX_DEPLOYMENT_TARGET": "16.0",
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_INTERPROCEDURAL_OPTIMIZATION": true,
"UNLEASHED_RECOMP_SKIP_TARGET_TOOL_EXECUTABLES": true,
diff --git a/UnleashedRecomp/res/ios/Info.plist.in b/UnleashedRecomp/res/ios/Info.plist.in
index bfb2b80b..cc18bda1 100644
--- a/UnleashedRecomp/res/ios/Info.plist.in
+++ b/UnleashedRecomp/res/ios/Info.plist.in
@@ -60,7 +60,7 @@
LSSupportsOpeningDocumentsInPlace
MinimumOSVersion
- 13.0
+ 16.0
UIFileSharingEnabled
UIApplicationSupportsIndirectInputEvents
diff --git a/tools/patches/plume-ios-metal.patch b/tools/patches/plume-ios-metal.patch
index b2b63edb..6ad2d0dd 100644
--- a/tools/patches/plume-ios-metal.patch
+++ b/tools/patches/plume-ios-metal.patch
@@ -242,7 +242,7 @@ index 64e4dc9..1d14600 100644
}
}
diff --git a/plume_metal.cpp b/plume_metal.cpp
-index ebbbaa3..6f5a82c 100644
+index ebbbaa3..854107a 100644
--- a/plume_metal.cpp
+++ b/plume_metal.cpp
@@ -14,8 +14,12 @@
@@ -258,10 +258,58 @@ index ebbbaa3..6f5a82c 100644
#include "plume_metal.h"
-@@ -38,6 +42,141 @@ namespace plume {
+@@ -38,6 +42,186 @@ namespace plume {
return (n + alignment - 1) & ~(alignment - 1);
}
++ bool supportsMetalFamily(MTL::Device* device, MTL::GPUFamily family) {
++ return device != nullptr && device->supportsFamily(family);
++ }
++
++ bool supportsBufferDeviceAddress(MTL::Device* device) {
++ return supportsMetalFamily(device, MTL::GPUFamilyMetal3);
++ }
++
++ uint64_t getRecommendedWorkingSetSize(MTL::Device* device) {
++#if PLUME_IOS
++ // recommendedMaxWorkingSetSize is iOS 16+. The app requires Metal 3 for
++ // gpuAddress, but keep startup safe on older runtimes and SDK shims.
++ (void)device;
++ return 2ULL * 1024ULL * 1024ULL * 1024ULL;
++#else
++ return device->recommendedMaxWorkingSetSize();
++#endif
++ }
++
++ bool hasUnifiedMemory(MTL::Device* device) {
++#if PLUME_IOS
++ (void)device;
++ return true;
++#else
++ return device->hasUnifiedMemory();
++#endif
++ }
++
++ bool supportsProgrammableSamplePositions(MTL::Device* device) {
++#if PLUME_IOS
++ return supportsMetalFamily(device, MTL::GPUFamilyApple3);
++#else
++ return device->programmableSamplePositionsSupported();
++#endif
++ }
++
++ spirv_cross::CompilerMSL::Options::ArgumentBuffersTier getArgumentBuffersTier(MTL::Device* device) {
++#if PLUME_IOS
++ return supportsMetalFamily(device, MTL::GPUFamilyApple3)
++ ? spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier2
++ : spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier1;
++#else
++ return device->argumentBuffersSupport() == MTL::ArgumentBuffersTier2
++ ? spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier2
++ : spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier1;
++#endif
++ }
++
+ uint32_t getSpirvResourceArrayCount(const spirv_cross::SPIRType& type) {
+ uint32_t count = 1;
+ for (const uint32_t dimension : type.array) {
@@ -339,10 +387,7 @@ index ebbbaa3..6f5a82c 100644
+#endif
+ options.set_msl_version(3, 0);
+ options.argument_buffers = true;
-+ options.argument_buffers_tier =
-+ device->mtl->argumentBuffersSupport() == MTL::ArgumentBuffersTier2
-+ ? spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier2
-+ : spirv_cross::CompilerMSL::Options::ArgumentBuffersTier::Tier1;
++ options.argument_buffers_tier = getArgumentBuffersTier(device->mtl);
+ options.enable_base_index_zero = true;
+ options.enable_decoration_binding = true;
+ options.force_active_argument_buffer_resources = true;
@@ -400,7 +445,16 @@ index ebbbaa3..6f5a82c 100644
uint64_t createClearPipelineKey(MTL::RenderPipelineDescriptor *pipelineDesc, bool depthWriteEnabled, bool stencilWriteEnabled) {
auto colorFormat = [&](uint32_t index) {
if (auto colorAttachment = pipelineDesc->colorAttachments()->object(index)) {
-@@ -1278,24 +1417,46 @@ namespace plume {
+@@ -1133,7 +1317,7 @@ namespace plume {
+ }
+
+ uint64_t MetalBuffer::getDeviceAddress() const {
+- assert(device->mtl->supportsFamily(MTL::GPUFamilyMetal3) && "Device address is only supported on Metal3 devices.");
++ assert(supportsBufferDeviceAddress(device->mtl) && "Device address is only supported on Metal3 devices.");
+ return mtl->gpuAddress();
+ }
+
+@@ -1278,24 +1462,46 @@ namespace plume {
assert(device != nullptr);
assert(data != nullptr);
assert(size > 0);
@@ -453,7 +507,7 @@ index ebbbaa3..6f5a82c 100644
if (debugName) {
debugName->release();
}
-@@ -1306,10 +1467,16 @@ namespace plume {
+@@ -1306,10 +1512,16 @@ namespace plume {
debugName->release();
}
debugName = NS::String::string(name.c_str(), NS::UTF8StringEncoding);
@@ -471,7 +525,110 @@ index ebbbaa3..6f5a82c 100644
MTL::FunctionConstantValues *values = MTL::FunctionConstantValues::alloc()->init();
if (specConstants != nullptr) {
for (uint32_t i = 0; i < specConstantsCount; i++) {
-@@ -3308,6 +3475,9 @@ namespace plume {
+@@ -1793,6 +2005,11 @@ namespace plume {
+
+ MetalSwapChain::MetalSwapChain(MetalCommandQueue *commandQueue, const RenderWindow renderWindow, uint32_t textureCount, const RenderFormat format) {
+ this->layer = static_cast(renderWindow.view);
++ if (layer == nullptr) {
++ fprintf(stderr, "Metal swapchain creation failed: SDL did not provide a CAMetalLayer.\n");
++ return;
++ }
++
+ layer->setDevice(commandQueue->device->mtl);
+ layer->setPixelFormat(mapPixelFormat(format));
+
+@@ -1819,7 +2036,10 @@ namespace plume {
+ }
+
+ bool MetalSwapChain::present(const uint32_t textureIndex, RenderCommandSemaphore **waitSemaphores, const uint32_t waitSemaphoreCount) {
+- assert(layer != nullptr && "Cannot present without a valid layer.");
++ if (layer == nullptr) {
++ return false;
++ }
++
+ NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init();
+
+ const MetalDrawable &drawable = drawables[textureIndex];
+@@ -1861,7 +2081,7 @@ namespace plume {
+ bool MetalSwapChain::resize() {
+ getWindowSize(width, height);
+
+- if (width == 0 || height == 0) {
++ if (layer == nullptr || width == 0 || height == 0) {
+ return false;
+ }
+
+@@ -1886,11 +2106,29 @@ namespace plume {
+ }
+
+ void MetalSwapChain::setVsyncEnabled(const bool vsyncEnabled) {
++ if (layer == nullptr) {
++ return;
++ }
++
++#if PLUME_IOS
++ // CAMetalLayer.displaySyncEnabled is unavailable on iOS. iOS drawables
++ // are display-synchronized by default, so keep this as a no-op.
++ (void)vsyncEnabled;
++#else
+ layer->setDisplaySyncEnabled(vsyncEnabled);
++#endif
+ }
+
+ bool MetalSwapChain::isVsyncEnabled() const {
++ if (layer == nullptr) {
++ return false;
++ }
++
++#if PLUME_IOS
++ return true;
++#else
+ return layer->displaySyncEnabled();
++#endif
+ }
+
+ uint32_t MetalSwapChain::getWidth() const {
+@@ -1910,6 +2148,10 @@ namespace plume {
+ assert(textureIndex != nullptr);
+ assert(*textureIndex < MAX_DRAWABLES);
+
++ if (layer == nullptr) {
++ return false;
++ }
++
+ NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init();
+
+ // Create a command buffer just to encode the signal
+@@ -1923,6 +2165,7 @@ namespace plume {
+ CA::MetalDrawable *nextDrawable = layer->nextDrawable();
+ if (nextDrawable == nullptr) {
+ fprintf(stderr, "No more drawables available for rendering.\n");
++ releasePool->release();
+ return false;
+ }
+
+@@ -1954,10 +2197,20 @@ namespace plume {
+ }
+
+ uint32_t MetalSwapChain::getRefreshRate() const {
++ if (windowWrapper == nullptr) {
++ return 60;
++ }
++
+ return windowWrapper->getRefreshRate();
+ }
+
+ void MetalSwapChain::getWindowSize(uint32_t &dstWidth, uint32_t &dstHeight) const {
++ if (windowWrapper == nullptr) {
++ dstWidth = 0;
++ dstHeight = 0;
++ return;
++ }
++
+ CocoaWindowAttributes attributes;
+ windowWrapper->getWindowAttributes(&attributes);
+ dstWidth = attributes.width;
+@@ -3308,6 +3561,9 @@ namespace plume {
this->renderInterface = renderInterface;
// Device Selection
@@ -481,7 +638,7 @@ index ebbbaa3..6f5a82c 100644
const NS::Array* devices = MTL::CopyAllDevices();
MTL::Device *preferredDevice = nullptr;
for (NS::UInteger i = 0; i < devices->count(); i++) {
-@@ -3320,9 +3490,18 @@ namespace plume {
+@@ -3320,12 +3576,31 @@ namespace plume {
}
mtl = preferredDevice ? preferredDevice : MTL::CreateSystemDefaultDevice();;
@@ -490,6 +647,16 @@ index ebbbaa3..6f5a82c 100644
+ return;
+ }
+
++#if PLUME_IOS
++ if (!supportsBufferDeviceAddress(mtl)) {
++ fprintf(stderr, "Metal device '%s' does not support Metal 3 buffer GPU addresses required by Unleashed Recompiled.\n", mtl->name()->utf8String());
++ mtl->release();
++ mtl = nullptr;
++ return;
++ }
++#endif
++
++ const uint64_t workingSetSize = getRecommendedWorkingSetSize(mtl);
const std::string deviceName(mtl->name()->utf8String());
description.name = deviceName;
+#if PLUME_IOS
@@ -499,8 +666,36 @@ index ebbbaa3..6f5a82c 100644
+#endif
description.driverVersion = 1; // Unavailable
description.vendor = mtl->supportsFamily(MTL::GPUFamilyApple1) ? RenderDeviceVendor::APPLE : getRenderDeviceVendor(mtl->registryID());
- description.dedicatedVideoMemory = mtl->recommendedMaxWorkingSetSize();
-@@ -3357,17 +3536,19 @@ namespace plume {
+- description.dedicatedVideoMemory = mtl->recommendedMaxWorkingSetSize();
++ description.dedicatedVideoMemory = workingSetSize;
+
+ // Setup blit, clear and resolve shaders / pipelines
+ createClearShaderLibrary();
+@@ -3337,7 +3612,7 @@ namespace plume {
+ // TODO: Support Raytracing.
+ // capabilities.raytracing = mtl->supportsRaytracing();
+ capabilities.maxTextureSize = mtl->supportsFamily(MTL::GPUFamilyApple3) ? 16384 : 8192;
+- capabilities.sampleLocations = mtl->programmableSamplePositionsSupported();
++ capabilities.sampleLocations = supportsProgrammableSamplePositions(mtl);
+ capabilities.resolveModes = false;
+ #if PLUME_IOS
+ capabilities.descriptorIndexing = mtl->supportsFamily(MTL::GPUFamilyApple3);
+@@ -3345,11 +3620,11 @@ namespace plume {
+ capabilities.descriptorIndexing = true;
+ #endif
+ capabilities.scalarBlockLayout = true;
+- capabilities.bufferDeviceAddress = mtl->supportsFamily(MTL::GPUFamilyApple3);
++ capabilities.bufferDeviceAddress = supportsBufferDeviceAddress(mtl);
+ capabilities.presentWait = false;
+- capabilities.preferHDR = mtl->recommendedMaxWorkingSetSize() > (512 * 1024 * 1024);
++ capabilities.preferHDR = workingSetSize > (512 * 1024 * 1024);
+ capabilities.dynamicDepthBias = true;
+- capabilities.uma = mtl->hasUnifiedMemory();
++ capabilities.uma = hasUnifiedMemory(mtl);
+ capabilities.gpuUploadHeap = capabilities.uma;
+ capabilities.queryPools = false;
+
+@@ -3357,17 +3632,19 @@ namespace plume {
}
MetalDevice::~MetalDevice() {
@@ -526,7 +721,7 @@ index ebbbaa3..6f5a82c 100644
}
std::unique_ptr MetalDevice::createDescriptorSet(const RenderDescriptorSetDesc &desc) {
-@@ -3635,16 +3816,24 @@ namespace plume {
+@@ -3635,16 +3912,24 @@ namespace plume {
MetalInterface::MetalInterface() {
NS::AutoreleasePool *releasePool = NS::AutoreleasePool::alloc()->init();