diff --git a/osu.Android.props b/osu.Android.props
index 03864d13adb5..6c7bb344cde4 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -88,6 +88,14 @@
+
+
+
+
+
diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs
index 7d7849b7e9f3..1f4deeb935e2 100644
--- a/osu.Android/AndroidNativeBridgeManager.cs
+++ b/osu.Android/AndroidNativeBridgeManager.cs
@@ -190,7 +190,19 @@ public void StartVulkanProbe()
public string GetVulkanStatus()
{
if (vulkanProbe is not VulkanProbe probe) return string.Empty;
- return cachedVulkanStatus ??= $"{(probe.SupportsMailboxPresentMode ? "MAILBOX" : "FIFO")}{(probe.DisablePresentId ? " [NoID]" : "")}{(probe.DisablePresentWait ? " [NoWait]" : "")}{(probe.DisableGraphicsPipelineLibrary ? " [NoGPL]" : "")}";
+
+ if (cachedVulkanStatus != null)
+ return cachedVulkanStatus;
+
+ int ver = probe.ApiVersion;
+ int major = (ver >> 22) & 0x3FF;
+ int minor = (ver >> 12) & 0x3FF;
+
+ cachedVulkanStatus = $"Vk{major}.{minor}"
+ + (probe.DisablePresentId ? " [NoID]" : "")
+ + (probe.DisablePresentWait ? " [NoWait]" : "")
+ + (probe.DisableGraphicsPipelineLibrary ? " [NoGPL]" : "");
+ return cachedVulkanStatus;
}
public void StopVulkanProbe()
@@ -211,10 +223,12 @@ private static void logVulkanInfo(VulkanProbe probe)
Debug.WriteLine($"[osu!] Vulkan GPU: {probe.DeviceLocalMemoryMB}MB, "
+ $"API={major}.{minor}.{patch}, "
- + $"mailbox={probe.SupportsMailboxPresentMode}, "
+ $"vk1.3={probe.MeetsVulkan13}, "
+ + $"vk1.4={probe.MeetsVulkan14}, "
+ $"gpl={probe.SupportsGraphicsPipelineLibrary}, "
- + $"shaderObj={probe.SupportsShaderObject}");
+ + $"shaderObj={probe.SupportsShaderObject}, "
+ + $"hostCopy={probe.SupportsHostImageCopy}, "
+ + $"pushDesc={probe.SupportsPushDescriptors}");
}
[MethodImpl(MethodImplOptions.NoInlining)]
diff --git a/osu.Android/Native/CMakeLists.txt b/osu.Android/Native/CMakeLists.txt
index 8c0658788d43..24f7bc4efd23 100644
--- a/osu.Android/Native/CMakeLists.txt
+++ b/osu.Android/Native/CMakeLists.txt
@@ -6,12 +6,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Maximum release optimizations for lowest-latency audio callback path.
# -O3: aggressive inlining & vectorisation
-# -flto: link-time optimisation across translation units
+# -flto=thin: link-time optimisation with thin backend (20-40% faster link than full LTO,
+# equivalent binary quality for small TUs like this project)
# -ffast-math: enable SIMD-friendly FP (safe — we only memset + store a double)
# -ffunction/data-sections + --gc-sections: dead-code elimination
# -fvisibility=hidden: only OSU_EXPORT symbols are visible
-set(CMAKE_CXX_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
-set(CMAKE_C_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
+set(CMAKE_CXX_FLAGS_RELEASE "-O3 -flto=thin -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
+set(CMAKE_C_FLAGS_RELEASE "-O3 -flto=thin -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
# -Wl,-z,max-page-size=16384: align ELF LOAD segments to 16 KB for Android 15+
# devices with 16 KB page sizes. Without this, the .so will fail to load on
# such devices. NDK r28+ supports this flag.
@@ -23,7 +24,10 @@ set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "-Wl,--gc-sections -Wl,-z,max-page-size=16
set(OBOE_ENABLE_FLOWGRAPH OFF CACHE BOOL "Disable Oboe flowgraph to reduce binary size")
# Download and build Oboe main branch from source to ensure we have the latest
-# features (like ADPF performance hints) regardless of the build environment.
+# features and fixes (ADPF performance hints, workload management, spatialization,
+# API 36 compatibility) regardless of the build environment.
+# main is preferred over pinned tags because Oboe releases infrequently (~yearly)
+# and the main branch accumulates significant latency-critical improvements between tags.
include(FetchContent)
FetchContent_Declare(oboe
GIT_REPOSITORY https://github.com/google/oboe.git
@@ -41,9 +45,9 @@ endif()
set(OBOE_LIB oboe)
-find_library(vulkan-lib vulkan)
-find_library(log-lib log)
-find_library(android-lib android)
+find_library(vulkan-lib vulkan REQUIRED)
+find_library(log-lib log REQUIRED)
+find_library(android-lib android REQUIRED)
add_library(osu_native SHARED
oboe_bridge.cpp
diff --git a/osu.Android/Native/VulkanProbe.cs b/osu.Android/Native/VulkanProbe.cs
index 0181706a46a0..aa32fcc6b5b5 100644
--- a/osu.Android/Native/VulkanProbe.cs
+++ b/osu.Android/Native/VulkanProbe.cs
@@ -68,6 +68,9 @@ static VulkanProbe()
public bool SupportsGlobalPriority => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsGlobalPriority(nativePtr) != 0;
public bool SupportsMemoryBudget => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsMemoryBudget(nativePtr) != 0;
public bool SupportsSurfaceMaintenance1 => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsSurfaceMaintenance1(nativePtr) != 0;
+ public bool MeetsVulkan14 => !disposed && nativePtr != IntPtr.Zero && nVulkanMeetsVulkan14(nativePtr) != 0;
+ public bool SupportsHostImageCopy => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsHostImageCopy(nativePtr) != 0;
+ public bool SupportsPushDescriptors => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsPushDescriptors(nativePtr) != 0;
public bool DisablePresentId => !disposed && nativePtr != IntPtr.Zero && nVulkanDisablePresentId(nativePtr) != 0;
public bool DisablePresentWait => !disposed && nativePtr != IntPtr.Zero && nVulkanDisablePresentWait(nativePtr) != 0;
@@ -108,5 +111,8 @@ public void Dispose()
[DllImport(lib_name)] private static extern byte nVulkanDisablePresentId(IntPtr ptr);
[DllImport(lib_name)] private static extern byte nVulkanDisablePresentWait(IntPtr ptr);
[DllImport(lib_name)] private static extern byte nVulkanDisableGraphicsPipelineLibrary(IntPtr ptr);
+ [DllImport(lib_name)] private static extern byte nVulkanMeetsVulkan14(IntPtr ptr);
+ [DllImport(lib_name)] private static extern byte nVulkanSupportsHostImageCopy(IntPtr ptr);
+ [DllImport(lib_name)] private static extern byte nVulkanSupportsPushDescriptors(IntPtr ptr);
}
}
diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp
index 4b44e1ca2e87..d14ce1172956 100644
--- a/osu.Android/Native/oboe_bridge.cpp
+++ b/osu.Android/Native/oboe_bridge.cpp
@@ -93,7 +93,8 @@ bool OboeBridge::open(int32_t sampleRate) {
oboe::OboeExtensions::setMMapEnabled(true);
// Initialise StabilizedCallback to even out callback execution time.
- stabilizedCallback_ = std::make_unique(this);
+ // shared_ptr is used to satisfy the non-deprecated setDataCallback overload.
+ stabilizedCallback_ = std::make_shared(this);
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
@@ -106,11 +107,19 @@ bool OboeBridge::open(int32_t sampleRate) {
->setContentType(oboe::ContentType::Music)
->setUsage(oboe::Usage::Game)
->setAudioApi(oboe::AudioApi::AAudio)
- ->setFramesPerCallback(oboe::kUnspecified)
+ ->setFramesPerDataCallback(oboe::kUnspecified)
->setBufferCapacityInFrames(oboe::kUnspecified)
->setChannelConversionAllowed(false)
->setFormatConversionAllowed(false)
- ->setCallback(stabilizedCallback_.get());
+ // Audio is pre-mixed by BASS — tell Android not to spatialize it again.
+ ->setIsContentSpatialized(true)
+ // Prevent other apps from capturing our audio stream (competitive integrity).
+ ->setAllowedCapturePolicy(oboe::AllowedCapturePolicy::AllowNone)
+ // Use shared_ptr overload (non-deprecated) for data callback.
+ ->setDataCallback(stabilizedCallback_)
+ // Non-owning shared_ptr for error callback — OboeBridge outlives the stream.
+ ->setErrorCallback(std::shared_ptr(
+ std::shared_ptr(), static_cast(this)));
oboe::Result result = builder.openStream(stream_);
@@ -255,6 +264,10 @@ oboe::DataCallbackResult OboeBridge::onAudioReady(
if (provider) {
int32_t framesRead = provider(audioData, numFrames);
+ // Clamp to valid range: negative or out-of-range values from the provider
+ // would wrap to a huge size_t, causing a buffer overrun in the memset below.
+ framesRead = std::clamp(framesRead, 0, numFrames);
+
if (framesRead < numFrames) {
size_t bytesDone = static_cast(framesRead) * stream->getChannelCount() * sizeof(float);
size_t totalBytes = static_cast(numFrames) * stream->getChannelCount() * sizeof(float);
diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h
index ec24e22dfa65..41bcb221385f 100644
--- a/osu.Android/Native/oboe_bridge.h
+++ b/osu.Android/Native/oboe_bridge.h
@@ -44,8 +44,8 @@ class OboeBridge : public oboe::AudioStreamCallback {
private:
std::shared_ptr stream_;
+ std::shared_ptr stabilizedCallback_;
std::unique_ptr tuner_;
- std::unique_ptr stabilizedCallback_;
mutable std::mutex streamLock_;
std::atomic active_{false};
diff --git a/osu.Android/Native/vulkan_bridge.cpp b/osu.Android/Native/vulkan_bridge.cpp
index ffcc96a48abd..9de3969e9e10 100644
--- a/osu.Android/Native/vulkan_bridge.cpp
+++ b/osu.Android/Native/vulkan_bridge.cpp
@@ -7,7 +7,7 @@
#include
#include
-#define LOG_TAG "osu_native"
+#define LOG_TAG "osu!native"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
@@ -26,7 +26,8 @@ VulkanProbe::VulkanProbe() {
available_ = true;
LOGI("Vulkan available: %s (Vendor: 0x%x, API %u.%u.%u, driver %u, VRAM %u MB, "
- "qCount %u, mailbox %d, vk1.3 %d, sync2 %d, pWait %d, gpl %d, sObj %d, gPrio %d)",
+ "qCount %u, vk1.3 %d, vk1.4 %d, sync2 %d, pWait %d, gpl %d, sObj %d, gPrio %d, "
+ "hostCopy %d, pushDesc %d)",
deviceInfo_.deviceName.c_str(),
deviceInfo_.vendorId,
VK_VERSION_MAJOR(deviceInfo_.apiVersion),
@@ -35,13 +36,15 @@ VulkanProbe::VulkanProbe() {
deviceInfo_.driverVersion,
deviceInfo_.deviceLocalMemoryMB,
deviceInfo_.queueFamilyCount,
- deviceInfo_.supportsMailboxPresentMode ? 1 : 0,
deviceInfo_.meetsVulkan13 ? 1 : 0,
+ deviceInfo_.meetsVulkan14 ? 1 : 0,
deviceInfo_.supportsSynchronization2 ? 1 : 0,
deviceInfo_.supportsPresentWait ? 1 : 0,
deviceInfo_.supportsGraphicsPipelineLibrary ? 1 : 0,
deviceInfo_.supportsShaderObject ? 1 : 0,
- deviceInfo_.supportsGlobalPriority ? 1 : 0);
+ deviceInfo_.supportsGlobalPriority ? 1 : 0,
+ deviceInfo_.supportsHostImageCopy ? 1 : 0,
+ deviceInfo_.supportsPushDescriptors ? 1 : 0);
}
VulkanProbe::~VulkanProbe() {
@@ -99,8 +102,15 @@ bool VulkanProbe::queryDevice() {
queryMemory(selected);
queryQueueFamilies(selected);
- queryVulkan13Features(selected);
+ // Query extensions first (sets extension-based feature flags for pre-1.3 devices).
queryModernExtensions(selected);
+ // Feature queries for 1.3+ override extension-based values with actual feature support.
+ queryVulkan13Features(selected);
+
+ // MAILBOX present mode cannot be queried without a VkSurfaceKHR (we have none in
+ // this lightweight probe). The actual present mode is selected by the renderer
+ // (Veldrid) at swapchain creation time via vkGetPhysicalDeviceSurfacePresentModesKHR.
+ // We leave supportsMailboxPresentMode = false (default) to avoid false positives.
return true;
}
@@ -137,7 +147,7 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) {
std::vector exts(count);
vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data());
for (const auto& ext : exts) {
- if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) { deviceInfo_.supportsSwapchain = true; deviceInfo_.supportsMailboxPresentMode = true; }
+ if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) deviceInfo_.supportsSwapchain = true;
if (strcmp(ext.extensionName, VK_KHR_PRESENT_ID_EXTENSION_NAME) == 0) deviceInfo_.supportsPresentId = true;
if (strcmp(ext.extensionName, VK_KHR_PRESENT_WAIT_EXTENSION_NAME) == 0) deviceInfo_.supportsPresentWait = true;
if (strcmp(ext.extensionName, VK_EXT_GRAPHICS_PIPELINE_LIBRARY_EXTENSION_NAME) == 0) deviceInfo_.supportsGraphicsPipelineLibrary = true;
@@ -145,18 +155,32 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) {
if (strcmp(ext.extensionName, VK_EXT_GLOBAL_PRIORITY_EXTENSION_NAME) == 0 || strcmp(ext.extensionName, VK_KHR_GLOBAL_PRIORITY_EXTENSION_NAME) == 0) deviceInfo_.supportsGlobalPriority = true;
if (strcmp(ext.extensionName, VK_EXT_MEMORY_BUDGET_EXTENSION_NAME) == 0) deviceInfo_.supportsMemoryBudget = true;
if (strcmp(ext.extensionName, "VK_EXT_surface_maintenance1") == 0) deviceInfo_.supportsSurfaceMaintenance1 = true;
+
+ // Extension-based fallback for pre-1.3 devices (overridden by queryVulkan13Features on 1.3+).
+ if (strcmp(ext.extensionName, "VK_KHR_dynamic_rendering") == 0) deviceInfo_.supportsDynamicRendering = true;
+ if (strcmp(ext.extensionName, "VK_KHR_synchronization2") == 0) deviceInfo_.supportsSynchronization2 = true;
+
+ // Vulkan 1.4+ / Android 16+ extensions — used by string literals since NDK r29
+ // headers may not define these macros.
+ if (strcmp(ext.extensionName, "VK_EXT_host_image_copy") == 0) deviceInfo_.supportsHostImageCopy = true;
+ if (strcmp(ext.extensionName, "VK_KHR_push_descriptor") == 0) deviceInfo_.supportsPushDescriptors = true;
}
// ── Vendor-specific GPU quirks ──────────────────────────────────────
// Qualcomm Adreno 7xx series: known flickering with PresentId/PresentWait
// and broken Graphics Pipeline Library compilation on some driver versions.
- if (deviceInfo_.vendorId == 0x5143 && (deviceInfo_.deviceName.find("740") != std::string::npos ||
- deviceInfo_.deviceName.find("750") != std::string::npos ||
- deviceInfo_.deviceName.find("Adreno") != std::string::npos)) {
- LOGI("Adreno 7xx GPU detected: applying performance and flickering overrides");
- deviceInfo_.disablePresentId = true;
- deviceInfo_.disablePresentWait = true;
- deviceInfo_.disableGraphicsPipelineLibrary = true;
+ // Only target 7xx (730/740/750) — other Adreno generations are unaffected.
+ if (deviceInfo_.vendorId == 0x5143) {
+ const auto& name = deviceInfo_.deviceName;
+ bool isAdreno7xx = name.find("730") != std::string::npos ||
+ name.find("740") != std::string::npos ||
+ name.find("750") != std::string::npos;
+ if (isAdreno7xx) {
+ LOGI("Adreno 7xx GPU detected: applying performance and flickering overrides");
+ deviceInfo_.disablePresentId = true;
+ deviceInfo_.disablePresentWait = true;
+ deviceInfo_.disableGraphicsPipelineLibrary = true;
+ }
}
// ARM Mali (Samsung Exynos, MediaTek Dimensity, Google Tensor):
@@ -184,12 +208,18 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) {
void VulkanProbe::queryVulkan13Features(VkPhysicalDevice device) {
if (VK_VERSION_MAJOR(deviceInfo_.apiVersion) < 1 || (VK_VERSION_MAJOR(deviceInfo_.apiVersion) == 1 && VK_VERSION_MINOR(deviceInfo_.apiVersion) < 3)) return;
deviceInfo_.meetsVulkan13 = true;
+
+ // Vulkan 1.4 is just a version check — no NDK header support needed.
+ if (VK_VERSION_MINOR(deviceInfo_.apiVersion) >= 4)
+ deviceInfo_.meetsVulkan14 = true;
+
VkPhysicalDeviceVulkan13Features f13{};
f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
VkPhysicalDeviceFeatures2 f2{};
f2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
f2.pNext = &f13;
vkGetPhysicalDeviceFeatures2(device, &f2);
+ // Override extension-based flags with accurate feature queries for 1.3+ devices.
deviceInfo_.supportsDynamicRendering = f13.dynamicRendering == VK_TRUE;
deviceInfo_.supportsSynchronization2 = f13.synchronization2 == VK_TRUE;
}
@@ -227,4 +257,7 @@ OSU_EXPORT byte nVulkanSupportsSurfaceMaintenance1(intptr_t ptr) { return (ptr &
OSU_EXPORT byte nVulkanDisablePresentId(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disablePresentId) ? 1 : 0; }
OSU_EXPORT byte nVulkanDisablePresentWait(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disablePresentWait) ? 1 : 0; }
OSU_EXPORT byte nVulkanDisableGraphicsPipelineLibrary(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disableGraphicsPipelineLibrary) ? 1 : 0; }
+OSU_EXPORT byte nVulkanMeetsVulkan14(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().meetsVulkan14) ? 1 : 0; }
+OSU_EXPORT byte nVulkanSupportsHostImageCopy(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().supportsHostImageCopy) ? 1 : 0; }
+OSU_EXPORT byte nVulkanSupportsPushDescriptors(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().supportsPushDescriptors) ? 1 : 0; }
}
diff --git a/osu.Android/Native/vulkan_bridge.h b/osu.Android/Native/vulkan_bridge.h
index ea8cb71b47f4..385c79732467 100644
--- a/osu.Android/Native/vulkan_bridge.h
+++ b/osu.Android/Native/vulkan_bridge.h
@@ -29,7 +29,7 @@ class VulkanProbe {
bool supportsDynamicRendering = false;
bool supportsSynchronization2 = false;
- // API 31+ / Modern High-Performance Extensions
+ // Modern High-Performance Extensions
bool supportsPresentId = false;
bool supportsPresentWait = false;
bool supportsGraphicsPipelineLibrary = false;
@@ -38,6 +38,11 @@ class VulkanProbe {
bool supportsMemoryBudget = false;
bool supportsSurfaceMaintenance1 = false;
+ // Vulkan 1.4+ / Android 16+ features
+ bool meetsVulkan14 = false;
+ bool supportsHostImageCopy = false;
+ bool supportsPushDescriptors = false;
+
// Quirks / Blacklist flags
bool disablePresentId = false;
bool disablePresentWait = false;
diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs
index da4fea69334a..cedb9b53dc88 100644
--- a/osu.Game/Localisation/GraphicsSettingsStrings.cs
+++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs
@@ -169,6 +169,11 @@ public static class GraphicsSettingsStrings
///
public static LocalisableString ShrinkGameToSafeArea => new TranslatableString(getKey(@"shrink_game_to_safe_area"), @"Shrink game to avoid cameras and notches");
+ ///
+ /// "Low latency"
+ ///
+ public static LocalisableString LowLatency => new TranslatableString(getKey(@"low_latency"), @"Low latency");
+
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
index d7696ce2b802..07e41f4ed7ce 100644
--- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
@@ -7,6 +7,7 @@
using osu.Framework.Configuration;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Rendering.LowLatency;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Configuration;
@@ -82,6 +83,14 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi
{
Keywords = new[] { @"framerate", @"counter" },
},
+ new SettingsItemV2(new FormEnumDropdown
+ {
+ Caption = GraphicsSettingsStrings.LowLatency,
+ Current = config.GetBindable(FrameworkSetting.LatencyMode),
+ })
+ {
+ Keywords = new[] { @"latency", @"reflex", @"input" },
+ },
};
renderer.BindValueChanged(r =>
diff --git a/submodules/osu-framework b/submodules/osu-framework
index 13df6c9cb05b..394ecf5d18d2 160000
--- a/submodules/osu-framework
+++ b/submodules/osu-framework
@@ -1 +1 @@
-Subproject commit 13df6c9cb05b5cb4099660ade055d2a6d6f417f3
+Subproject commit 394ecf5d18d2eacc21badf94511090b50d57eb09