diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs new file mode 100644 index 0000000000..cac331451b --- /dev/null +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -0,0 +1,101 @@ +// 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 NUnit.Framework; +using osu.Framework.Input.Bindings; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class TestRealmKeyBindingStore + { + private NativeStorage storage; + + private RealmKeyBindingStore keyBindingStore; + + private RealmContextFactory realmContextFactory; + + [SetUp] + public void SetUp() + { + var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + storage = new NativeStorage(directory.FullName); + + realmContextFactory = new RealmContextFactory(storage); + keyBindingStore = new RealmKeyBindingStore(realmContextFactory); + } + + [Test] + public void TestDefaultsPopulationAndQuery() + { + Assert.That(query().Count, Is.EqualTo(0)); + + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + keyBindingStore.Register(testContainer); + + Assert.That(query().Count, Is.EqualTo(3)); + + Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Back).Count, Is.EqualTo(1)); + Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Select).Count, Is.EqualTo(2)); + } + + private IQueryable query() => realmContextFactory.Context.All(); + + [Test] + public void TestUpdateViaQueriedReference() + { + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + keyBindingStore.Register(testContainer); + + var backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back); + + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape })); + + var tsr = ThreadSafeReference.Create(backBinding); + + using (var usage = realmContextFactory.GetForWrite()) + { + var binding = usage.Realm.ResolveReference(tsr); + binding.KeyCombination = new KeyCombination(InputKey.BackSpace); + + usage.Commit(); + } + + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + + // check still correct after re-query. + backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + } + + [TearDown] + public void TearDown() + { + realmContextFactory.Dispose(); + storage.DeleteDirectory(string.Empty); + } + + public class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => + new[] + { + new KeyBinding(InputKey.Escape, GlobalAction.Back), + new KeyBinding(InputKey.Enter, GlobalAction.Select), + new KeyBinding(InputKey.Space, GlobalAction.Select), + }; + } + } +} diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index a763544c37..a540ad7247 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -142,7 +142,10 @@ namespace osu.Game.Tests.NonVisual foreach (var file in osuStorage.IgnoreFiles) { - Assert.That(File.Exists(Path.Combine(originalDirectory, file))); + // avoid touching realm files which may be a pipe and break everything. + // this is also done locally inside OsuStorage via the IgnoreFiles list. + if (file.EndsWith(".ini", StringComparison.Ordinal)) + Assert.That(File.Exists(Path.Combine(originalDirectory, file))); Assert.That(storage.Exists(file), Is.False); } diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index b347c39c1e..4e5e8517a4 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual typeof(FileStore), typeof(ScoreManager), typeof(BeatmapManager), - typeof(KeyBindingStore), typeof(SettingsStore), typeof(RulesetConfigCache), typeof(OsuColour), diff --git a/osu.Game/Database/IHasGuidPrimaryKey.cs b/osu.Game/Database/IHasGuidPrimaryKey.cs new file mode 100644 index 0000000000..c9cd9b257a --- /dev/null +++ b/osu.Game/Database/IHasGuidPrimaryKey.cs @@ -0,0 +1,16 @@ +// 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 Newtonsoft.Json; +using Realms; + +namespace osu.Game.Database +{ + public interface IHasGuidPrimaryKey + { + [JsonIgnore] + [PrimaryKey] + Guid ID { get; set; } + } +} diff --git a/osu.Game/Database/IRealmFactory.cs b/osu.Game/Database/IRealmFactory.cs new file mode 100644 index 0000000000..c79442134c --- /dev/null +++ b/osu.Game/Database/IRealmFactory.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Realms; + +namespace osu.Game.Database +{ + public interface IRealmFactory + { + /// + /// The main realm context, bound to the update thread. + /// + Realm Context { get; } + + /// + /// Get a fresh context for read usage. + /// + RealmContextFactory.RealmUsage GetForRead(); + + /// + /// Request a context for write usage. + /// This method may block if a write is already active on a different thread. + /// + /// A usage containing a usable context. + RealmContextFactory.RealmWriteUsage GetForWrite(); + } +} diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index 2aae62edea..e0c0f56cb3 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -24,13 +24,15 @@ namespace osu.Game.Database public DbSet BeatmapDifficulty { get; set; } public DbSet BeatmapMetadata { get; set; } public DbSet BeatmapSetInfo { get; set; } - public DbSet DatabasedKeyBinding { get; set; } public DbSet DatabasedSetting { get; set; } public DbSet FileInfo { get; set; } public DbSet RulesetInfo { get; set; } public DbSet SkinInfo { get; set; } public DbSet ScoreInfo { get; set; } + // migrated to realm + public DbSet DatabasedKeyBinding { get; set; } + private readonly string connectionString; private static readonly Lazy logger = new Lazy(() => new OsuDbLoggerFactory()); diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs new file mode 100644 index 0000000000..ed5931dd2b --- /dev/null +++ b/osu.Game/Database/RealmContextFactory.cs @@ -0,0 +1,208 @@ +// 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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Statistics; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Database +{ + public class RealmContextFactory : Component, IRealmFactory + { + private readonly Storage storage; + + private const string database_name = @"client"; + + private const int schema_version = 6; + + /// + /// Lock object which is held for the duration of a write operation (via ). + /// + private readonly object writeLock = new object(); + + private static readonly GlobalStatistic reads = GlobalStatistics.Get("Realm", "Get (Read)"); + private static readonly GlobalStatistic writes = GlobalStatistics.Get("Realm", "Get (Write)"); + private static readonly GlobalStatistic refreshes = GlobalStatistics.Get("Realm", "Dirty Refreshes"); + private static readonly GlobalStatistic contexts_created = GlobalStatistics.Get("Realm", "Contexts (Created)"); + private static readonly GlobalStatistic pending_writes = GlobalStatistics.Get("Realm", "Pending writes"); + private static readonly GlobalStatistic active_usages = GlobalStatistics.Get("Realm", "Active usages"); + + private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true); + + private Realm context; + + public Realm Context + { + get + { + if (IsDisposed) + throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory"); + + if (context == null) + { + context = createContext(); + Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}"); + } + + // creating a context will ensure our schema is up-to-date and migrated. + + return context; + } + } + + public RealmContextFactory(Storage storage) + { + this.storage = storage; + } + + public RealmUsage GetForRead() + { + reads.Value++; + return new RealmUsage(this); + } + + public RealmWriteUsage GetForWrite() + { + writes.Value++; + pending_writes.Value++; + + Monitor.Enter(writeLock); + + return new RealmWriteUsage(this); + } + + protected override void Update() + { + base.Update(); + + if (context?.Refresh() == true) + refreshes.Value++; + } + + private Realm createContext() + { + blockingResetEvent.Wait(); + + contexts_created.Value++; + + return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true)) + { + SchemaVersion = schema_version, + MigrationCallback = onMigration, + }); + } + + private void onMigration(Migration migration, ulong lastSchemaVersion) + { + switch (lastSchemaVersion) + { + case 5: + // let's keep things simple. changing the type of the primary key is a bit involved. + migration.NewRealm.RemoveAll(); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + BlockAllOperations(); + } + + public IDisposable BlockAllOperations() + { + blockingResetEvent.Reset(); + flushContexts(); + + return new InvokeOnDisposal(this, r => endBlockingSection()); + } + + private void endBlockingSection() + { + blockingResetEvent.Set(); + } + + private void flushContexts() + { + var previousContext = context; + context = null; + + // wait for all threaded usages to finish + while (active_usages.Value > 0) + Thread.Sleep(50); + + previousContext?.Dispose(); + } + + /// + /// A usage of realm from an arbitrary thread. + /// + public class RealmUsage : IDisposable + { + public readonly Realm Realm; + + protected readonly RealmContextFactory Factory; + + internal RealmUsage(RealmContextFactory factory) + { + active_usages.Value++; + Factory = factory; + Realm = factory.createContext(); + } + + /// + /// Disposes this instance, calling the initially captured action. + /// + public virtual void Dispose() + { + Realm?.Dispose(); + active_usages.Value--; + } + } + + /// + /// A transaction used for making changes to realm data. + /// + public class RealmWriteUsage : RealmUsage + { + private readonly Transaction transaction; + + internal RealmWriteUsage(RealmContextFactory factory) + : base(factory) + { + transaction = Realm.BeginWrite(); + } + + /// + /// Commit all changes made in this transaction. + /// + public void Commit() => transaction.Commit(); + + /// + /// Revert all changes made in this transaction. + /// + public void Rollback() => transaction.Rollback(); + + /// + /// Disposes this instance, calling the initially captured action. + /// + public override void Dispose() + { + // rollback if not explicitly committed. + transaction?.Dispose(); + + base.Dispose(); + + Monitor.Exit(Factory.writeLock); + pending_writes.Value--; + } + } + } +} diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs new file mode 100644 index 0000000000..aee36e81c5 --- /dev/null +++ b/osu.Game/Database/RealmExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using AutoMapper; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Database +{ + public static class RealmExtensions + { + private static readonly IMapper mapper = new MapperConfiguration(c => + { + c.ShouldMapField = fi => false; + c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic; + + c.CreateMap(); + }).CreateMapper(); + + /// + /// Create a detached copy of the each item in the collection. + /// + /// A list of managed s to detach. + /// The type of object. + /// A list containing non-managed copies of provided items. + public static List Detach(this IEnumerable items) where T : RealmObject + { + var list = new List(); + + foreach (var obj in items) + list.Add(obj.Detach()); + + return list; + } + + /// + /// Create a detached copy of the item. + /// + /// The managed to detach. + /// The type of object. + /// A non-managed copy of provided item. Will return the provided item if already detached. + public static T Detach(this T item) where T : RealmObject + { + if (!item.IsManaged) + return item; + + return mapper.Map(item); + } + } +} diff --git a/osu.Game/FodyWeavers.xml b/osu.Game/FodyWeavers.xml new file mode 100644 index 0000000000..cc07b89533 --- /dev/null +++ b/osu.Game/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/osu.Game/FodyWeavers.xsd b/osu.Game/FodyWeavers.xsd new file mode 100644 index 0000000000..447878c551 --- /dev/null +++ b/osu.Game/FodyWeavers.xsd @@ -0,0 +1,34 @@ + + + + + + + + + + + Disables anonymized usage information from being sent on build. Read more about what data is being collected and why here: https://github.com/realm/realm-dotnet/blob/master/Realm/Realm.Fody/Common/Analytics.cs + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 7df5d820ee..75130b0f9b 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -33,12 +33,18 @@ namespace osu.Game.IO private readonly StorageConfigManager storageConfig; private readonly Storage defaultStorage; - public override string[] IgnoreDirectories => new[] { "cache" }; + public override string[] IgnoreDirectories => new[] + { + "cache", + "client.realm.management" + }; public override string[] IgnoreFiles => new[] { "framework.ini", - "storage.ini" + "storage.ini", + "client.realm.note", + "client.realm.lock", }; public OsuStorage(GameHost host, Storage defaultStorage) diff --git a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs index 8c0072c3da..ad3493d0fc 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs @@ -8,7 +8,7 @@ using osu.Game.Database; namespace osu.Game.Input.Bindings { [Table("KeyBinding")] - public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey + public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey { public int ID { get; set; } @@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings public int? Variant { get; set; } [Column("Keys")] - public string KeysString - { - get => KeyCombination.ToString(); - private set => KeyCombination = value; - } + public string KeysString { get; set; } [Column("Action")] - public int IntAction + public int IntAction { get; set; } + + [NotMapped] + public KeyCombination KeyCombination { - get => (int)Action; - set => Action = value; + get => KeysString; + set => KeysString = value.ToString(); + } + + [NotMapped] + public object Action + { + get => IntAction; + set => IntAction = (int)value; } } } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index 23b09e8fb1..10376c1866 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Input.Bindings; +using osu.Game.Database; using osu.Game.Rulesets; -using System.Linq; +using Realms; namespace osu.Game.Input.Bindings { @@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings private readonly int? variant; - private KeyBindingStore store; + private IDisposable realmSubscription; + private IQueryable realmKeyBindings; + + [Resolved] + private RealmContextFactory realmFactory { get; set; } public override IEnumerable DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0); @@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided."); } - [BackgroundDependencyLoader] - private void load(KeyBindingStore keyBindings) - { - store = keyBindings; - } - protected override void LoadComplete() { + if (ruleset == null || ruleset.ID.HasValue) + { + var rulesetId = ruleset?.ID; + + realmKeyBindings = realmFactory.Context.All() + .Where(b => b.RulesetID == rulesetId && b.Variant == variant); + + realmSubscription = realmKeyBindings + .SubscribeForNotifications((sender, changes, error) => + { + // first subscription ignored as we are handling this in LoadComplete. + if (changes == null) + return; + + ReloadMappings(); + }); + } + base.LoadComplete(); - store.KeyBindingChanged += ReloadMappings; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (store != null) - store.KeyBindingChanged -= ReloadMappings; + realmSubscription?.Dispose(); } protected override void ReloadMappings() @@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings var defaults = DefaultKeyBindings.ToList(); if (ruleset != null && !ruleset.ID.HasValue) - // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings. - // fallback to defaults instead. + // some tests instantiate a ruleset which is not present in the database. + // in these cases we still want key bindings to work, but matching to database instances would result in none being present, + // so let's populate the defaults directly. KeyBindings = defaults; else { - KeyBindings = store.Query(ruleset?.ID, variant) - .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction)) - // this ordering is important to ensure that we read entries from the database in the order - // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise - // have been eaten by the music controller due to query order. - .ToList(); + KeyBindings = realmKeyBindings.Detach() + // this ordering is important to ensure that we read entries from the database in the order + // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise + // have been eaten by the music controller due to query order. + .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList(); } } } diff --git a/osu.Game/Input/Bindings/RealmKeyBinding.cs b/osu.Game/Input/Bindings/RealmKeyBinding.cs new file mode 100644 index 0000000000..334d2da427 --- /dev/null +++ b/osu.Game/Input/Bindings/RealmKeyBinding.cs @@ -0,0 +1,39 @@ +// 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 osu.Framework.Input.Bindings; +using osu.Game.Database; +using Realms; + +namespace osu.Game.Input.Bindings +{ + [MapTo(nameof(KeyBinding))] + public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public int? RulesetID { get; set; } + + public int? Variant { get; set; } + + public KeyCombination KeyCombination + { + get => KeyCombinationString; + set => KeyCombinationString = value.ToString(); + } + + public object Action + { + get => ActionInt; + set => ActionInt = (int)value; + } + + [MapTo(nameof(Action))] + public int ActionInt { get; set; } + + [MapTo(nameof(KeyCombination))] + public string KeyCombinationString { get; set; } + } +} diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs deleted file mode 100644 index 3ef9923487..0000000000 --- a/osu.Game/Input/KeyBindingStore.cs +++ /dev/null @@ -1,133 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using osu.Framework.Input.Bindings; -using osu.Framework.Platform; -using osu.Game.Database; -using osu.Game.Input.Bindings; -using osu.Game.Rulesets; - -namespace osu.Game.Input -{ - public class KeyBindingStore : DatabaseBackedStore - { - public event Action KeyBindingChanged; - - /// - /// Keys which should not be allowed for gameplay input purposes. - /// - private static readonly IEnumerable banned_keys = new[] - { - InputKey.MouseWheelDown, - InputKey.MouseWheelLeft, - InputKey.MouseWheelUp, - InputKey.MouseWheelRight - }; - - public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null) - : base(contextFactory, storage) - { - using (ContextFactory.GetForWrite()) - { - foreach (var info in rulesets.AvailableRulesets) - { - var ruleset = info.CreateInstance(); - foreach (var variant in ruleset.AvailableVariants) - insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant); - } - } - } - - public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings); - - /// - /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action. - /// - /// The action to lookup. - /// A set of display strings for all the user's key configuration for the action. - public IEnumerable GetReadableKeyCombinationsFor(GlobalAction globalAction) - { - foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction)) - { - string str = action.KeyCombination.ReadableString(); - - // even if found, the readable string may be empty for an unbound action. - if (str.Length > 0) - yield return str; - } - } - - private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null) - { - using (var usage = ContextFactory.GetForWrite()) - { - // compare counts in database vs defaults - foreach (var group in defaults.GroupBy(k => k.Action)) - { - int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key); - int aimCount = group.Count(); - - if (aimCount <= count) - continue; - - foreach (var insertable in group.Skip(count).Take(aimCount - count)) - { - // insert any defaults which are missing. - usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding - { - KeyCombination = insertable.KeyCombination, - Action = insertable.Action, - RulesetID = rulesetId, - Variant = variant - }); - - // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686) - usage.Context.SaveChanges(); - } - } - } - } - - /// - /// Retrieve s for a specified ruleset/variant content. - /// - /// The ruleset's internal ID. - /// An optional variant. - public List Query(int? rulesetId = null, int? variant = null) => - ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); - - public void Update(KeyBinding keyBinding) - { - using (ContextFactory.GetForWrite()) - { - var dbKeyBinding = (DatabasedKeyBinding)keyBinding; - - Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination)); - - Refresh(ref dbKeyBinding); - - if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination)) - return; - - dbKeyBinding.KeyCombination = keyBinding.KeyCombination; - } - - KeyBindingChanged?.Invoke(); - } - - public static bool CheckValidForGameplay(KeyCombination combination) - { - foreach (var key in banned_keys) - { - if (combination.Keys.Contains(key)) - return false; - } - - return true; - } - } -} diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs new file mode 100644 index 0000000000..9089169877 --- /dev/null +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Bindings; +using osu.Game.Database; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; + +#nullable enable + +namespace osu.Game.Input +{ + public class RealmKeyBindingStore + { + private readonly RealmContextFactory realmFactory; + + public RealmKeyBindingStore(RealmContextFactory realmFactory) + { + this.realmFactory = realmFactory; + } + + /// + /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action. + /// + /// The action to lookup. + /// A set of display strings for all the user's key configuration for the action. + public IReadOnlyList GetReadableKeyCombinationsFor(GlobalAction globalAction) + { + List combinations = new List(); + + using (var context = realmFactory.GetForRead()) + { + foreach (var action in context.Realm.All().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction)) + { + string str = action.KeyCombination.ReadableString(); + + // even if found, the readable string may be empty for an unbound action. + if (str.Length > 0) + combinations.Add(str); + } + } + + return combinations; + } + + /// + /// Register a new type of , adding default bindings from . + /// + /// The container to populate defaults from. + public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings); + + /// + /// Register a ruleset, adding default bindings for each of its variants. + /// + /// The ruleset to populate defaults from. + public void Register(RulesetInfo ruleset) + { + var instance = ruleset.CreateInstance(); + + foreach (var variant in instance.AvailableVariants) + insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant); + } + + private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null) + { + using (var usage = realmFactory.GetForWrite()) + { + // compare counts in database vs defaults + foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) + { + int existingCount = usage.Realm.All().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key); + + if (defaultsForAction.Count() <= existingCount) + continue; + + foreach (var k in defaultsForAction.Skip(existingCount)) + { + // insert any defaults which are missing. + usage.Realm.Add(new RealmKeyBinding + { + KeyCombinationString = k.KeyCombination.ToString(), + ActionInt = (int)k.Action, + RulesetID = rulesetId, + Variant = variant + }); + } + } + + usage.Commit(); + } + } + + /// + /// Keys which should not be allowed for gameplay input purposes. + /// + private static readonly IEnumerable banned_keys = new[] + { + InputKey.MouseWheelDown, + InputKey.MouseWheelLeft, + InputKey.MouseWheelUp, + InputKey.MouseWheelRight + }; + + public static bool CheckValidForGameplay(KeyCombination combination) + { + foreach (var key in banned_keys) + { + if (combination.Keys.Contains(key)) + return false; + } + + return true; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 0c4d035728..907794d3bb 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -608,9 +608,9 @@ namespace osu.Game LocalConfig.LookupKeyBindings = l => { - var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray(); + var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l); - if (combinations.Length == 0) + if (combinations.Count == 0) return "none"; return string.Join(" or ", combinations); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 05a53452f7..3a08ef684f 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -95,7 +95,7 @@ namespace osu.Game protected RulesetStore RulesetStore { get; private set; } - protected KeyBindingStore KeyBindingStore { get; private set; } + protected RealmKeyBindingStore KeyBindingStore { get; private set; } protected MenuCursorContainer MenuCursorContainer { get; private set; } @@ -144,6 +144,8 @@ namespace osu.Game private DatabaseContextFactory contextFactory; + private RealmContextFactory realmFactory; + protected override Container Content => content; private Container content; @@ -179,6 +181,9 @@ namespace osu.Game 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"))); @@ -284,7 +289,8 @@ namespace osu.Game dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); - dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); + migrateDataToRealm(); + dependencies.Cache(settingsStore = new SettingsStore(contextFactory)); dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore)); @@ -332,7 +338,12 @@ namespace osu.Game base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); + KeyBindingStore = new RealmKeyBindingStore(realmFactory); KeyBindingStore.Register(globalBindings); + + foreach (var r in RulesetStore.AvailableRulesets) + KeyBindingStore.Register(r); + dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; @@ -387,8 +398,11 @@ namespace osu.Game public void Migrate(string path) { - contextFactory.FlushConnections(); - (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + using (realmFactory.BlockAllOperations()) + { + contextFactory.FlushConnections(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + } } protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); @@ -399,6 +413,34 @@ namespace osu.Game protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + 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 + { + 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>(); diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index 0df3359c28..ef620df171 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -1,6 +1,7 @@ // 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.Linq; using osu.Framework.Allocation; @@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding public class KeyBindingRow : Container, IFilterable { private readonly object action; - private readonly IEnumerable bindings; + private readonly IEnumerable bindings; private const float transition_time = 150; @@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString()); - public KeyBindingRow(object action, IEnumerable bindings) + public KeyBindingRow(object action, List bindings) { this.action = action; this.bindings = bindings; @@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding } [Resolved] - private KeyBindingStore store { get; set; } + private RealmContextFactory realmFactory { get; set; } [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding { var button = buttons[i++]; button.UpdateKeyCombination(d); - store.Update(button.KeyBinding); + + updateStoreFromButton(button); } isDefault.Value = true; @@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding { if (bindTarget != null) { - store.Update(bindTarget.KeyBinding); + updateStoreFromButton(bindTarget); updateIsDefaultValue(); @@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding if (bindTarget != null) bindTarget.IsBinding = true; } + private void updateStoreFromButton(KeyButton button) + { + using (var usage = realmFactory.GetForWrite()) + { + var binding = usage.Realm.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); + binding.KeyCombinationString = button.KeyBinding.KeyCombinationString; + + usage.Commit(); + } + } + private void updateIsDefaultValue() { isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults); @@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding public class KeyButton : Container { - public readonly Framework.Input.Bindings.KeyBinding KeyBinding; + public readonly RealmKeyBinding KeyBinding; private readonly Box box; public readonly OsuSpriteText Text; @@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding } } - public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding) + public KeyButton(RealmKeyBinding keyBinding) { + if (keyBinding.IsManaged) + throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding)); + KeyBinding = keyBinding; Margin = new MarginPadding(padding); @@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding public void UpdateKeyCombination(KeyCombination newCombination) { - if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination)) + if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination)) return; KeyBinding.KeyCombination = newCombination; diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs index 5e1f9d8f75..1fdc1b6574 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs @@ -6,8 +6,9 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Input; +using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osuTK; @@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding } [BackgroundDependencyLoader] - private void load(KeyBindingStore store) + private void load(RealmContextFactory realmFactory) { - var bindings = store.Query(Ruleset?.ID, variant); + var rulesetId = Ruleset?.ID; + + List bindings; + + using (var usage = realmFactory.GetForRead()) + bindings = usage.Realm.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach(); foreach (var defaultGroup in Defaults.GroupBy(d => d.Action)) { int intKey = (int)defaultGroup.Key; // one row per valid action. - Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey))) + Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList()) { AllowMainMouseButtons = Ruleset != null, Defaults = defaultGroup.Select(d => d.KeyCombination) diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 1933422dd9..432c52c2e9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; @@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Input; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar protected FillFlowContainer Flow; [Resolved] - private KeyBindingStore keyBindings { get; set; } + private RealmContextFactory realmFactory { get; set; } protected ToolbarButton() : base(HoverSampleSet.Toolbar) @@ -159,27 +159,28 @@ namespace osu.Game.Overlays.Toolbar }; } - private readonly Cached tooltipKeyBinding = new Cached(); + private RealmKeyBinding realmKeyBinding; - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate(); + base.LoadComplete(); + + if (Hotkey == null) return; + + realmKeyBinding = realmFactory.Context.All().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value); + + if (realmKeyBinding != null) + { + realmKeyBinding.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString)) + updateKeyBindingTooltip(); + }; + } + updateKeyBindingTooltip(); } - private void updateKeyBindingTooltip() - { - if (tooltipKeyBinding.IsValid) - return; - - var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey); - var keyBindingString = binding?.KeyCombination.ReadableString(); - keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty; - - tooltipKeyBinding.Validate(); - } - protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) @@ -218,6 +219,17 @@ namespace osu.Game.Overlays.Toolbar public void OnReleased(GlobalAction action) { } + + private void updateKeyBindingTooltip() + { + if (realmKeyBinding != null) + { + var keyBindingString = realmKeyBinding.KeyCombination.ReadableString(); + + if (!string.IsNullOrEmpty(keyBindingString)) + keyBindingTooltip.Text = $" ({keyBindingString})"; + } + } } public class OpaqueBackground : Container diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index e3b9ad5641..e6cd2aa3dc 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.UI { base.ReloadMappings(); - KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); + KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index fe7214de38..3c52405f8e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,6 +18,7 @@ + @@ -34,6 +35,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive +