Skip to content
Merged
8 changes: 8 additions & 0 deletions osu.Android.props
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@
<ProjectReference Include="$(MSBuildThisFileDirectory)submodules\osu-framework\osu.Framework.Android\osu.Framework.Android.csproj" />
</ItemGroup>

<!-- Include framework native libraries (BASS audio, FFmpeg, etc.) from the submodule.
When using a NuGet package these are bundled automatically; with a ProjectReference
they must be declared explicitly or the app crashes at startup with
System.DllNotFoundException: bass (or similar). -->
<ItemGroup>
<AndroidNativeLibrary Include="$(MSBuildThisFileDirectory)submodules\osu-framework\osu.Framework.Android\arm64-v8a\*.so" Abi="arm64-v8a" />
</ItemGroup>

<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
Since Realm objects are not declared directly in Android projects, simply disable Fody. -->
Expand Down
20 changes: 17 additions & 3 deletions osu.Android/AndroidNativeBridgeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)]
Expand Down
18 changes: 11 additions & 7 deletions osu.Android/Native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions osu.Android/Native/VulkanProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
19 changes: 16 additions & 3 deletions osu.Android/Native/oboe_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<oboe::StabilizedCallback>(this);
// shared_ptr is used to satisfy the non-deprecated setDataCallback overload.
stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);

oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
Expand All @@ -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<oboe::AudioStreamErrorCallback>(
std::shared_ptr<void>(), static_cast<oboe::AudioStreamErrorCallback*>(this)));

oboe::Result result = builder.openStream(stream_);

Expand Down Expand Up @@ -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<size_t>(framesRead) * stream->getChannelCount() * sizeof(float);
size_t totalBytes = static_cast<size_t>(numFrames) * stream->getChannelCount() * sizeof(float);
Expand Down
2 changes: 1 addition & 1 deletion osu.Android/Native/oboe_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class OboeBridge : public oboe::AudioStreamCallback {

private:
std::shared_ptr<oboe::AudioStream> stream_;
std::shared_ptr<oboe::StabilizedCallback> stabilizedCallback_;
std::unique_ptr<oboe::LatencyTuner> tuner_;
std::unique_ptr<oboe::StabilizedCallback> stabilizedCallback_;

mutable std::mutex streamLock_;
std::atomic<bool> active_{false};
Expand Down
59 changes: 46 additions & 13 deletions osu.Android/Native/vulkan_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#include <cstring>
#include <new>

#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__)

Expand All @@ -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),
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -137,26 +147,40 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) {
std::vector<VkExtensionProperties> 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;
if (strcmp(ext.extensionName, VK_EXT_SHADER_OBJECT_EXTENSION_NAME) == 0) deviceInfo_.supportsShaderObject = true;
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):
Expand Down Expand Up @@ -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)

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meetsVulkan14 is computed using only VK_VERSION_MINOR(apiVersion) >= 4, which will incorrectly return false for any future Vulkan major versions (e.g. 2.0) even though they should satisfy the “>= 1.4” check. Consider comparing against VK_MAKE_VERSION(1, 4, 0) (or major > 1 || (major == 1 && minor >= 4)) instead of relying on the minor value alone.

Suggested change
if (VK_VERSION_MINOR(deviceInfo_.apiVersion) >= 4)
if (deviceInfo_.apiVersion >= VK_MAKE_VERSION(1, 4, 0))

Copilot uses AI. Check for mistakes.
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;
}
Expand Down Expand Up @@ -227,4 +257,7 @@ OSU_EXPORT byte nVulkanSupportsSurfaceMaintenance1(intptr_t ptr) { return (ptr &
OSU_EXPORT byte nVulkanDisablePresentId(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().disablePresentId) ? 1 : 0; }
OSU_EXPORT byte nVulkanDisablePresentWait(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().disablePresentWait) ? 1 : 0; }
OSU_EXPORT byte nVulkanDisableGraphicsPipelineLibrary(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().disableGraphicsPipelineLibrary) ? 1 : 0; }
OSU_EXPORT byte nVulkanMeetsVulkan14(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().meetsVulkan14) ? 1 : 0; }
OSU_EXPORT byte nVulkanSupportsHostImageCopy(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().supportsHostImageCopy) ? 1 : 0; }
OSU_EXPORT byte nVulkanSupportsPushDescriptors(intptr_t ptr) { return (ptr && reinterpret_cast<VulkanProbe*>(ptr)->getDeviceInfo().supportsPushDescriptors) ? 1 : 0; }
}
7 changes: 6 additions & 1 deletion osu.Android/Native/vulkan_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions osu.Game/Localisation/GraphicsSettingsStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ public static class GraphicsSettingsStrings
/// </summary>
public static LocalisableString ShrinkGameToSafeArea => new TranslatableString(getKey(@"shrink_game_to_safe_area"), @"Shrink game to avoid cameras and notches");

/// <summary>
/// "Low latency"
/// </summary>
public static LocalisableString LowLatency => new TranslatableString(getKey(@"low_latency"), @"Low latency");

private static string getKey(string key) => $@"{prefix}:{key}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +83,14 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi
{
Keywords = new[] { @"framerate", @"counter" },
},
new SettingsItemV2(new FormEnumDropdown<LatencyMode>
{
Caption = GraphicsSettingsStrings.LowLatency,
Current = config.GetBindable<LatencyMode>(FrameworkSetting.LatencyMode),
})
{
Keywords = new[] { @"latency", @"reflex", @"input" },
},
};

renderer.BindValueChanged(r =>
Expand Down
Loading
Loading