// 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 osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.IO.Stores; 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.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Overlays; 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. /// public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; public const string OSU_PROTOCOL = "osu://"; public const string CLIENT_STREAM_NAME = @"lazer"; /// /// The filename of the main client database. /// public const string CLIENT_DATABASE_FILENAME = @"client.realm"; public const int SAMPLE_CONCURRENCY = 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 playback. 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; internal EndpointConfiguration CreateEndpoints() => UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); 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; internal const string BUILD_SUFFIX = "lazer"; public virtual string Version { get { if (!IsDeployedBuild) return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); var version = AssemblyVersion; return $@"{version.Major}.{version.Minor}.{version.Build}-{BUILD_SUFFIX}"; } } /// /// 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 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 MenuCursorContainer MenuCursorContainer { get; private set; } protected MusicController MusicController { get; private set; } protected IAPIProvider API { get; set; } protected Storage Storage { get; set; } protected Bindable Beatmap { get; private set; } // cached via load() method [Cached] [Cached(typeof(IBindable))] protected 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>(Array.Empty()); /// /// Mods available for the current . /// public readonly Bindable>> AvailableMods = new Bindable>>(new Dictionary>()); private BeatmapDifficultyCache difficultyCache; private UserLookupCache userCache; private BeatmapLookupCache beatmapCache; private RulesetConfigCache rulesetConfigCache; private SpectatorClient spectatorClient; private MultiplayerClient multiplayerClient; private RealmAccess realm; protected override Container Content => content; private Container content; private DependencyContainer dependencies; private Bindable fpsDisplayVisible; private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); /// /// A legacy EF context factory if migration has not been performed to realm yet. /// protected DatabaseContextFactory EFContextFactory { get; private set; } /// /// 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 = @"osu!"; allowableExceptions = UnhandledExceptionsBeforeCrash; } [BackgroundDependencyLoader] private void load(ReadableKeyCombinationProvider keyCombinationProvider) { 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)); if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME)) dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage)); dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread, EFContextFactory)); dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); dependencies.CacheAs(RulesetStore); Decoder.RegisterDependencies(RulesetStore); dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); InitialiseFonts(); 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.WebsiteRootUrl; dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, Scheduler, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true)); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); AddInternal(difficultyCache); dependencies.Cache(userCache = new UserLookupCache()); AddInternal(userCache); dependencies.Cache(beatmapCache = new BeatmapLookupCache()); AddInternal(beatmapCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); var powerStatus = CreateBatteryInfo(); if (powerStatus != null) dependencies.CacheAs(powerStatus); dependencies.Cache(SessionStatics = new SessionStatics()); dependencies.Cache(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); // add api components to hierarchy. if (API is APIAccess apiAccess) AddInternal(apiAccess); AddInternal(spectatorClient); AddInternal(multiplayerClient); AddInternal(rulesetConfigCache); GlobalActionContainer globalBindings; base.Content.Add(new SafeAreaContainer { SafeAreaOverrideEdges = SafeAreaOverrideEdges, RelativeSizeAxes = Axes.Both, Child = CreateScalingContainer().WithChildren(new Drawable[] { (MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }).WithChild(content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }), // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything. globalBindings = new GlobalActionContainer(this) }) }); KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); Add(previewTrackManager); AddInternal(MusicController = new MusicController()); dependencies.CacheAs(MusicController); Ruleset.BindValueChanged(onRulesetChanged); Beatmap.BindValueChanged(onBeatmapChanged); } protected virtual void InitialiseFonts() { AddFont(Resources, @"Fonts/osuFont"); 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-Hangul"); AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic"); AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility"); AddFont(Resources, @"Fonts/Noto/Noto-Thai"); AddFont(Resources, @"Fonts/Venera/Venera-Light"); AddFont(Resources, @"Fonts/Venera/Venera-Bold"); AddFont(Resources, @"Fonts/Venera/Venera-Black"); } protected override void LoadComplete() { base.LoadComplete(); // TODO: This is temporary until we reimplement the local FPS display. // It's just to allow end-users to access the framework FPS display without knowing the shortcut key. fpsDisplayVisible = LocalConfig.GetBindable(OsuSetting.ShowFpsDisplay); fpsDisplayVisible.ValueChanged += visible => { FrameStatistics.Value = visible.NewValue ? FrameStatisticsMode.Minimal : FrameStatisticsMode.None; }; fpsDisplayVisible.TriggerChange(); FrameStatistics.ValueChanged += e => fpsDisplayVisible.Value = e.NewValue != FrameStatisticsMode.None; } 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; } /// /// Use to programatically exit the game as if the user was triggering via alt-f4. /// Will keep persisting until an exit occurs (exit may be blocked multiple times). /// public void GracefullyExit() { if (!OnExiting()) Exit(); else Scheduler.AddDelayed(GracefullyExit, 2000); } 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(); Scheduler.Add(() => { realmBlocker = realm.BlockAllOperations(); readyToRun.Set(); }, false); readyToRun.Wait(); bool? cleanupSucceded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); Logger.Log(@"Migration complete!"); return cleanupSucceded != false; } finally { realmBlocker?.Dispose(); } } 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); private void onBeatmapChanged(ValueChangedEvent valueChangedEvent) { if (IsLoaded && !ThreadSafety.IsUpdateThread) throw new InvalidOperationException("Global beatmap bindable must be changed from update thread."); } private void onRulesetChanged(ValueChangedEvent r) { if (IsLoaded && !ThreadSafety.IsUpdateThread) throw new InvalidOperationException("Global ruleset bindable must be changed from update thread."); Ruleset instance = null; try { if (r.NewValue?.Available == true) { instance = r.NewValue.CreateInstance(); } } catch (Exception e) { Logger.Error(e, "Ruleset load failed and has been rolled back"); } 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(typeof(ModType))) { 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) { Logger.Error(e, $"Could not load mods for \"{instance.RulesetInfo.Name}\" ruleset. Current ruleset has been rolled back."); revertRulesetChange(); return; } if (!SelectedMods.Disabled) SelectedMods.Value = Array.Empty(); AvailableMods.Value = dict; 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. /// private bool onExceptionThrown(Exception _) { bool continueExecution = Interlocked.Decrement(ref allowableExceptions) >= 0; Logger.Log($"Unhandled exception has been {(continueExecution ? $"allowed with {allowableExceptions} more allowable exceptions" : "denied")} ."); // restore the stock of allowable exceptions after a short delay. Task.Delay(1000).ContinueWith(_ => Interlocked.Increment(ref allowableExceptions)); return continueExecution; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); RulesetStore?.Dispose(); BeatmapManager?.Dispose(); LocalConfig?.Dispose(); realm?.Dispose(); if (Host != null) Host.ExceptionThrown -= onExceptionThrown; } ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.Beatmap.ControlPointInfo; IClock IBeatSyncProvider.Clock => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track : (IClock)null; ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : (ChannelAmplitudes?)null; } }