// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Online.API; using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Database; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online; 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 osuTK.Input; 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 class OsuGameBase : Framework.Game, ICanAcceptFiles { public const string CLIENT_STREAM_NAME = "lazer"; public const int SAMPLE_CONCURRENCY = 6; public bool UseDevelopmentServer { get; } protected OsuConfigManager LocalConfig; protected SessionStatics SessionStatics { get; private set; } protected BeatmapManager BeatmapManager; protected ScoreManager ScoreManager; protected BeatmapDifficultyCache DifficultyCache; protected UserLookupCache UserCache; protected SkinManager SkinManager; protected RulesetStore RulesetStore; protected FileStore FileStore; protected RealmKeyBindingStore KeyBindingStore; protected SettingsStore SettingsStore; protected RulesetConfigCache RulesetConfigCache; protected IAPIProvider API; private SpectatorStreamingClient spectatorStreaming; private StatefulMultiplayerClient multiplayerClient; protected MenuCursorContainer MenuCursorContainer; protected MusicController MusicController; private Container content; protected override Container Content => content; protected Storage Storage { get; set; } [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>>(); protected Bindable Beatmap { get; private set; } // cached via load() method private Bindable fpsDisplayVisible; 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 string Version { get { if (!IsDeployedBuild) return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); var version = AssemblyVersion; return $@"{version.Major}.{version.Minor}.{version.Build}"; } } public OsuGameBase() { UseDevelopmentServer = DebugUtils.IsDebugBuild; Name = @"osu!lazer"; } private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); private DatabaseContextFactory contextFactory; private RealmContextFactory realmFactory; protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); protected virtual BatteryInfo CreateBatteryInfo() => null; /// /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. /// internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); [BackgroundDependencyLoader] private void load() { 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(contextFactory = new DatabaseContextFactory(Storage)); dependencies.Cache(realmFactory = new RealmContextFactory(Storage)); AddInternal(realmFactory); 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); AddFont(Resources, @"Fonts/osuFont"); AddFont(Resources, @"Fonts/Torus-Regular"); AddFont(Resources, @"Fonts/Torus-Light"); AddFont(Resources, @"Fonts/Torus-SemiBold"); AddFont(Resources, @"Fonts/Torus-Bold"); AddFont(Resources, @"Fonts/Noto-Basic"); AddFont(Resources, @"Fonts/Noto-Hangul"); AddFont(Resources, @"Fonts/Noto-CJK-Basic"); AddFont(Resources, @"Fonts/Noto-CJK-Compatibility"); AddFont(Resources, @"Fonts/Noto-Thai"); AddFont(Resources, @"Fonts/Venera-Light"); AddFont(Resources, @"Fonts/Venera-Bold"); AddFont(Resources, @"Fonts/Venera-Black"); Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; runMigrations(); dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Audio, new NamespacedResourceStore(Resources, "Skins/Legacy"))); dependencies.CacheAs(SkinManager); // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. SkinManager.ItemRemoved.BindValueChanged(weakRemovedInfo => { if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) { Schedule(() => { // check the removed skin is not the current user choice. if it is, switch back to default. if (removedInfo.ID == SkinManager.CurrentSkinInfo.Value.ID) SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; }); } }); EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints)); dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); dependencies.Cache(FileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => DifficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Host, defaultBeatmap, true)); // this should likely be moved to ArchiveModelManager when another case appers where it is necessary // to have inter-dependent model managers. this could be obtained with an IHasForeign interface to // allow lookups to be done on the child (ScoreManager in this case) to perform the cascading delete. List getBeatmapScores(BeatmapSetInfo set) { var beatmapIds = BeatmapManager.QueryBeatmaps(b => b.BeatmapSetInfoID == set.ID).Select(b => b.ID).ToList(); return ScoreManager.QueryScores(s => beatmapIds.Contains(s.Beatmap.ID)).ToList(); } BeatmapManager.ItemRemoved.BindValueChanged(i => { if (i.NewValue.TryGetTarget(out var item)) ScoreManager.Delete(getBeatmapScores(item), true); }); BeatmapManager.ItemUpdated.BindValueChanged(i => { if (i.NewValue.TryGetTarget(out var item)) ScoreManager.Undelete(getBeatmapScores(item), true); }); dependencies.Cache(DifficultyCache = new BeatmapDifficultyCache()); AddInternal(DifficultyCache); dependencies.Cache(UserCache = new UserLookupCache()); AddInternal(UserCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); migrateDataToRealm(); dependencies.CacheAs(KeyBindingStore = new RealmKeyBindingStore(realmFactory)); dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); dependencies.Cache(RulesetConfigCache = new RulesetConfigCache(SettingsStore)); 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); FileStore.Cleanup(); // add api components to hierarchy. if (API is APIAccess apiAccess) AddInternal(apiAccess); AddInternal(spectatorStreaming); AddInternal(multiplayerClient); AddInternal(RulesetConfigCache); GlobalActionContainer globalBindings; var mainContent = new Drawable[] { MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }, // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything. globalBindings = new GlobalActionContainer(this) }; MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }; base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); KeyBindingStore.Register(globalBindings); foreach (var r in RulesetStore.AvailableRulesets) KeyBindingStore.Register(r); dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; dependencies.Cache(previewTrackManager = new PreviewTrackManager()); Add(previewTrackManager); AddInternal(MusicController = new MusicController()); dependencies.CacheAs(MusicController); Ruleset.BindValueChanged(onRulesetChanged); } private void migrateDataToRealm() { using (var db = contextFactory.GetForWrite()) using (var usage = realmFactory.GetForWrite()) { var existingBindings = db.Context.DatabasedKeyBinding; // only migrate data if the realm database is empty. if (!usage.Realm.All().Any()) { foreach (var dkb in existingBindings) { usage.Realm.Add(new RealmKeyBinding { ID = Guid.NewGuid(), KeyCombinationString = dkb.KeyCombination.ToString(), ActionInt = (int)dkb.Action, RulesetID = dkb.RulesetID, Variant = dkb.Variant }); } } db.Context.RemoveRange(existingBindings); usage.Commit(); } } private void onRulesetChanged(ValueChangedEvent r) { var dict = new Dictionary>(); if (r.NewValue?.Available == true) { foreach (ModType type in Enum.GetValues(typeof(ModType))) dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); } if (!SelectedMods.Disabled) SelectedMods.Value = Array.Empty(); AvailableMods.Value = dict; } protected virtual Container CreateScalingContainer() => new DrawSizePreservingFillContainer(); 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; } private void runMigrations() { try { using (var db = contextFactory.GetForWrite(false)) db.Context.Migrate(); } catch (Exception e) { Logger.Error(e.InnerException ?? e, "Migration failed! We'll be starting with a fresh database.", LoggingTarget.Database); // if we failed, let's delete the database and start fresh. // todo: we probably want a better (non-destructive) migrations/recovery process at a later point than this. contextFactory.ResetDatabase(); Logger.Log("Database purged successfully.", LoggingTarget.Database); // only run once more, then hard bail. using (var db = contextFactory.GetForWrite(false)) db.Context.Migrate(); } } 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); } /// /// 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); } protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); private readonly List fileImporters = new List(); /// /// Register a global handler for file imports. Most recently registered will have precedence. /// /// The handler to register. public void RegisterImportHandler(ICanAcceptFiles handler) => fileImporters.Insert(0, handler); /// /// Unregister a global handler for file imports. /// /// The previously registered handler. public void UnregisterImportHandler(ICanAcceptFiles handler) => fileImporters.Remove(handler); public async Task Import(params string[] paths) { if (paths.Length == 0) return; var filesPerExtension = paths.GroupBy(p => Path.GetExtension(p).ToLowerInvariant()); foreach (var groups in filesPerExtension) { foreach (var importer in fileImporters) { if (importer.HandledExtensions.Contains(groups.Key)) await importer.Import(groups.ToArray()).ConfigureAwait(false); } } } public virtual async Task Import(params ImportTask[] tasks) { var tasksPerExtension = tasks.GroupBy(t => Path.GetExtension(t.Path).ToLowerInvariant()); await Task.WhenAll(tasksPerExtension.Select(taskGroup => { var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; })).ConfigureAwait(false); } public IEnumerable HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions); protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); RulesetStore?.Dispose(); BeatmapManager?.Dispose(); LocalConfig?.Dispose(); contextFactory.FlushConnections(); } private class OsuUserInputManager : UserInputManager { protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { switch (button) { case MouseButton.Right: return new RightMouseManager(button); } return base.CreateButtonEventManagerFor(button); } private class RightMouseManager : MouseButtonEventManager { public RightMouseManager(MouseButton button) : base(button) { } public override bool EnableDrag => true; // allow right-mouse dragging for absolute scroll in scroll containers. public override bool EnableClick => false; public override bool ChangeFocusOnClick => false; } } public void Migrate(string path) { using (realmFactory.BlockAllOperations()) { contextFactory.FlushConnections(); (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } } } }