diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 1e11b28600cb..d86b80e30312 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -54,16 +54,15 @@ public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasure onStarted?.Invoke(bridge.SampleRate); - scheduler.AddDelayed(() => + scheduler.Add(new ScheduledDelegate(() => { if (oboeBridge is not OboeAudioBridge b) return; double latency = b.GetOutputLatencyMs(); - Debug.WriteLine($"[osu!] Oboe measured latency after warm-up: {latency:F1}ms"); if (latency > 0) onLatencyMeasured(latency); - }, 2000); + }, 2000, 5000)); } else { @@ -87,6 +86,16 @@ public void StopOboeBridge() [MethodImpl(MethodImplOptions.NoInlining)] public static bool SetThreadAffinity(int coreMask) => OboeAudioBridge.nSetThreadAffinity(coreMask) != 0; + + [MethodImpl(MethodImplOptions.NoInlining)] + public bool IsOboeActive() => (oboeBridge as OboeAudioBridge)?.IsActive ?? false; + + [MethodImpl(MethodImplOptions.NoInlining)] + public string GetOboeStatus() + { + if (oboeBridge is not OboeAudioBridge bridge) return string.Empty; + return $"{(bridge.IsAAudio ? "AAudio" : "OpenSLES")} [{(bridge.IsMMap ? "MMAP" : "Legacy")}]"; + } public double GetMeasuredAudioLatencyMs() { return (oboeBridge as OboeAudioBridge)?.GetOutputLatencyMs() ?? -1; diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 9eaa2f05d5c4..5b2db547c1a6 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -338,9 +338,5 @@ public void Dispose() [DllImport(lib_name)] private static extern void nOboeSetProvider(IntPtr ptr, IntPtr provider); [DllImport(lib_name)] internal static extern byte nSetThreadAffinity(int coreMask); - [DllImport(lib_name)] internal static extern IntPtr nADPFCreateSession(long targetDurationNanos); - [DllImport(lib_name)] internal static extern void nADPFReportActualDuration(IntPtr sessionPtr, long actualDurationNanos); - [DllImport(lib_name)] internal static extern void nADPFUpdateTargetDuration(IntPtr sessionPtr, long targetDurationNanos); - [DllImport(lib_name)] internal static extern void nADPFCloseSession(IntPtr sessionPtr); } } diff --git a/osu.Android/Native/VulkanProbe.cs b/osu.Android/Native/VulkanProbe.cs index e0c08239d565..ce0332ecf6ab 100644 --- a/osu.Android/Native/VulkanProbe.cs +++ b/osu.Android/Native/VulkanProbe.cs @@ -47,7 +47,7 @@ static VulkanProbe() public bool SupportsGlobalPriority => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsGlobalPriority(nativePtr) != 0; public bool SupportsMemoryBudget => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsMemoryBudget(nativePtr) != 0; - public bool IsRecommended => IsAvailable && MeetsVulkan13 && SupportsDynamicRendering && SupportsSynchronization2; + public bool IsRecommended => IsAvailable && MeetsVulkan13 && SupportsDynamicRendering && SupportsSynchronization2 && SupportsGraphicsPipelineLibrary && SupportsShaderObject; public void Dispose() { diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index b5cca3114b33..148dabf386ec 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -172,7 +172,6 @@ void OboeBridge::setProvider(OboeAudioProvider provider) { oboe::DataCallbackResult OboeBridge::onAudioReady( oboe::AudioStream* stream, void* audioData, int32_t numFrames) { - OboeAudioProvider provider = provider_.load(std::memory_order_acquire); if (provider) { @@ -190,7 +189,6 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( memset(audioData, 0, byteCount); } - uint32_t count = callbackCount_.fetch_add(1, std::memory_order_relaxed); if ((count & 127) == 0) { @@ -386,34 +384,4 @@ OSU_EXPORT byte nSetThreadAffinity(int coreMask) { } return (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) == 0) ? 1 : 0; } -} - -#include - -extern "C" { -OSU_EXPORT intptr_t nADPFCreateSession(int64_t targetDurationNanos) { - auto manager = APerformanceHint_getManager(); - if (!manager) return 0; - - int32_t thread_id = gettid(); - return reinterpret_cast(APerformanceHint_createSession(manager, &thread_id, 1, targetDurationNanos)); -} - -OSU_EXPORT void nADPFReportActualDuration(intptr_t sessionPtr, int64_t actualDurationNanos) { - if (sessionPtr) { - APerformanceHint_reportActualWorkDuration(reinterpret_cast(sessionPtr), actualDurationNanos); - } -} - -OSU_EXPORT void nADPFUpdateTargetDuration(intptr_t sessionPtr, int64_t targetDurationNanos) { - if (sessionPtr) { - APerformanceHint_updateTargetWorkDuration(reinterpret_cast(sessionPtr), targetDurationNanos); - } -} - -OSU_EXPORT void nADPFCloseSession(intptr_t sessionPtr) { - if (sessionPtr) { - APerformanceHint_closeSession(reinterpret_cast(sessionPtr)); - } -} -} +} \ No newline at end of file diff --git a/osu.Android/Native/vulkan_bridge.cpp b/osu.Android/Native/vulkan_bridge.cpp index 41abc73d4c13..f6a8ba972c94 100644 --- a/osu.Android/Native/vulkan_bridge.cpp +++ b/osu.Android/Native/vulkan_bridge.cpp @@ -130,13 +130,12 @@ void VulkanProbe::queryQueueFamilies(VkPhysicalDevice device) { } void VulkanProbe::queryMailboxSupport(VkPhysicalDevice device) { - // mailbox detection via display_timing hint uint32_t count = 0; vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr); std::vector exts(count); vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data()); for (const auto& ext : exts) { - if (strcmp(ext.extensionName, "VK_GOOGLE_display_timing") == 0) { + if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) { deviceInfo_.supportsMailboxPresentMode = true; break; } diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 691d00b768e0..ad9bc9931b66 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -86,8 +86,6 @@ public partial class OsuGameAndroid : OsuGame private OboeAudioRedirector? audioRedirector; private Delegate? activeMixersHandler; private object? activeMixersList; - private IntPtr updateAdpfSession; - private IntPtr renderAdpfSession; /// /// Boxed reference to the native bridge manager. @@ -211,7 +209,7 @@ protected override void LoadComplete() try { // Target 1ms (1,000,000ns) for 1000 FPS target. - updateAdpfSession = OboeAudioBridge.nADPFCreateSession(1000000); + Scheduler.Add(() => { @@ -219,16 +217,14 @@ protected override void LoadComplete() { try { - renderAdpfSession = OboeAudioBridge.nADPFCreateSession(1000000); - if (renderAdpfSession != IntPtr.Zero) + Debug.WriteLine("[osu!] ADPF Performance Hint Session created for Render thread"); } catch { } }); }); - if (updateAdpfSession != IntPtr.Zero) Debug.WriteLine("[osu!] ADPF Performance Hint Session created for Update thread"); } catch { } @@ -267,9 +263,6 @@ protected override void LoadComplete() { startOboeBridge(latency => { - if (Math.Abs(audioOffset.Value) >= 0.01) - return; - double suggested = Math.Clamp(-latency, audioOffset.MinValue, audioOffset.MaxValue); audioOffset.Value = suggested; Debug.WriteLine($"[osu!] Audio offset auto-suggested: {suggested:F1}ms (hardware latency={latency:F1}ms)"); @@ -404,6 +397,12 @@ private void selectHighestRefreshRate() public override bool IsVulkanSupported => (nativeBridges as AndroidNativeBridgeManager)?.IsVulkanAvailable() ?? false; + public override bool IsOboeActive => (nativeBridges as AndroidNativeBridgeManager)?.IsOboeActive() ?? false; + + public override string OboeStatus => (nativeBridges as AndroidNativeBridgeManager)?.GetOboeStatus() ?? string.Empty; + + public override double OboeLatency => (nativeBridges as AndroidNativeBridgeManager)?.GetMeasuredAudioLatencyMs() ?? -1; + private void onActiveMixersChanged(object? sender, NotifyCollectionChangedEventArgs args) => Schedule(() => { if (lowLatencyAudio.Value) audioRedirector?.RefreshMixers(0); }); public double GetMeasuredAudioLatencyMs() => getMeasuredAudioLatencyFromBridge(); @@ -543,37 +542,11 @@ protected override void Dispose(bool isDisposing) if (nativeBridges != null) disposeNativeBridges(); - - if (updateAdpfSession != IntPtr.Zero) - { - OboeAudioBridge.nADPFCloseSession(updateAdpfSession); - updateAdpfSession = IntPtr.Zero; - } - - if (renderAdpfSession != IntPtr.Zero) - { - OboeAudioBridge.nADPFCloseSession(renderAdpfSession); - renderAdpfSession = IntPtr.Zero; - } } } [MethodImpl(MethodImplOptions.AggressiveOptimization)] - protected override void UpdateAfterChildren() - { - if (updateAdpfSession == IntPtr.Zero) - { - base.UpdateAfterChildren(); - return; - } - - long startTime = Stopwatch.GetTimestamp(); - base.UpdateAfterChildren(); - long elapsedTicks = Stopwatch.GetTimestamp() - startTime; - long elapsedNanos = (elapsedTicks * 1000000000) / Stopwatch.Frequency; - - OboeAudioBridge.nADPFReportActualDuration(updateAdpfSession, elapsedNanos); - } + protected override void UpdateAfterChildren() => base.UpdateAfterChildren(); } diff --git a/osu.Game.OsuGameBase.patch b/osu.Game.OsuGameBase.patch new file mode 100644 index 000000000000..b72a5e28b674 --- /dev/null +++ b/osu.Game.OsuGameBase.patch @@ -0,0 +1,15 @@ +--- a/osu.Game/OsuGameBase.cs ++++ b/osu.Game/OsuGameBase.cs +@@ -122,6 +122,12 @@ + + public virtual bool IsVulkanSupported => false; + ++ public virtual bool IsOboeActive => false; ++ ++ public virtual string OboeStatus => string.Empty; ++ ++ public virtual double OboeLatency => -1; ++ + public virtual string Version + { + get diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs index 190d88a6e464..aabccaee5fd8 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounter.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -59,6 +59,12 @@ public partial class FPSCounter : VisibilityContainer, IHasCustomTooltip [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + protected OsuGame Game { get; private set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + public FPSCounter() { AutoSizeAxes = Axes.Both; @@ -232,7 +238,17 @@ private void requestDisplay() private void updateFpsDisplay() { counterDrawFPS.Colour = getColour(displayedFpsCount / aimDrawFPS); - counterDrawFPS.Text = $"{displayedFpsCount:#,0} fps"; + string status = $"{displayedFpsCount:#,0} fps"; + + if (Game is OsuGame osu) + { + status += $" | {host.ResolvedRenderer.ToString()}"; + + if (osu.IsOboeActive) + status += $" | Oboe: {osu.OboeStatus} ({osu.OboeLatency:F1}ms)"; + } + + counterDrawFPS.Text = status; } private void updateFrameTimeDisplay() diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 4108a4b5b43e..336ec1ba2009 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -122,6 +122,12 @@ public virtual EndpointConfiguration CreateEndpoints() => public virtual bool IsVulkanSupported => false; + public virtual bool IsOboeActive => false; + + public virtual string OboeStatus => string.Empty; + + public virtual double OboeLatency => -1; + public virtual string Version { get diff --git a/osu.Game/OsuGameBase.cs.orig b/osu.Game/OsuGameBase.cs.orig new file mode 100644 index 000000000000..4108a4b5b43e --- /dev/null +++ b/osu.Game/OsuGameBase.cs.orig @@ -0,0 +1,802 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input; +using osu.Framework.Input.Handlers; +using osu.Framework.Input.Handlers.Joystick; +using osu.Framework.Input.Handlers.Midi; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Input.Handlers.Touch; +using osu.Framework.IO.Stores; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using osu.Game.IO; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Chat; +using osu.Game.Online.Leaderboards; +using osu.Game.Online.Metadata; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.Settings.Sections.Input; +using osu.Game.Resources; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Utils; +using RuntimeInfo = osu.Framework.RuntimeInfo; + +namespace osu.Game +{ + /// + /// The most basic that can be used to host osu! components and systems. + /// Unlike , this class will not load any kind of UI, allowing it to be used + /// for provide dependencies to test cases without interfering with them. + /// + [Cached(typeof(OsuGameBase))] + public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider + { +#if DEBUG + public const string GAME_NAME = "osu! (development)"; +#else + public const string GAME_NAME = "osu!"; +#endif + + public const string OSU_PROTOCOL = "osu://"; + + /// + /// The filename of the main client database. + /// + public const string CLIENT_DATABASE_FILENAME = @"client.realm"; + + public const int SAMPLE_CONCURRENCY = 6; + + public const double SFX_STEREO_STRENGTH = 0.6; + + /// + /// Length of debounce (in milliseconds) for commonly occuring sample playbacks that could stack. + /// + public const int SAMPLE_DEBOUNCE_TIME = 20; + + /// + /// The maximum volume at which audio tracks should play back at. This can be set lower than 1 to create some head-room for sound effects. + /// + private const double global_track_volume_adjust = 0.8; + + public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; + + public virtual EndpointConfiguration CreateEndpoints() => + UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + + protected override OnlineStore CreateOnlineStore() => new TrustedDomainOnlineStore(); + + public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); + + /// + /// MD5 representation of the game executable. + /// + public string VersionHash { get; private set; } + + public bool IsDeployedBuild => AssemblyVersion.Major > 0; + public virtual bool IsVulkanRecommended => false; + + public virtual bool IsVulkanSupported => false; + + public virtual string Version + { + get + { + if (!IsDeployedBuild) + return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); + + string informationalVersion = Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion; + + // Example: [assembly: AssemblyInformationalVersion("2025.613.0-tachyon+d934e574b2539e8787956c3c9ecce9dadebb10ee")] + if (!string.IsNullOrEmpty(informationalVersion)) + return informationalVersion.Split('+').First(); + + Version version = AssemblyVersion; + return $@"{version.Major}.{version.Minor}.{version.Build}-lazer"; + } + } + + /// + /// The that the game should be drawn over at a top level. + /// Defaults to . + /// + protected virtual Edges SafeAreaOverrideEdges => Edges.None; + + protected OsuConfigManager LocalConfig { get; private set; } + + protected SessionStatics SessionStatics { get; private set; } + + protected OsuColour Colours { get; private set; } + + protected BeatmapManager BeatmapManager { get; private set; } + + protected BeatmapModelDownloader BeatmapDownloader { get; private set; } + + protected ScoreManager ScoreManager { get; private set; } + + protected ScoreModelDownloader ScoreDownloader { get; private set; } + + protected SkinManager SkinManager { get; private set; } + + protected RealmRulesetStore RulesetStore { get; private set; } + + protected RealmKeyBindingStore KeyBindingStore { get; private set; } + + protected GlobalCursorDisplay GlobalCursorDisplay { get; private set; } + + protected MusicController MusicController { get; private set; } + + protected IAPIProvider API { get; set; } + + protected Storage Storage { get; set; } + + /// + /// The language in which the game is currently displayed in. + /// + public Bindable CurrentLanguage { get; } = new Bindable(); + + protected Bindable Beatmap { get; private set; } // cached via load() method + + /// + /// The current ruleset selection for the local user. + /// + [Cached] + [Cached(typeof(IBindable))] + protected internal readonly Bindable Ruleset = new Bindable(); + + /// + /// The current mod selection for the local user. + /// + /// + /// If a mod select overlay is present, mod instances set to this value are not guaranteed to remain as the provided instance and will be overwritten by a copy. + /// In such a case, changes to settings of a mod will *not* propagate after a mod is added to this collection. + /// As such, all settings should be finalised before adding a mod to this collection. + /// + [Cached] + [Cached(typeof(IBindable>))] + protected readonly Bindable> SelectedMods = new Bindable>([]); + + /// + /// Mods available for the current . + /// + public readonly Bindable>> AvailableMods = new Bindable>>(new Dictionary>()); + + private BeatmapDifficultyCache difficultyCache; + private IBeatmapUpdater beatmapUpdater; + + private UserLookupCache userCache; + private BeatmapLookupCache beatmapCache; + protected LeaderboardManager LeaderboardManager { get; private set; } + + private RulesetConfigCache rulesetConfigCache; + + private SessionAverageHitErrorTracker hitErrorTracker; + + protected SpectatorClient SpectatorClient { get; private set; } + + protected MultiplayerClient MultiplayerClient { get; private set; } + + private MetadataClient metadataClient; + + private RealmAccess realm; + + protected SafeAreaContainer SafeAreaContainer { get; private set; } + + /// + /// For now, this is used as a source specifically for beat synced components. + /// Going forward, it could potentially be used as the single source-of-truth for beatmap timing. + /// + private readonly FramedBeatmapClock beatmapClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: false); + + protected override Container Content => content; + + private Container content; + + private DependencyContainer dependencies; + + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); + + private Bindable frameworkLocale = null!; + + private IBindable localisationParameters = null!; + + /// + /// Number of unhandled exceptions to allow before aborting execution. + /// + /// + /// When an unhandled exception is encountered, an internal count will be decremented. + /// If the count hits zero, the game will crash. + /// Each second, the count is incremented until reaching the value specified. + /// + protected virtual int UnhandledExceptionsBeforeCrash => DebugUtils.IsDebugBuild ? 0 : 1; + + public OsuGameBase() + { + Name = GAME_NAME; + + allowableExceptions = UnhandledExceptionsBeforeCrash; + } + + [BackgroundDependencyLoader] + private void load(ReadableKeyCombinationProvider keyCombinationProvider, FrameworkConfigManager frameworkConfig) + { + try + { + using (var str = File.OpenRead(typeof(OsuGameBase).Assembly.Location)) + VersionHash = str.ComputeMD5Hash(); + } + catch + { + // special case for android builds, which can't read DLLs from a packed apk. + // should eventually be handled in a better way. + VersionHash = $"{Version}-{RuntimeInfo.OS}".ComputeMD5Hash(); + } + + Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); + + dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread)); + + dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); + dependencies.CacheAs(RulesetStore); + + Decoder.RegisterDependencies(RulesetStore); + + dependencies.CacheAs(Storage); + + var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); + largeStore.AddTextureSource(Host.CreateTextureLoaderStore(CreateOnlineStore())); + dependencies.Cache(largeStore); + + dependencies.CacheAs(LocalConfig); + dependencies.CacheAs(LocalConfig); + + InitialiseFonts(); + + addFilesWarning(); + + Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; + + dependencies.Cache(SkinManager = new SkinManager(Storage, realm, Host, Resources, Audio, Scheduler)); + dependencies.CacheAs(SkinManager); + + EndpointConfiguration endpoints = CreateEndpoints(); + + MessageFormatter.WebsiteRootUrl = endpoints.WebsiteUrl; + + // Initialise localisation + frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + frameworkLocale.BindValueChanged(_ => updateLanguage()); + + localisationParameters = Localisation.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => updateLanguage(), true); + + CurrentLanguage.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); + + dependencies.CacheAs(API ??= new APIAccess(this, LocalConfig, endpoints, VersionHash)); + + var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); + + dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); + + // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); + + dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); + dependencies.CacheAs(BeatmapManager); + + dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); + dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); + + // Add after all the above cache operations as it depends on them. + base.Content.Add(difficultyCache); + + // TODO: OsuGame or OsuGameBase? + dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater()); + dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints)); + dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints)); + dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints)); + + base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient)); + + BeatmapManager.ProcessBeatmap = (beatmapSet, scope) => beatmapUpdater.Process(beatmapSet, scope); + + dependencies.Cache(userCache = new UserLookupCache()); + base.Content.Add(userCache); + + dependencies.Cache(beatmapCache = new BeatmapLookupCache()); + base.Content.Add(beatmapCache); + + dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); + + var powerStatus = CreateBatteryInfo(); + if (powerStatus != null) + dependencies.CacheAs(powerStatus); + + dependencies.Cache(SessionStatics = new SessionStatics()); + dependencies.Cache(hitErrorTracker = new SessionAverageHitErrorTracker()); + dependencies.Cache(Colours = new OsuColour()); + + RegisterImportHandler(BeatmapManager); + RegisterImportHandler(ScoreManager); + RegisterImportHandler(SkinManager); + + // drop track volume game-wide to leave some head-room for UI effects / samples. + // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. + // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). + Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); + + Beatmap = new NonNullableBindable(defaultBeatmap); + + dependencies.CacheAs>(Beatmap); + dependencies.CacheAs(Beatmap); + + dependencies.Cache(LeaderboardManager = new LeaderboardManager()); + base.Content.Add(LeaderboardManager); + + // add api components to hierarchy. + if (API is APIAccess apiAccess) + base.Content.Add(apiAccess); + + base.Content.Add(SpectatorClient); + base.Content.Add(MultiplayerClient); + base.Content.Add(metadataClient); + + base.Content.Add(rulesetConfigCache); + + PreviewTrackManager previewTrackManager; + dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); + base.Content.Add(previewTrackManager); + + base.Content.Add(MusicController = new MusicController()); + dependencies.CacheAs(MusicController); + + MusicController.TrackChanged += onTrackChanged; + base.Content.Add(beatmapClock); + + GlobalActionContainer globalBindings; + + OsuMenuSamples menuSamples; + dependencies.Cache(menuSamples = new OsuMenuSamples()); + base.Content.Add(menuSamples); + + base.Content.Add(SafeAreaContainer = new SafeAreaContainer + { + SafeAreaOverrideEdges = SafeAreaOverrideEdges, + RelativeSizeAxes = Axes.Both, + Child = CreateScalingContainer().WithChild(globalBindings = new GlobalActionContainer(this) + { + Children = new Drawable[] + { + (GlobalCursorDisplay = new GlobalCursorDisplay + { + RelativeSizeAxes = Axes.Both + }).WithChild(content = new OsuTooltipContainer(GlobalCursorDisplay.MenuCursor) + { + RelativeSizeAxes = Axes.Both + }), + } + }) + }); + + base.Content.Add(new TouchInputInterceptor()); + base.Content.Add(hitErrorTracker); + + KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); + KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); + dependencies.Cache(KeyBindingStore); + + dependencies.Cache(globalBindings); + + Ruleset.BindValueChanged(onRulesetChanged); + Beatmap.BindValueChanged(onBeatmapChanged); + + // make config aware of how to lookup skins for on-screen display purposes. + // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. + LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; + LocalConfig.LookupKeyBindings = l => KeyBindingStore.GetBindingsStringFor(l); + } + + private void updateLanguage() => CurrentLanguage.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); + + private void addFilesWarning() + { + const string filename = "IMPORTANT READ ME.txt"; + + if (!Storage.Exists(filename)) + { + using (var stream = Storage.CreateFileSafely(filename)) + using (var textWriter = new StreamWriter(stream)) + { + textWriter.WriteLine(@"This folder contains all your user files and configuration."); + textWriter.WriteLine(@"Please DO NOT make manual changes to this folder."); + textWriter.WriteLine(); + textWriter.WriteLine(@"- If you want to back up your game files, please back up THE ENTIRETY OF THIS DIRECTORY."); + textWriter.WriteLine(@"- If you want to delete all of your game files, please delete THE ENTIRETY OF THIS DIRECTORY."); + textWriter.WriteLine(); + textWriter.WriteLine(@"To be very clear, the ""files/"" directory inside this directory stores all the raw pieces of your beatmaps, skins, and replays."); + textWriter.WriteLine(@"Importantly, it is NOT the only directory you need a backup of to avoid losing data. If you copy only the ""files/"" directory, YOU WILL LOSE DATA."); + textWriter.WriteLine(); + textWriter.WriteLine(@"For more information on how these files are organised,"); + textWriter.WriteLine(@"see https://github.com/ppy/osu/wiki/User-file-storage"); + } + } + } + + private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) => beatmapClock.ChangeSource(beatmap.Track); + + protected virtual void InitialiseFonts() + { + AddFont(Resources, @"Fonts/Torus/Torus-Regular"); + AddFont(Resources, @"Fonts/Torus/Torus-Light"); + AddFont(Resources, @"Fonts/Torus/Torus-SemiBold"); + AddFont(Resources, @"Fonts/Torus/Torus-Bold"); + + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Regular"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Light"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-SemiBold"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Bold"); + + AddFont(Resources, @"Fonts/Inter/Inter-Regular"); + AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-Light"); + AddFont(Resources, @"Fonts/Inter/Inter-LightItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-SemiBold"); + AddFont(Resources, @"Fonts/Inter/Inter-SemiBoldItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-Bold"); + AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic"); + + AddFont(Resources, @"Fonts/Noto/Noto-Basic"); + AddFont(Resources, @"Fonts/Noto/Noto-Bopomofo"); + AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic"); + AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto/Noto-Hangul"); + AddFont(Resources, @"Fonts/Noto/Noto-Thai"); + + AddFont(Resources, @"Fonts/Venera/Venera-Light"); + AddFont(Resources, @"Fonts/Venera/Venera-Bold"); + AddFont(Resources, @"Fonts/Venera/Venera-Black"); + + Fonts.AddStore(new OsuIcon.OsuIconStore(Textures)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var localeMappings = Enum.GetValues().Select(language => + { +#if DEBUG + if (language == Language.debug) + return new LocaleMapping("debug", new DebugLocalisationStore()); +#endif + + string cultureCode = language.ToCultureCode(); + + try + { + return new LocaleMapping(new ResourceManagerLocalisationStore(cultureCode)); + } + catch (Exception ex) + { + Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\""); + return null; + } + }).Where(m => m != null); + + Localisation.AddLocaleMappings(localeMappings); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + public override void SetHost(GameHost host) + { + base.SetHost(host); + + // may be non-null for certain tests + Storage ??= host.Storage; + + LocalConfig ??= UseDevelopmentServer + ? new DevelopmentOsuConfigManager(Storage) + : new OsuConfigManager(Storage); + + host.ExceptionThrown += onExceptionThrown; + } + + #region Exit handling + + /// + /// Use to programatically exit the game as if the user was triggering via alt-f4. + /// By default, will keep persisting until an exit occurs (exit may be blocked multiple times). + /// May be interrupted (see 's override). + /// + public virtual void AttemptExit() + { + if (!OnExiting()) + Exit(); + else + Scheduler.AddDelayed(AttemptExit, 2000); + } + + /// + /// An action that restarts the application after it has exited. + /// + [CanBeNull] + public Action RestartOnExitAction { private get; set; } + + /// + /// Signals that the application should not be restarted after it is exited. + /// + public void CancelRestartOnExit() + { + RestartOnExitAction = null; + } + + /// + /// If supported by the platform, the game will automatically restart after the next exit. + /// + /// Whether a restart operation was queued. + public virtual bool RestartAppWhenExited() => false; + + #endregion + + /// + /// Perform migration of user data to a specified path. + /// + /// The path to migrate to. + /// Whether migration succeeded to completion. If false, some files were left behind. + /// + public bool Migrate(string path) + { + Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); + + IDisposable realmBlocker = null; + + try + { + ManualResetEventSlim readyToRun = new ManualResetEventSlim(); + + bool success = false; + + Scheduler.Add(() => + { + try + { + realmBlocker = realm.BlockAllOperations("migration"); + success = true; + } + catch (Exception ex) + { + Logger.Log($"Attempting to block all operations failed: {ex}", LoggingTarget.Database); + } + + readyToRun.Set(); + }, false); + + if (!readyToRun.Wait(30000) || !success) + throw new TimeoutException("Attempting to block for migration took too long."); + + bool? cleanupSucceeded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + + Logger.Log(@"Migration complete!"); + return cleanupSucceeded != false; + } + finally + { + realmBlocker?.Dispose(); + } + } + + protected virtual IBeatmapUpdater CreateBeatmapUpdater() => new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage); + + protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); + + protected virtual BatteryInfo CreateBatteryInfo() => null; + + protected virtual Container CreateScalingContainer() => new DrawSizePreservingFillContainer(); + + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + + /// + /// Creates an input settings subsection for an . + /// + /// Should be overriden per-platform to provide settings for platform-specific handlers. + public virtual SettingsSubsection CreateSettingsSubsectionFor(InputHandler handler) + { + // One would think that this could be moved to the `OsuGameDesktop` class, but doing so means that + // OsuGameTestScenes will not show any input options (as they are based on OsuGame not OsuGameDesktop). + // + // This in turn makes it hard for ruleset creators to adjust input settings while testing their ruleset + // within the test browser interface. + if (RuntimeInfo.IsDesktop) + { + switch (handler) + { + case ITabletHandler th: + return new TabletSettings(th); + } + } + + switch (handler) + { + case MouseHandler mh: + return new MouseSettings(mh); + + case JoystickHandler jh: + return new JoystickSettings(jh); + + case TouchHandler th: + return new TouchSettings(th); + + case MidiHandler: + return new InputSubsection(handler); + + // return null for handlers that shouldn't have settings. + default: + return null; + } + } + + private void onBeatmapChanged(ValueChangedEvent beatmap) + { + if (IsLoaded && !ThreadSafety.IsUpdateThread) + throw new InvalidOperationException("Global beatmap bindable must be changed from update thread."); + + Logger.Log($"Game-wide working beatmap updated to {beatmap.NewValue}"); + } + + private void onRulesetChanged(ValueChangedEvent r) + { + if (IsLoaded && !ThreadSafety.IsUpdateThread) + throw new InvalidOperationException("Global ruleset bindable must be changed from update thread."); + + Ruleset instance = null; + + if (r.NewValue?.Available == true) + { + try + { + instance = r.NewValue.CreateInstance(); + } + catch (Exception e) + { + Rulesets.RulesetStore.LogRulesetFailure(r.NewValue, e); + } + } + + if (instance == null) + { + // reject the change if the ruleset is not available. + revertRulesetChange(); + return; + } + + var dict = new Dictionary>(); + + try + { + foreach (ModType type in Enum.GetValues()) + { + dict[type] = instance.GetModsFor(type) + // Rulesets should never return null mods, but let's be defensive just in case. + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + .Where(mod => mod != null) + .ToList(); + } + } + catch (Exception e) + { + Rulesets.RulesetStore.LogRulesetFailure(r.NewValue, e); + revertRulesetChange(); + return; + } + + AvailableMods.Value = dict; + + if (SelectedMods.Disabled) + return; + + var convertedMods = SelectedMods.Value.Select(mod => + { + var newMod = instance.CreateModFromAcronym(mod.Acronym); + newMod?.CopyCommonSettingsFrom(mod); + return newMod; + }).Where(newMod => newMod != null).ToList(); + + if (!ModUtils.CheckValidForGameplay(convertedMods, out var invalid)) + invalid.ForEach(newMod => convertedMods.Remove(newMod)); + + SelectedMods.Value = convertedMods; + + void revertRulesetChange() => Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); + } + + private int allowableExceptions; + + /// + /// Allows a maximum of one unhandled exception, per second of execution. + /// + /// Whether to ignore the exception and continue running. + private bool onExceptionThrown(Exception ex) + { + if (Interlocked.Decrement(ref allowableExceptions) < 0) + { + Logger.Log("Too many unhandled exceptions, crashing out."); + RulesetStore?.TryDisableCustomRulesetsCausing(ex); + return false; + } + + Logger.Log($"Unhandled exception has been allowed with {allowableExceptions} more allowable exceptions."); + // restore the stock of allowable exceptions after a short delay. + Task.Delay(1000).ContinueWith(_ => Interlocked.Increment(ref allowableExceptions)); + + return true; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + RulesetStore?.Dispose(); + LocalConfig?.Dispose(); + + beatmapUpdater?.Dispose(); + + realm?.Dispose(); + + Host?.ExceptionThrown -= onExceptionThrown; + + RestartOnExitAction?.Invoke(); + } + + ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.BeatmapLoaded ? Beatmap.Value.Beatmap.ControlPointInfo : null; + IClock IBeatSyncProvider.Clock => beatmapClock; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; + } +} diff --git a/osu.Game/OsuGameBase.cs.rej b/osu.Game/OsuGameBase.cs.rej new file mode 100644 index 000000000000..eed5e9858d04 --- /dev/null +++ b/osu.Game/OsuGameBase.cs.rej @@ -0,0 +1,15 @@ +--- OsuGameBase.cs ++++ OsuGameBase.cs +@@ -122,6 +122,12 @@ + + public virtual bool IsVulkanSupported => false; + ++ public virtual bool IsOboeActive => false; ++ ++ public virtual string OboeStatus => string.Empty; ++ ++ public virtual double OboeLatency => -1; ++ + public virtual string Version + { + get