Merge branch 'master' into music-controller-less-population

This commit is contained in:
Dean Herbert
2022-01-24 20:44:37 +09:00
50 changed files with 1034 additions and 339 deletions

View File

@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default
if (!effectPoint.KiaiMode) if (!effectPoint.KiaiMode)
return; return;
if (beatIndex % (int)timingPoint.TimeSignature != 0) if (beatIndex % timingPoint.TimeSignature.Numerator != 0)
return; return;
double duration = timingPoint.BeatLength * 2; double duration = timingPoint.BeatLength * 2;

View File

@ -178,17 +178,17 @@ namespace osu.Game.Tests.Beatmaps.Formats
var timingPoint = controlPoints.TimingPointAt(0); var timingPoint = controlPoints.TimingPointAt(0);
Assert.AreEqual(956, timingPoint.Time); Assert.AreEqual(956, timingPoint.Time);
Assert.AreEqual(329.67032967033, timingPoint.BeatLength); Assert.AreEqual(329.67032967033, timingPoint.BeatLength);
Assert.AreEqual(TimeSignatures.SimpleQuadruple, timingPoint.TimeSignature); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
timingPoint = controlPoints.TimingPointAt(48428); timingPoint = controlPoints.TimingPointAt(48428);
Assert.AreEqual(956, timingPoint.Time); Assert.AreEqual(956, timingPoint.Time);
Assert.AreEqual(329.67032967033d, timingPoint.BeatLength); Assert.AreEqual(329.67032967033d, timingPoint.BeatLength);
Assert.AreEqual(TimeSignatures.SimpleQuadruple, timingPoint.TimeSignature); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
timingPoint = controlPoints.TimingPointAt(119637); timingPoint = controlPoints.TimingPointAt(119637);
Assert.AreEqual(119637, timingPoint.Time); Assert.AreEqual(119637, timingPoint.Time);
Assert.AreEqual(659.340659340659, timingPoint.BeatLength); Assert.AreEqual(659.340659340659, timingPoint.BeatLength);
Assert.AreEqual(TimeSignatures.SimpleQuadruple, timingPoint.TimeSignature); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
var difficultyPoint = controlPoints.DifficultyPointAt(0); var difficultyPoint = controlPoints.DifficultyPointAt(0);
Assert.AreEqual(0, difficultyPoint.Time); Assert.AreEqual(0, difficultyPoint.Time);

View File

@ -46,7 +46,7 @@ namespace osu.Game.Tests.Database
{ {
bool callbackRan = false; bool callbackRan = false;
realmFactory.Register(realm => realmFactory.RegisterCustomSubscription(realm =>
{ {
var subscription = realm.All<BeatmapInfo>().QueryAsyncWithNotifications((sender, changes, error) => var subscription = realm.All<BeatmapInfo>().QueryAsyncWithNotifications((sender, changes, error) =>
{ {

View File

@ -239,7 +239,7 @@ namespace osu.Game.Tests.Database
{ {
int changesTriggered = 0; int changesTriggered = 0;
realmFactory.Register(outerRealm => realmFactory.RegisterCustomSubscription(outerRealm =>
{ {
outerRealm.All<BeatmapInfo>().QueryAsyncWithNotifications(gotChange); outerRealm.All<BeatmapInfo>().QueryAsyncWithNotifications(gotChange);
ILive<BeatmapInfo>? liveBeatmap = null; ILive<BeatmapInfo>? liveBeatmap = null;

View File

@ -0,0 +1,138 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using Realms;
#nullable enable
namespace osu.Game.Tests.Database
{
[TestFixture]
public class RealmSubscriptionRegistrationTests : RealmTest
{
[Test]
public void TestSubscriptionWithContextLoss()
{
IEnumerable<BeatmapSetInfo>? resolvedItems = null;
ChangeSet? lastChanges = null;
RunTestWithRealm((realmFactory, _) =>
{
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
var registration = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>(), onChanged);
testEventsArriving(true);
// All normal until here.
// Now let's yank the main realm context.
resolvedItems = null;
lastChanges = null;
using (realmFactory.BlockAllOperations())
Assert.That(resolvedItems, Is.Empty);
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
testEventsArriving(true);
// Now let's try unsubscribing.
resolvedItems = null;
lastChanges = null;
registration.Dispose();
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
testEventsArriving(false);
// And make sure even after another context loss we don't get firings.
using (realmFactory.BlockAllOperations())
Assert.That(resolvedItems, Is.Null);
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
testEventsArriving(false);
void testEventsArriving(bool shouldArrive)
{
realmFactory.Run(realm => realm.Refresh());
if (shouldArrive)
Assert.That(resolvedItems, Has.One.Items);
else
Assert.That(resolvedItems, Is.Null);
realmFactory.Write(realm =>
{
realm.RemoveAll<BeatmapSetInfo>();
realm.RemoveAll<RulesetInfo>();
});
realmFactory.Run(realm => realm.Refresh());
if (shouldArrive)
Assert.That(lastChanges?.DeletedIndices, Has.One.Items);
else
Assert.That(lastChanges, Is.Null);
}
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error)
{
if (changes == null)
resolvedItems = sender;
lastChanges = changes;
}
}
[Test]
public void TestCustomRegisterWithContextLoss()
{
RunTestWithRealm((realmFactory, _) =>
{
BeatmapSetInfo? beatmapSetInfo = null;
realmFactory.Write(realm => realm.Add(TestResources.CreateTestBeatmapSetInfo()));
var subscription = realmFactory.RegisterCustomSubscription(realm =>
{
beatmapSetInfo = realm.All<BeatmapSetInfo>().First();
return new InvokeOnDisposal(() => beatmapSetInfo = null);
});
Assert.That(beatmapSetInfo, Is.Not.Null);
using (realmFactory.BlockAllOperations())
{
// custom disposal action fired when context lost.
Assert.That(beatmapSetInfo, Is.Null);
}
// re-registration after context restore.
realmFactory.Run(realm => realm.Refresh());
Assert.That(beatmapSetInfo, Is.Not.Null);
subscription.Dispose();
Assert.That(beatmapSetInfo, Is.Null);
using (realmFactory.BlockAllOperations())
Assert.That(beatmapSetInfo, Is.Null);
realmFactory.Run(realm => realm.Refresh());
Assert.That(beatmapSetInfo, Is.Null);
});
}
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Tests.NonVisual
const int beat_length_numerator = 2000; const int beat_length_numerator = 2000;
const int beat_length_denominator = 7; const int beat_length_denominator = 7;
const TimeSignatures signature = TimeSignatures.SimpleQuadruple; TimeSignature signature = TimeSignature.SimpleQuadruple;
var beatmap = new Beatmap var beatmap = new Beatmap
{ {
@ -49,7 +49,7 @@ namespace osu.Game.Tests.NonVisual
for (int i = 0; i * beat_length_denominator < barLines.Count; i++) for (int i = 0; i * beat_length_denominator < barLines.Count; i++)
{ {
var barLine = barLines[i * beat_length_denominator]; var barLine = barLines[i * beat_length_denominator];
int expectedTime = beat_length_numerator * (int)signature * i; int expectedTime = beat_length_numerator * signature.Numerator * i;
// every seventh bar's start time should be at least greater than the whole number we expect. // every seventh bar's start time should be at least greater than the whole number we expect.
// It cannot be less, as that can affect overlapping scroll algorithms // It cannot be less, as that can affect overlapping scroll algorithms
@ -60,7 +60,7 @@ namespace osu.Game.Tests.NonVisual
Assert.IsTrue(Precision.AlmostEquals(barLine.StartTime, expectedTime)); Assert.IsTrue(Precision.AlmostEquals(barLine.StartTime, expectedTime));
// check major/minor lines for good measure too // check major/minor lines for good measure too
Assert.AreEqual(i % (int)signature == 0, barLine.Major); Assert.AreEqual(i % signature.Numerator == 0, barLine.Major);
} }
} }

View File

@ -80,7 +80,10 @@ namespace osu.Game.Tests.Resources
public static BeatmapSetInfo CreateTestBeatmapSetInfo(int? difficultyCount = null, RulesetInfo[] rulesets = null) public static BeatmapSetInfo CreateTestBeatmapSetInfo(int? difficultyCount = null, RulesetInfo[] rulesets = null)
{ {
int j = 0; int j = 0;
RulesetInfo getRuleset() => rulesets?[j++ % rulesets.Length] ?? new OsuRuleset().RulesetInfo;
rulesets ??= new[] { new OsuRuleset().RulesetInfo };
RulesetInfo getRuleset() => rulesets?[j++ % rulesets.Length];
int setId = Interlocked.Increment(ref importId); int setId = Interlocked.Increment(ref importId);

View File

@ -46,6 +46,7 @@ namespace osu.Game.Tests.Visual.Editing
editorBeatmap.BeatmapInfo.Metadata.Artist = "artist"; editorBeatmap.BeatmapInfo.Metadata.Artist = "artist";
editorBeatmap.BeatmapInfo.Metadata.Title = "title"; editorBeatmap.BeatmapInfo.Metadata.Title = "title";
}); });
AddStep("Set author", () => editorBeatmap.BeatmapInfo.Metadata.Author.Username = "author");
AddStep("Set difficulty name", () => editorBeatmap.BeatmapInfo.DifficultyName = "difficulty"); AddStep("Set difficulty name", () => editorBeatmap.BeatmapInfo.DifficultyName = "difficulty");
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
@ -64,6 +65,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Save", () => InputManager.Keys(PlatformAction.Save)); AddStep("Save", () => InputManager.Keys(PlatformAction.Save));
checkMutations(); checkMutations();
AddAssert("Beatmap has correct .osu file path", () => editorBeatmap.BeatmapInfo.Path == "artist - title (author) [difficulty].osu");
AddStep("Exit", () => InputManager.Key(Key.Escape)); AddStep("Exit", () => InputManager.Key(Key.Escape));
@ -88,6 +90,7 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1);
AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7); AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7);
AddAssert("Beatmap has correct metadata", () => editorBeatmap.BeatmapInfo.Metadata.Artist == "artist" && editorBeatmap.BeatmapInfo.Metadata.Title == "title"); AddAssert("Beatmap has correct metadata", () => editorBeatmap.BeatmapInfo.Metadata.Artist == "artist" && editorBeatmap.BeatmapInfo.Metadata.Title == "title");
AddAssert("Beatmap has correct author", () => editorBeatmap.BeatmapInfo.Metadata.Author.Username == "author");
AddAssert("Beatmap has correct difficulty name", () => editorBeatmap.BeatmapInfo.DifficultyName == "difficulty"); AddAssert("Beatmap has correct difficulty name", () => editorBeatmap.BeatmapInfo.DifficultyName == "difficulty");
} }
} }

View File

@ -0,0 +1,88 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Timing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit.Timing;
namespace osu.Game.Tests.Visual.Editing
{
public class TestSceneLabelledTimeSignature : OsuManualInputManagerTestScene
{
private LabelledTimeSignature timeSignature;
private void createLabelledTimeSignature(TimeSignature initial) => AddStep("create labelled time signature", () =>
{
Child = timeSignature = new LabelledTimeSignature
{
Label = "Time Signature",
RelativeSizeAxes = Axes.None,
Width = 400,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Current = { Value = initial }
};
});
private OsuTextBox numeratorTextBox => timeSignature.ChildrenOfType<OsuTextBox>().Single();
[Test]
public void TestInitialValue()
{
createLabelledTimeSignature(TimeSignature.SimpleTriple);
AddAssert("current is 3/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleTriple));
}
[Test]
public void TestChangeViaCurrent()
{
createLabelledTimeSignature(TimeSignature.SimpleQuadruple);
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddStep("set current to 5/4", () => timeSignature.Current.Value = new TimeSignature(5));
AddAssert("current is 5/4", () => timeSignature.Current.Value.Equals(new TimeSignature(5)));
AddAssert("numerator is 5", () => numeratorTextBox.Current.Value == "5");
AddStep("set current to 3/4", () => timeSignature.Current.Value = TimeSignature.SimpleTriple);
AddAssert("current is 3/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleTriple));
AddAssert("numerator is 3", () => numeratorTextBox.Current.Value == "3");
}
[Test]
public void TestChangeNumerator()
{
createLabelledTimeSignature(TimeSignature.SimpleQuadruple);
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddStep("focus text box", () => InputManager.ChangeFocus(numeratorTextBox));
AddStep("set numerator to 7", () => numeratorTextBox.Current.Value = "7");
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddStep("drop focus", () => InputManager.ChangeFocus(null));
AddAssert("current is 7/4", () => timeSignature.Current.Value.Equals(new TimeSignature(7)));
}
[Test]
public void TestInvalidChangeRollbackOnCommit()
{
createLabelledTimeSignature(TimeSignature.SimpleQuadruple);
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddStep("focus text box", () => InputManager.ChangeFocus(numeratorTextBox));
AddStep("set numerator to 0", () => numeratorTextBox.Current.Value = "0");
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddStep("drop focus", () => InputManager.ChangeFocus(null));
AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple));
AddAssert("numerator is 4", () => numeratorTextBox.Current.Value == "4");
}
}
}

View File

@ -24,8 +24,8 @@ namespace osu.Game.Tests.Visual.Gameplay
Add(new ModNightcore<HitObject>.NightcoreBeatContainer()); Add(new ModNightcore<HitObject>.NightcoreBeatContainer());
AddStep("change signature to quadruple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignatures.SimpleQuadruple)); AddStep("change signature to quadruple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignature.SimpleQuadruple));
AddStep("change signature to triple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignatures.SimpleTriple)); AddStep("change signature to triple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignature.SimpleTriple));
} }
} }
} }

View File

@ -8,6 +8,8 @@ using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Overlays.Login; using osu.Game.Overlays.Login;
using osu.Game.Users.Drawables;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Menus namespace osu.Game.Tests.Visual.Menus
{ {
@ -15,6 +17,7 @@ namespace osu.Game.Tests.Visual.Menus
public class TestSceneLoginPanel : OsuManualInputManagerTestScene public class TestSceneLoginPanel : OsuManualInputManagerTestScene
{ {
private LoginPanel loginPanel; private LoginPanel loginPanel;
private int hideCount;
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
@ -26,6 +29,7 @@ namespace osu.Game.Tests.Visual.Menus
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Width = 0.5f, Width = 0.5f,
RequestHide = () => hideCount++,
}); });
}); });
} }
@ -51,5 +55,22 @@ namespace osu.Game.Tests.Visual.Menus
AddStep("enter password", () => loginPanel.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password"); AddStep("enter password", () => loginPanel.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginPanel.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick()); AddStep("submit", () => loginPanel.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
} }
[Test]
public void TestClickingOnFlagClosesPanel()
{
AddStep("reset hide count", () => hideCount = 0);
AddStep("logout", () => API.Logout());
AddStep("enter password", () => loginPanel.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginPanel.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
AddStep("click on flag", () =>
{
InputManager.MoveMouseTo(loginPanel.ChildrenOfType<UpdateableFlag>().First());
InputManager.Click(MouseButton.Left);
});
AddAssert("hide requested", () => hideCount == 1);
}
} }
} }

View File

@ -45,7 +45,7 @@ namespace osu.Game.Tournament.Components
if (manager == null) if (manager == null)
{ {
AddInternal(manager = new ChannelManager()); AddInternal(manager = new ChannelManager { HighPollRate = { Value = true } });
Channel.BindTo(manager.CurrentChannel); Channel.BindTo(manager.CurrentChannel);
} }

View File

@ -88,7 +88,7 @@ namespace osu.Game.Beatmaps
private static string getFilename(BeatmapInfo beatmapInfo) private static string getFilename(BeatmapInfo beatmapInfo)
{ {
var metadata = beatmapInfo.Metadata; var metadata = beatmapInfo.Metadata;
return $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.DifficultyName}].osu".GetValidArchiveContentFilename(); return $"{metadata.Artist} - {metadata.Title} ({metadata.Author.Username}) [{beatmapInfo.DifficultyName}].osu".GetValidArchiveContentFilename();
} }
/// <summary> /// <summary>

View File

@ -13,7 +13,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// <summary> /// <summary>
/// The time signature at this control point. /// The time signature at this control point.
/// </summary> /// </summary>
public readonly Bindable<TimeSignatures> TimeSignatureBindable = new Bindable<TimeSignatures>(TimeSignatures.SimpleQuadruple) { Default = TimeSignatures.SimpleQuadruple }; public readonly Bindable<TimeSignature> TimeSignatureBindable = new Bindable<TimeSignature>(TimeSignature.SimpleQuadruple);
/// <summary> /// <summary>
/// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing.
@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// <summary> /// <summary>
/// The time signature at this control point. /// The time signature at this control point.
/// </summary> /// </summary>
public TimeSignatures TimeSignature public TimeSignature TimeSignature
{ {
get => TimeSignatureBindable.Value; get => TimeSignatureBindable.Value;
set => TimeSignatureBindable.Value = value; set => TimeSignatureBindable.Value = value;

View File

@ -340,9 +340,9 @@ namespace osu.Game.Beatmaps.Formats
double beatLength = Parsing.ParseDouble(split[1].Trim()); double beatLength = Parsing.ParseDouble(split[1].Trim());
double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1;
TimeSignatures timeSignature = TimeSignatures.SimpleQuadruple; TimeSignature timeSignature = TimeSignature.SimpleQuadruple;
if (split.Length >= 3) if (split.Length >= 3)
timeSignature = split[2][0] == '0' ? TimeSignatures.SimpleQuadruple : (TimeSignatures)Parsing.ParseInt(split[2]); timeSignature = split[2][0] == '0' ? TimeSignature.SimpleQuadruple : new TimeSignature(Parsing.ParseInt(split[2]));
LegacySampleBank sampleSet = defaultSampleBank; LegacySampleBank sampleSet = defaultSampleBank;
if (split.Length >= 4) if (split.Length >= 4)

View File

@ -227,7 +227,7 @@ namespace osu.Game.Beatmaps.Formats
if (effectPoint.OmitFirstBarLine) if (effectPoint.OmitFirstBarLine)
effectFlags |= LegacyEffectFlags.OmitFirstBarLine; effectFlags |= LegacyEffectFlags.OmitFirstBarLine;
writer.Write(FormattableString.Invariant($"{(int)legacyControlPoints.TimingPointAt(time).TimeSignature},")); writer.Write(FormattableString.Invariant($"{legacyControlPoints.TimingPointAt(time).TimeSignature.Numerator},"));
writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},")); writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},"));
writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},")); writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},"));
writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},")); writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},"));

View File

@ -0,0 +1,45 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
namespace osu.Game.Beatmaps.Timing
{
/// <summary>
/// Stores the time signature of a track.
/// For now, the lower numeral can only be 4; support for other denominators can be considered at a later date.
/// </summary>
public class TimeSignature : IEquatable<TimeSignature>
{
/// <summary>
/// The numerator of a signature.
/// </summary>
public int Numerator { get; }
// TODO: support time signatures with a denominator other than 4
// this in particular requires a new beatmap format.
public TimeSignature(int numerator)
{
if (numerator < 1)
throw new ArgumentOutOfRangeException(nameof(numerator), numerator, "The numerator of a time signature must be positive.");
Numerator = numerator;
}
public static TimeSignature SimpleTriple { get; } = new TimeSignature(3);
public static TimeSignature SimpleQuadruple { get; } = new TimeSignature(4);
public override string ToString() => $"{Numerator}/4";
public bool Equals(TimeSignature other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Numerator == other.Numerator;
}
public override int GetHashCode() => Numerator;
}
}

View File

@ -1,11 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.ComponentModel; using System.ComponentModel;
namespace osu.Game.Beatmaps.Timing namespace osu.Game.Beatmaps.Timing
{ {
public enum TimeSignatures [Obsolete("Use osu.Game.Beatmaps.Timing.TimeSignature instead.")]
public enum TimeSignatures // can be removed 20220722
{ {
[Description("4/4")] [Description("4/4")]
SimpleQuadruple = 4, SimpleQuadruple = 4,

View File

@ -5,7 +5,6 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using osu.Framework.Development;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Statistics; using osu.Framework.Statistics;
@ -151,9 +150,6 @@ namespace osu.Game.Database
{ {
Logger.Log($"Creating full EF database backup at {backupFilename}", LoggingTarget.Database); Logger.Log($"Creating full EF database backup at {backupFilename}", LoggingTarget.Database);
if (DebugUtils.IsDebugBuild)
Logger.Log("Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", level: LogLevel.Important);
using (var source = storage.GetStream(DATABASE_NAME)) using (var source = storage.GetStream(DATABASE_NAME))
using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination); source.CopyTo(destination);

View File

@ -1,55 +1,140 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Models; using osu.Game.Models;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
using Realms; using Realms;
#nullable enable #nullable enable
namespace osu.Game.Database namespace osu.Game.Database
{ {
internal class EFToRealmMigrator internal class EFToRealmMigrator : CompositeDrawable
{ {
private readonly DatabaseContextFactory efContextFactory; public Task<bool> MigrationCompleted => migrationCompleted.Task;
private readonly RealmContextFactory realmContextFactory;
private readonly OsuConfigManager config;
private readonly Storage storage;
public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config, Storage storage) private readonly TaskCompletionSource<bool> migrationCompleted = new TaskCompletionSource<bool>();
[Resolved]
private DatabaseContextFactory efContextFactory { get; set; } = null!;
[Resolved]
private RealmContextFactory realmContextFactory { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private readonly OsuSpriteText currentOperationText;
public EFToRealmMigrator()
{ {
this.efContextFactory = efContextFactory; RelativeSizeAxes = Axes.Both;
this.realmContextFactory = realmContextFactory;
this.config = config; InternalChildren = new Drawable[]
this.storage = storage; {
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spacing = new Vector2(10),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Database migration in progress",
Font = OsuFont.Default.With(size: 40)
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "This could take a few minutes depending on the speed of your disk(s).",
Font = OsuFont.Default.With(size: 30)
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Please keep the window open until this completes!",
Font = OsuFont.Default.With(size: 30)
},
new LoadingSpinner(true)
{
State = { Value = Visibility.Visible }
},
currentOperationText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 30)
},
}
},
};
} }
public void Run() protected override void LoadComplete()
{ {
createBackup(); base.LoadComplete();
using (var ef = efContextFactory.Get()) Task.Factory.StartNew(() =>
{ {
migrateSettings(ef); using (var ef = efContextFactory.Get())
migrateSkins(ef); {
migrateBeatmaps(ef); realmContextFactory.Write(realm =>
migrateScores(ef); {
} // Before beginning, ensure realm is in an empty state.
// Migrations which are half-completed could lead to issues if the user tries a second time.
// Note that we only do this for beatmaps and scores since the other migrations are yonks old.
realm.RemoveAll<BeatmapSetInfo>();
realm.RemoveAll<BeatmapInfo>();
realm.RemoveAll<BeatmapMetadata>();
realm.RemoveAll<ScoreInfo>();
});
// Delete the database permanently. migrateSettings(ef);
// Will cause future startups to not attempt migration. migrateSkins(ef);
Logger.Log("Migration successful, deleting EF database", LoggingTarget.Database); migrateBeatmaps(ef);
efContextFactory.ResetDatabase(); migrateScores(ef);
}
// Delete the database permanently.
// Will cause future startups to not attempt migration.
log("Migration successful, deleting EF database");
efContextFactory.ResetDatabase();
if (DebugUtils.IsDebugBuild)
Logger.Log("Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", level: LogLevel.Important);
}, TaskCreationOptions.LongRunning).ContinueWith(t =>
{
migrationCompleted.SetResult(true);
});
}
private void log(string message)
{
Logger.Log(message, LoggingTarget.Database);
Scheduler.AddOnce(m => currentOperationText.Text = m, message);
} }
private void migrateBeatmaps(OsuDbContext ef) private void migrateBeatmaps(OsuDbContext ef)
@ -62,12 +147,12 @@ namespace osu.Game.Database
.Include(s => s.Files).ThenInclude(f => f.FileInfo) .Include(s => s.Files).ThenInclude(f => f.FileInfo)
.Include(s => s.Metadata); .Include(s => s.Metadata);
Logger.Log("Beginning beatmaps migration to realm", LoggingTarget.Database); log("Beginning beatmaps migration to realm");
// previous entries in EF are removed post migration. // previous entries in EF are removed post migration.
if (!existingBeatmapSets.Any()) if (!existingBeatmapSets.Any())
{ {
Logger.Log("No beatmaps found to migrate", LoggingTarget.Database); log("No beatmaps found to migrate");
return; return;
} }
@ -75,89 +160,80 @@ namespace osu.Game.Database
realmContextFactory.Run(realm => realmContextFactory.Run(realm =>
{ {
Logger.Log($"Found {count} beatmaps in EF", LoggingTarget.Database); log($"Found {count} beatmaps in EF");
// only migrate data if the realm database is empty. var transaction = realm.BeginWrite();
// note that this cannot be written as: `realm.All<BeatmapSetInfo>().All(s => s.Protected)`, because realm does not support `.All()`. int written = 0;
if (realm.All<BeatmapSetInfo>().Any(s => !s.Protected))
{
Logger.Log("Skipping migration as realm already has beatmaps loaded", LoggingTarget.Database);
}
else
{
var transaction = realm.BeginWrite();
int written = 0;
try try
{
foreach (var beatmapSet in existingBeatmapSets)
{ {
foreach (var beatmapSet in existingBeatmapSets) if (++written % 1000 == 0)
{ {
if (++written % 1000 == 0) transaction.Commit();
{ transaction = realm.BeginWrite();
transaction.Commit(); log($"Migrated {written}/{count} beatmaps...");
transaction = realm.BeginWrite(); }
Logger.Log($"Migrated {written}/{count} beatmaps...", LoggingTarget.Database);
}
var realmBeatmapSet = new BeatmapSetInfo var realmBeatmapSet = new BeatmapSetInfo
{
OnlineID = beatmapSet.OnlineID ?? -1,
DateAdded = beatmapSet.DateAdded,
Status = beatmapSet.Status,
DeletePending = beatmapSet.DeletePending,
Hash = beatmapSet.Hash,
Protected = beatmapSet.Protected,
};
migrateFiles(beatmapSet, realm, realmBeatmapSet);
foreach (var beatmap in beatmapSet.Beatmaps)
{
var ruleset = realm.Find<RulesetInfo>(beatmap.RulesetInfo.ShortName);
var metadata = getBestMetadata(beatmap.Metadata, beatmapSet.Metadata);
var realmBeatmap = new BeatmapInfo(ruleset, new BeatmapDifficulty(beatmap.BaseDifficulty), metadata)
{ {
OnlineID = beatmapSet.OnlineID ?? -1, DifficultyName = beatmap.DifficultyName,
DateAdded = beatmapSet.DateAdded, Status = beatmap.Status,
Status = beatmapSet.Status, OnlineID = beatmap.OnlineID ?? -1,
DeletePending = beatmapSet.DeletePending, Length = beatmap.Length,
Hash = beatmapSet.Hash, BPM = beatmap.BPM,
Protected = beatmapSet.Protected, Hash = beatmap.Hash,
StarRating = beatmap.StarRating,
MD5Hash = beatmap.MD5Hash,
Hidden = beatmap.Hidden,
AudioLeadIn = beatmap.AudioLeadIn,
StackLeniency = beatmap.StackLeniency,
SpecialStyle = beatmap.SpecialStyle,
LetterboxInBreaks = beatmap.LetterboxInBreaks,
WidescreenStoryboard = beatmap.WidescreenStoryboard,
EpilepsyWarning = beatmap.EpilepsyWarning,
SamplesMatchPlaybackRate = beatmap.SamplesMatchPlaybackRate,
DistanceSpacing = beatmap.DistanceSpacing,
BeatDivisor = beatmap.BeatDivisor,
GridSize = beatmap.GridSize,
TimelineZoom = beatmap.TimelineZoom,
Countdown = beatmap.Countdown,
CountdownOffset = beatmap.CountdownOffset,
MaxCombo = beatmap.MaxCombo,
Bookmarks = beatmap.Bookmarks,
BeatmapSet = realmBeatmapSet,
}; };
migrateFiles(beatmapSet, realm, realmBeatmapSet); realmBeatmapSet.Beatmaps.Add(realmBeatmap);
foreach (var beatmap in beatmapSet.Beatmaps)
{
var ruleset = realm.Find<RulesetInfo>(beatmap.RulesetInfo.ShortName);
var metadata = getBestMetadata(beatmap.Metadata, beatmapSet.Metadata);
var realmBeatmap = new BeatmapInfo(ruleset, new BeatmapDifficulty(beatmap.BaseDifficulty), metadata)
{
DifficultyName = beatmap.DifficultyName,
Status = beatmap.Status,
OnlineID = beatmap.OnlineID ?? -1,
Length = beatmap.Length,
BPM = beatmap.BPM,
Hash = beatmap.Hash,
StarRating = beatmap.StarRating,
MD5Hash = beatmap.MD5Hash,
Hidden = beatmap.Hidden,
AudioLeadIn = beatmap.AudioLeadIn,
StackLeniency = beatmap.StackLeniency,
SpecialStyle = beatmap.SpecialStyle,
LetterboxInBreaks = beatmap.LetterboxInBreaks,
WidescreenStoryboard = beatmap.WidescreenStoryboard,
EpilepsyWarning = beatmap.EpilepsyWarning,
SamplesMatchPlaybackRate = beatmap.SamplesMatchPlaybackRate,
DistanceSpacing = beatmap.DistanceSpacing,
BeatDivisor = beatmap.BeatDivisor,
GridSize = beatmap.GridSize,
TimelineZoom = beatmap.TimelineZoom,
Countdown = beatmap.Countdown,
CountdownOffset = beatmap.CountdownOffset,
MaxCombo = beatmap.MaxCombo,
Bookmarks = beatmap.Bookmarks,
BeatmapSet = realmBeatmapSet,
};
realmBeatmapSet.Beatmaps.Add(realmBeatmap);
}
realm.Add(realmBeatmapSet);
} }
}
finally
{
transaction.Commit();
}
Logger.Log($"Successfully migrated {count} beatmaps to realm", LoggingTarget.Database); realm.Add(realmBeatmapSet);
}
} }
finally
{
transaction.Commit();
}
log($"Successfully migrated {count} beatmaps to realm");
}); });
} }
@ -193,12 +269,12 @@ namespace osu.Game.Database
.Include(s => s.Files) .Include(s => s.Files)
.ThenInclude(f => f.FileInfo); .ThenInclude(f => f.FileInfo);
Logger.Log("Beginning scores migration to realm", LoggingTarget.Database); log("Beginning scores migration to realm");
// previous entries in EF are removed post migration. // previous entries in EF are removed post migration.
if (!existingScores.Any()) if (!existingScores.Any())
{ {
Logger.Log("No scores found to migrate", LoggingTarget.Database); log("No scores found to migrate");
return; return;
} }
@ -206,72 +282,64 @@ namespace osu.Game.Database
realmContextFactory.Run(realm => realmContextFactory.Run(realm =>
{ {
Logger.Log($"Found {count} scores in EF", LoggingTarget.Database); log($"Found {count} scores in EF");
// only migrate data if the realm database is empty. var transaction = realm.BeginWrite();
if (realm.All<ScoreInfo>().Any()) int written = 0;
{
Logger.Log("Skipping migration as realm already has scores loaded", LoggingTarget.Database);
}
else
{
var transaction = realm.BeginWrite();
int written = 0;
try try
{
foreach (var score in existingScores)
{ {
foreach (var score in existingScores) if (++written % 1000 == 0)
{ {
if (++written % 1000 == 0) transaction.Commit();
{ transaction = realm.BeginWrite();
transaction.Commit(); log($"Migrated {written}/{count} scores...");
transaction = realm.BeginWrite();
Logger.Log($"Migrated {written}/{count} scores...", LoggingTarget.Database);
}
var beatmap = realm.All<BeatmapInfo>().First(b => b.Hash == score.BeatmapInfo.Hash);
var ruleset = realm.Find<RulesetInfo>(score.Ruleset.ShortName);
var user = new RealmUser
{
OnlineID = score.User.OnlineID,
Username = score.User.Username
};
var realmScore = new ScoreInfo(beatmap, ruleset, user)
{
Hash = score.Hash,
DeletePending = score.DeletePending,
OnlineID = score.OnlineID ?? -1,
ModsJson = score.ModsJson,
StatisticsJson = score.StatisticsJson,
TotalScore = score.TotalScore,
MaxCombo = score.MaxCombo,
Accuracy = score.Accuracy,
HasReplay = ((IScoreInfo)score).HasReplay,
Date = score.Date,
PP = score.PP,
Rank = score.Rank,
HitEvents = score.HitEvents,
Passed = score.Passed,
Combo = score.Combo,
Position = score.Position,
Statistics = score.Statistics,
Mods = score.Mods,
APIMods = score.APIMods,
};
migrateFiles(score, realm, realmScore);
realm.Add(realmScore);
} }
}
finally
{
transaction.Commit();
}
Logger.Log($"Successfully migrated {count} scores to realm", LoggingTarget.Database); var beatmap = realm.All<BeatmapInfo>().First(b => b.Hash == score.BeatmapInfo.Hash);
var ruleset = realm.Find<RulesetInfo>(score.Ruleset.ShortName);
var user = new RealmUser
{
OnlineID = score.User.OnlineID,
Username = score.User.Username
};
var realmScore = new ScoreInfo(beatmap, ruleset, user)
{
Hash = score.Hash,
DeletePending = score.DeletePending,
OnlineID = score.OnlineID ?? -1,
ModsJson = score.ModsJson,
StatisticsJson = score.StatisticsJson,
TotalScore = score.TotalScore,
MaxCombo = score.MaxCombo,
Accuracy = score.Accuracy,
HasReplay = ((IScoreInfo)score).HasReplay,
Date = score.Date,
PP = score.PP,
Rank = score.Rank,
HitEvents = score.HitEvents,
Passed = score.Passed,
Combo = score.Combo,
Position = score.Position,
Statistics = score.Statistics,
Mods = score.Mods,
APIMods = score.APIMods,
};
migrateFiles(score, realm, realmScore);
realm.Add(realmScore);
}
} }
finally
{
transaction.Commit();
}
log($"Successfully migrated {count} scores to realm");
}); });
} }
@ -309,7 +377,7 @@ namespace osu.Game.Database
// note that this cannot be written as: `realm.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`. // note that this cannot be written as: `realm.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`.
if (!realm.All<SkinInfo>().Any(s => !s.Protected)) if (!realm.All<SkinInfo>().Any(s => !s.Protected))
{ {
Logger.Log($"Migrating {existingSkins.Count} skins", LoggingTarget.Database); log($"Migrating {existingSkins.Count} skins");
foreach (var skin in existingSkins) foreach (var skin in existingSkins)
{ {
@ -358,7 +426,7 @@ namespace osu.Game.Database
if (!existingSettings.Any()) if (!existingSettings.Any())
return; return;
Logger.Log("Beginning settings migration to realm", LoggingTarget.Database); log("Beginning settings migration to realm");
realmContextFactory.Run(realm => realmContextFactory.Run(realm =>
{ {
@ -367,7 +435,7 @@ namespace osu.Game.Database
// only migrate data if the realm database is empty. // only migrate data if the realm database is empty.
if (!realm.All<RealmRulesetSetting>().Any()) if (!realm.All<RealmRulesetSetting>().Any())
{ {
Logger.Log($"Migrating {existingSettings.Count} settings", LoggingTarget.Database); log($"Migrating {existingSettings.Count} settings");
foreach (var dkb in existingSettings) foreach (var dkb in existingSettings)
{ {
@ -396,17 +464,5 @@ namespace osu.Game.Database
private string? getRulesetShortNameFromLegacyID(long rulesetId) => private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
private void createBackup()
{
string migration = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
efContextFactory.CreateBackup($"client.{migration}.db");
realmContextFactory.CreateBackup($"client.{migration}.realm");
using (var source = storage.GetStream("collection.db"))
using (var destination = storage.GetStream($"collection.{migration}.db", FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
}
} }
} }

View File

@ -0,0 +1,46 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using Realms;
using Realms.Schema;
#nullable enable
namespace osu.Game.Database
{
public class EmptyRealmSet<T> : IRealmCollection<T>
{
private IList<T> emptySet => Array.Empty<T>();
public IEnumerator<T> GetEnumerator() => emptySet.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => emptySet.GetEnumerator();
public int Count => emptySet.Count;
public T this[int index] => emptySet[index];
public int IndexOf(object item) => emptySet.IndexOf((T)item);
public bool Contains(object item) => emptySet.Contains((T)item);
public event NotifyCollectionChangedEventHandler? CollectionChanged
{
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
public event PropertyChangedEventHandler? PropertyChanged
{
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
public IRealmCollection<T> Freeze() => throw new NotImplementedException();
public IDisposable SubscribeForNotifications(NotificationCallbackDelegate<T> callback) => throw new NotImplementedException();
public bool IsValid => throw new NotImplementedException();
public Realm Realm => throw new NotImplementedException();
public ObjectSchema ObjectSchema => throw new NotImplementedException();
public bool IsFrozen => throw new NotImplementedException();
}
}

View File

@ -63,6 +63,24 @@ namespace osu.Game.Database
private readonly ThreadLocal<bool> currentThreadCanCreateContexts = new ThreadLocal<bool>(); private readonly ThreadLocal<bool> currentThreadCanCreateContexts = new ThreadLocal<bool>();
/// <summary>
/// Holds a map of functions registered via <see cref="RegisterCustomSubscription"/> and <see cref="RegisterForNotifications{T}"/> and a coinciding action which when triggered,
/// will unregister the subscription from realm.
///
/// Put another way, the key is an action which registers the subscription with realm. The returned <see cref="IDisposable"/> from the action is stored as the value and only
/// used internally.
///
/// Entries in this dictionary are only removed when a consumer signals that the subscription should be permanently ceased (via their own <see cref="IDisposable"/>).
/// </summary>
private readonly Dictionary<Func<Realm, IDisposable?>, IDisposable?> customSubscriptionsResetMap = new Dictionary<Func<Realm, IDisposable?>, IDisposable?>();
/// <summary>
/// Holds a map of functions registered via <see cref="RegisterForNotifications{T}"/> and a coinciding action which when triggered,
/// fires a change set event with an empty collection. This is used to inform subscribers when a realm context goes away, and ensure they don't use invalidated
/// managed realm objects from a previous firing.
/// </summary>
private readonly Dictionary<Func<Realm, IDisposable?>, Action> notificationsResetMap = new Dictionary<Func<Realm, IDisposable?>, Action>();
private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>(@"Realm", @"Contexts (Created)"); private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>(@"Realm", @"Contexts (Created)");
private readonly object contextLock = new object(); private readonly object contextLock = new object();
@ -84,7 +102,7 @@ namespace osu.Game.Database
Logger.Log(@$"Opened realm ""{context.Config.DatabasePath}"" at version {context.Config.SchemaVersion}"); Logger.Log(@$"Opened realm ""{context.Config.DatabasePath}"" at version {context.Config.SchemaVersion}");
// Resubscribe any subscriptions // Resubscribe any subscriptions
foreach (var action in subscriptionActions.Keys) foreach (var action in customSubscriptionsResetMap.Keys)
registerSubscription(action); registerSubscription(action);
} }
@ -233,27 +251,75 @@ namespace osu.Game.Database
} }
} }
private readonly Dictionary<Func<Realm, IDisposable?>, IDisposable?> subscriptionActions = new Dictionary<Func<Realm, IDisposable?>, IDisposable?>(); /// <summary>
/// Subscribe to a realm collection and begin watching for asynchronous changes.
/// </summary>
/// <remarks>
/// This adds osu! specific thread and managed state safety checks on top of <see cref="IRealmCollection{T}.SubscribeForNotifications"/>.
///
/// In addition to the documented realm behaviour, we have the additional requirement of handling subscriptions over potential context loss.
/// When this happens, callback events will be automatically fired:
/// - On context loss, a callback with an empty collection and <c>null</c> <see cref="ChangeSet"/> will be invoked.
/// - On context revival, a standard initial realm callback will arrive, with <c>null</c> <see cref="ChangeSet"/> and an up-to-date collection.
/// </remarks>
/// <param name="query">The <see cref="IQueryable{T}"/> to observe for changes.</param>
/// <typeparam name="T">Type of the elements in the list.</typeparam>
/// <param name="callback">The callback to be invoked with the updated <see cref="IRealmCollection{T}"/>.</param>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
/// </returns>
/// <seealso cref="IRealmCollection{T}.SubscribeForNotifications"/>
public IDisposable RegisterForNotifications<T>(Func<Realm, IQueryable<T>> query, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
lock (contextLock)
{
Func<Realm, IDisposable?> action = realm => query(realm).QueryAsyncWithNotifications(callback);
// Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing.
notificationsResetMap.Add(action, () => callback(new EmptyRealmSet<T>(), null, null));
return RegisterCustomSubscription(action);
}
}
/// <summary> /// <summary>
/// Run work on realm that will be run every time the update thread realm context gets recycled. /// Run work on realm that will be run every time the update thread realm context gets recycled.
/// </summary> /// </summary>
/// <param name="action">The work to run. Return value should be an <see cref="IDisposable"/> from QueryAsyncWithNotifications, or an <see cref="InvokeOnDisposal"/> to clean up any bindings.</param> /// <param name="action">The work to run. Return value should be an <see cref="IDisposable"/> from QueryAsyncWithNotifications, or an <see cref="InvokeOnDisposal"/> to clean up any bindings.</param>
/// <returns>An <see cref="IDisposable"/> which should be disposed to unsubscribe any inner subscription.</returns> /// <returns>An <see cref="IDisposable"/> which should be disposed to unsubscribe any inner subscription.</returns>
public IDisposable Register(Func<Realm, IDisposable?> action) public IDisposable RegisterCustomSubscription(Func<Realm, IDisposable?> action)
{ {
if (!ThreadSafety.IsUpdateThread) if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"{nameof(Register)} must be called from the update thread."); throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
var syncContext = SynchronizationContext.Current;
registerSubscription(action); registerSubscription(action);
// This token is returned to the consumer.
// When disposed, it will cause the registration to be permanently ceased (unsubscribed with realm and unregistered by this class).
return new InvokeOnDisposal(() => return new InvokeOnDisposal(() =>
{ {
// TODO: this likely needs to be run on the update thread. if (ThreadSafety.IsUpdateThread)
if (subscriptionActions.TryGetValue(action, out var unsubscriptionAction)) unsubscribe();
else
syncContext.Post(_ => unsubscribe(), null);
void unsubscribe()
{ {
unsubscriptionAction?.Dispose(); lock (contextLock)
subscriptionActions.Remove(action); {
if (customSubscriptionsResetMap.TryGetValue(action, out var unsubscriptionAction))
{
unsubscriptionAction?.Dispose();
customSubscriptionsResetMap.Remove(action);
notificationsResetMap.Remove(action);
}
}
} }
}); });
} }
@ -262,17 +328,40 @@ namespace osu.Game.Database
{ {
Debug.Assert(ThreadSafety.IsUpdateThread); Debug.Assert(ThreadSafety.IsUpdateThread);
// Get context outside of flag update to ensure beyond doubt this can't be cyclic.
var realm = Context;
lock (contextLock) lock (contextLock)
{ {
// Retrieve context outside of flag update to ensure that the context is constructed,
// as attempting to access it inside the subscription if it's not constructed would lead to
// cyclic invocations of the subscription callback.
var realm = Context;
Debug.Assert(!customSubscriptionsResetMap.TryGetValue(action, out var found) || found == null);
current_thread_subscriptions_allowed.Value = true; current_thread_subscriptions_allowed.Value = true;
subscriptionActions[action] = action(realm); customSubscriptionsResetMap[action] = action(realm);
current_thread_subscriptions_allowed.Value = false; current_thread_subscriptions_allowed.Value = false;
} }
} }
/// <summary>
/// Unregister all subscriptions when the realm context is to be recycled.
/// Subscriptions will still remain and will be re-subscribed when the realm context returns.
/// </summary>
private void unregisterAllSubscriptions()
{
lock (contextLock)
{
foreach (var action in notificationsResetMap.Values)
action();
foreach (var action in customSubscriptionsResetMap)
{
action.Value?.Dispose();
customSubscriptionsResetMap[action.Key] = null;
}
}
}
private Realm createContext() private Realm createContext()
{ {
if (isDisposed) if (isDisposed)
@ -505,7 +594,7 @@ namespace osu.Game.Database
if (isDisposed) if (isDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory)); throw new ObjectDisposedException(nameof(RealmContextFactory));
SynchronizationContext syncContext; SynchronizationContext? syncContext = null;
try try
{ {
@ -513,10 +602,21 @@ namespace osu.Game.Database
lock (contextLock) lock (contextLock)
{ {
if (!ThreadSafety.IsUpdateThread && context != null) if (context == null)
throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread."); {
// null context means the update thread has not yet retrieved its context.
// we don't need to worry about reviving the update context in this case, so don't bother with the SynchronizationContext.
Debug.Assert(!ThreadSafety.IsUpdateThread);
}
else
{
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread.");
syncContext = SynchronizationContext.Current; syncContext = SynchronizationContext.Current;
}
unregisterAllSubscriptions();
Logger.Log(@"Blocking realm operations.", LoggingTarget.Database); Logger.Log(@"Blocking realm operations.", LoggingTarget.Database);

View File

@ -272,7 +272,7 @@ namespace osu.Game.Database
where T : RealmObjectBase where T : RealmObjectBase
{ {
if (!RealmContextFactory.CurrentThreadSubscriptionsAllowed) if (!RealmContextFactory.CurrentThreadSubscriptionsAllowed)
throw new InvalidOperationException($"Make sure to call {nameof(RealmContextFactory)}.{nameof(RealmContextFactory.Register)}"); throw new InvalidOperationException($"Make sure to call {nameof(RealmContextFactory)}.{nameof(RealmContextFactory.RegisterForNotifications)}");
return collection.SubscribeForNotifications(callback); return collection.SubscribeForNotifications(callback);
} }

View File

@ -46,25 +46,21 @@ namespace osu.Game.Input.Bindings
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided."); throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
} }
private IQueryable<RealmKeyBinding> realmKeyBindings private IQueryable<RealmKeyBinding> queryRealmKeyBindings()
{ {
get string rulesetName = ruleset?.ShortName;
{ return realmFactory.Context.All<RealmKeyBinding>()
string rulesetName = ruleset?.ShortName; .Where(b => b.RulesetName == rulesetName && b.Variant == variant);
return realmFactory.Context.All<RealmKeyBinding>()
.Where(b => b.RulesetName == rulesetName && b.Variant == variant);
}
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
realmSubscription = realmFactory.Register(realm => realmKeyBindings realmSubscription = realmFactory.RegisterForNotifications(realm => queryRealmKeyBindings(), (sender, changes, error) =>
.QueryAsyncWithNotifications((sender, changes, error) => {
{ // The first fire of this is a bit redundant as this is being called in base.LoadComplete,
// The first fire of this is a bit redundant as this is being called in base.LoadComplete, // but this is safest in case the subscription is restored after a context recycle.
// but this is safest in case the subscription is restored after a context recycle. ReloadMappings();
ReloadMappings(); });
}));
base.LoadComplete(); base.LoadComplete();
} }
@ -80,11 +76,11 @@ namespace osu.Game.Input.Bindings
{ {
var defaults = DefaultKeyBindings.ToList(); var defaults = DefaultKeyBindings.ToList();
List<RealmKeyBinding> newBindings = realmKeyBindings.Detach() List<RealmKeyBinding> newBindings = queryRealmKeyBindings().Detach()
// this ordering is important to ensure that we read entries from the database in the order // 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 // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order. // have been eaten by the music controller due to query order.
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList(); .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
// In the case no bindings were found in the database, presume this usage is for a non-databased ruleset. // In the case no bindings were found in the database, presume this usage is for a non-databased ruleset.
// This actually should never be required and can be removed if it is ever deemed to cause a problem. // This actually should never be required and can be removed if it is ever deemed to cause a problem.

View File

@ -42,7 +42,7 @@ namespace osu.Game.Online
// Used to interact with manager classes that don't support interface types. Will eventually be replaced. // Used to interact with manager classes that don't support interface types. Will eventually be replaced.
var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID }; var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID };
realmSubscription = realmContextFactory.Register(realm => realm.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending).QueryAsyncWithNotifications((items, changes, ___) => realmSubscription = realmContextFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, changes, ___) =>
{ {
if (items.Any()) if (items.Any())
Schedule(() => UpdateState(DownloadState.LocallyAvailable)); Schedule(() => UpdateState(DownloadState.LocallyAvailable));
@ -54,7 +54,7 @@ namespace osu.Game.Online
attachDownload(Downloader.GetExistingDownload(beatmapSetInfo)); attachDownload(Downloader.GetExistingDownload(beatmapSetInfo));
}); });
} }
})); });
} }
private void downloadBegan(ArchiveDownloadRequest<IBeatmapSetInfo> request) => Schedule(() => private void downloadBegan(ArchiveDownloadRequest<IBeatmapSetInfo> request) => Schedule(() =>

View File

@ -78,13 +78,13 @@ namespace osu.Game.Online.Rooms
// handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow). // handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow).
realmSubscription?.Dispose(); realmSubscription?.Dispose();
realmSubscription = realmContextFactory.Register(realm => filteredBeatmaps().QueryAsyncWithNotifications((items, changes, ___) => realmSubscription = realmContextFactory.RegisterForNotifications(realm => filteredBeatmaps(), (items, changes, ___) =>
{ {
if (changes == null) if (changes == null)
return; return;
Scheduler.AddOnce(updateAvailability); Scheduler.AddOnce(updateAvailability);
})); });
}, true); }, true);
} }

View File

@ -47,7 +47,7 @@ namespace osu.Game.Online
Downloader.DownloadBegan += downloadBegan; Downloader.DownloadBegan += downloadBegan;
Downloader.DownloadFailed += downloadFailed; Downloader.DownloadFailed += downloadFailed;
realmSubscription = realmContextFactory.Register(realm => realm.All<ScoreInfo>().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || s.Hash == TrackedItem.Hash) && !s.DeletePending).QueryAsyncWithNotifications((items, changes, ___) => realmSubscription = realmContextFactory.RegisterForNotifications(realm => realm.All<ScoreInfo>().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || s.Hash == TrackedItem.Hash) && !s.DeletePending), (items, changes, ___) =>
{ {
if (items.Any()) if (items.Any())
Schedule(() => UpdateState(DownloadState.LocallyAvailable)); Schedule(() => UpdateState(DownloadState.LocallyAvailable));
@ -59,7 +59,7 @@ namespace osu.Game.Online
attachDownload(Downloader.GetExistingDownload(scoreInfo)); attachDownload(Downloader.GetExistingDownload(scoreInfo));
}); });
} }
})); });
} }
private void downloadBegan(ArchiveDownloadRequest<IScoreInfo> request) => Schedule(() => private void downloadBegan(ArchiveDownloadRequest<IScoreInfo> request) => Schedule(() =>

View File

@ -161,6 +161,11 @@ namespace osu.Game
private readonly BindableNumber<double> globalTrackVolumeAdjust = new BindableNumber<double>(global_track_volume_adjust); private readonly BindableNumber<double> globalTrackVolumeAdjust = new BindableNumber<double>(global_track_volume_adjust);
/// <summary>
/// A legacy EF context factory if migration has not been performed to realm yet.
/// </summary>
protected DatabaseContextFactory EFContextFactory { get; private set; }
public OsuGameBase() public OsuGameBase()
{ {
UseDevelopmentServer = DebugUtils.IsDebugBuild; UseDevelopmentServer = DebugUtils.IsDebugBuild;
@ -184,18 +189,28 @@ namespace osu.Game
Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly));
DatabaseContextFactory efContextFactory = Storage.Exists(DatabaseContextFactory.DATABASE_NAME) if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME))
? new DatabaseContextFactory(Storage) dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage));
: null;
dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", efContextFactory)); dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", EFContextFactory));
dependencies.Cache(RulesetStore = new RulesetStore(realmFactory, Storage)); dependencies.Cache(RulesetStore = new RulesetStore(realmFactory, Storage));
dependencies.CacheAs<IRulesetStore>(RulesetStore); dependencies.CacheAs<IRulesetStore>(RulesetStore);
// A non-null context factory means there's still content to migrate. // Backup is taken here rather than in EFToRealmMigrator to avoid recycling realm contexts
if (efContextFactory != null) // after initial usages below. It can be moved once a direction is established for handling re-subscription.
new EFToRealmMigrator(efContextFactory, realmFactory, LocalConfig, Storage).Run(); // See https://github.com/ppy/osu/pull/16547 for more discussion.
if (EFContextFactory != null)
{
string migration = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
EFContextFactory.CreateBackup($"client.{migration}.db");
realmFactory.CreateBackup($"client.{migration}.realm");
using (var source = Storage.GetStream("collection.db"))
using (var destination = Storage.GetStream($"collection.{migration}.db", FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
}
dependencies.CacheAs(Storage); dependencies.CacheAs(Storage);

View File

@ -71,14 +71,15 @@ namespace osu.Game.Overlays
mods.BindValueChanged(_ => ResetTrackAdjustments(), true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true);
} }
private IQueryable<BeatmapSetInfo> availableBeatmaps => realmFactory.Context private IQueryable<BeatmapSetInfo> queryRealmBeatmapSets() =>
.All<BeatmapSetInfo>() realmFactory.Context
.Where(s => !s.DeletePending); .All<BeatmapSetInfo>()
.Where(s => !s.DeletePending);
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
beatmapSubscription = realmFactory.Register(realm => availableBeatmaps.QueryAsyncWithNotifications(beatmapsChanged)); beatmapSubscription = realmFactory.RegisterForNotifications(realm => queryRealmBeatmapSets(), beatmapsChanged);
} }
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error) private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)

View File

@ -1,9 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Localisation; using osu.Game.Localisation;
@ -17,6 +21,9 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, RealmContextFactory realmFactory) private void load(GameHost host, RealmContextFactory realmFactory)
{ {
SettingsButton blockAction;
SettingsButton unblockAction;
Children = new Drawable[] Children = new Drawable[]
{ {
new SettingsButton new SettingsButton
@ -35,6 +42,51 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings
} }
} }
}, },
blockAction = new SettingsButton
{
Text = "Block realm",
},
unblockAction = new SettingsButton
{
Text = "Unblock realm",
},
};
blockAction.Action = () =>
{
try
{
var token = realmFactory.BlockAllOperations();
blockAction.Enabled.Value = false;
// As a safety measure, unblock after 10 seconds.
// This is to handle the case where a dev may block, but then something on the update thread
// accesses realm and blocks for eternity.
Task.Factory.StartNew(() =>
{
Thread.Sleep(10000);
unblock();
});
unblockAction.Action = unblock;
void unblock()
{
token?.Dispose();
token = null;
Scheduler.Add(() =>
{
blockAction.Enabled.Value = true;
unblockAction.Action = null;
});
}
}
catch (Exception e)
{
Logger.Error(e, "Blocking realm failed");
}
}; };
} }
} }

View File

@ -51,7 +51,7 @@ namespace osu.Game.Overlays.Settings.Sections
private IDisposable realmSubscription; private IDisposable realmSubscription;
private IQueryable<SkinInfo> realmSkins => private IQueryable<SkinInfo> queryRealmSkins() =>
realmFactory.Context.All<SkinInfo>() realmFactory.Context.All<SkinInfo>()
.Where(s => !s.DeletePending) .Where(s => !s.DeletePending)
.OrderByDescending(s => s.Protected) // protected skins should be at the top. .OrderByDescending(s => s.Protected) // protected skins should be at the top.
@ -83,13 +83,12 @@ namespace osu.Game.Overlays.Settings.Sections
skinDropdown.Current = dropdownBindable; skinDropdown.Current = dropdownBindable;
realmSubscription = realmFactory.Register(realm => realmSkins realmSubscription = realmFactory.RegisterForNotifications(realm => queryRealmSkins(), (sender, changes, error) =>
.QueryAsyncWithNotifications((sender, changes, error) => {
{ // The first fire of this is a bit redundant due to the call below,
// The first fire of this is a bit redundant due to the call below, // but this is safest in case the subscription is restored after a context recycle.
// but this is safest in case the subscription is restored after a context recycle. updateItems();
updateItems(); });
}));
updateItems(); updateItems();
@ -129,9 +128,9 @@ namespace osu.Game.Overlays.Settings.Sections
private void updateItems() private void updateItems()
{ {
int protectedCount = realmSkins.Count(s => s.Protected); int protectedCount = queryRealmSkins().Count(s => s.Protected);
skinItems = realmSkins.ToLive(realmFactory); skinItems = queryRealmSkins().ToLive(realmFactory);
skinItems.Insert(protectedCount, random_skin_info); skinItems.Insert(protectedCount, random_skin_info);

View File

@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mods
if (!IsBeatSyncedWithTrack) return; if (!IsBeatSyncedWithTrack) return;
int timeSignature = (int)timingPoint.TimeSignature; int timeSignature = timingPoint.TimeSignature.Numerator;
// play metronome from one measure before the first object. // play metronome from one measure before the first object.
if (BeatSyncClock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature) if (BeatSyncClock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature)

View File

@ -85,7 +85,7 @@ namespace osu.Game.Rulesets.Mods
{ {
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
int beatsPerBar = (int)timingPoint.TimeSignature; int beatsPerBar = timingPoint.TimeSignature.Numerator;
int segmentLength = beatsPerBar * Divisor * bars_per_segment; int segmentLength = beatsPerBar * Divisor * bars_per_segment;
if (!IsBeatSyncedWithTrack) if (!IsBeatSyncedWithTrack)
@ -102,14 +102,14 @@ namespace osu.Game.Rulesets.Mods
playBeatFor(beatIndex % segmentLength, timingPoint.TimeSignature); playBeatFor(beatIndex % segmentLength, timingPoint.TimeSignature);
} }
private void playBeatFor(int beatIndex, TimeSignatures signature) private void playBeatFor(int beatIndex, TimeSignature signature)
{ {
if (beatIndex == 0) if (beatIndex == 0)
finishSample?.Play(); finishSample?.Play();
switch (signature) switch (signature.Numerator)
{ {
case TimeSignatures.SimpleTriple: case 3:
switch (beatIndex % 6) switch (beatIndex % 6)
{ {
case 0: case 0:
@ -127,7 +127,7 @@ namespace osu.Game.Rulesets.Mods
break; break;
case TimeSignatures.SimpleQuadruple: case 4:
switch (beatIndex % 4) switch (beatIndex % 4)
{ {
case 0: case 0:

View File

@ -41,9 +41,9 @@ namespace osu.Game.Rulesets.Objects
int currentBeat = 0; int currentBeat = 0;
// Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object
double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * (int)currentTimingPoint.TimeSignature; double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator;
double barLength = currentTimingPoint.BeatLength * (int)currentTimingPoint.TimeSignature; double barLength = currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator;
for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++)
{ {
@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Objects
BarLines.Add(new TBarLine BarLines.Add(new TBarLine
{ {
StartTime = t, StartTime = t,
Major = currentBeat % (int)currentTimingPoint.TimeSignature == 0 Major = currentBeat % currentTimingPoint.TimeSignature.Numerator == 0
}); });
} }
} }

View File

@ -125,7 +125,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (beat == 0 && i == 0) if (beat == 0 && i == 0)
nextMinTick = float.MinValue; nextMinTick = float.MinValue;
int indexInBar = beat % ((int)point.TimeSignature * beatDivisor.Value); int indexInBar = beat % (point.TimeSignature.Numerator * beatDivisor.Value);
int divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value); int divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value);
var colour = BindableBeatDivisor.GetColourFor(divisor, colours); var colour = BindableBeatDivisor.GetColourFor(divisor, colours);

View File

@ -0,0 +1,97 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps.Timing;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
namespace osu.Game.Screens.Edit.Timing
{
public class LabelledTimeSignature : LabelledComponent<LabelledTimeSignature.TimeSignatureBox, TimeSignature>
{
public LabelledTimeSignature()
: base(false)
{
}
protected override TimeSignatureBox CreateComponent() => new TimeSignatureBox();
public class TimeSignatureBox : CompositeDrawable, IHasCurrentValue<TimeSignature>
{
private readonly BindableWithCurrent<TimeSignature> current = new BindableWithCurrent<TimeSignature>(TimeSignature.SimpleQuadruple);
public Bindable<TimeSignature> Current
{
get => current.Current;
set => current.Current = value;
}
private OsuNumberBox numeratorBox;
[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
numeratorBox = new OsuNumberBox
{
Width = 40,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
CornerRadius = CORNER_RADIUS,
CommitOnFocusLost = true
},
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Margin = new MarginPadding
{
Left = 5,
Right = CONTENT_PADDING_HORIZONTAL
},
Text = "/ 4",
Font = OsuFont.Default.With(size: 20)
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(_ => updateFromCurrent(), true);
numeratorBox.OnCommit += (_, __) => updateFromNumeratorBox();
}
private void updateFromCurrent()
{
numeratorBox.Current.Value = Current.Value.Numerator.ToString();
}
private void updateFromNumeratorBox()
{
if (int.TryParse(numeratorBox.Current.Value, out int numerator) && numerator > 0)
Current.Value = new TimeSignature(numerator);
else
{
// trigger `Current` change to restore the numerator box's text to a valid value.
Current.TriggerChange();
}
}
}
}
}

View File

@ -13,7 +13,7 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes
public class TimingRowAttribute : RowAttribute public class TimingRowAttribute : RowAttribute
{ {
private readonly BindableNumber<double> beatLength; private readonly BindableNumber<double> beatLength;
private readonly Bindable<TimeSignatures> timeSignature; private readonly Bindable<TimeSignature> timeSignature;
private OsuSpriteText text; private OsuSpriteText text;
public TimingRowAttribute(TimingControlPoint timing) public TimingRowAttribute(TimingControlPoint timing)

View File

@ -6,7 +6,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
@ -15,7 +14,7 @@ namespace osu.Game.Screens.Edit.Timing
internal class TimingSection : Section<TimingControlPoint> internal class TimingSection : Section<TimingControlPoint>
{ {
private SettingsSlider<double> bpmSlider; private SettingsSlider<double> bpmSlider;
private SettingsEnumDropdown<TimeSignatures> timeSignature; private LabelledTimeSignature timeSignature;
private BPMTextBox bpmTextEntry; private BPMTextBox bpmTextEntry;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -25,10 +24,10 @@ namespace osu.Game.Screens.Edit.Timing
{ {
bpmTextEntry = new BPMTextBox(), bpmTextEntry = new BPMTextBox(),
bpmSlider = new BPMSlider(), bpmSlider = new BPMSlider(),
timeSignature = new SettingsEnumDropdown<TimeSignatures> timeSignature = new LabelledTimeSignature
{ {
LabelText = "Time Signature" Label = "Time Signature"
}, }
}); });
} }

View File

@ -12,6 +12,7 @@ using osu.Game.Screens.Menu;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using IntroSequence = osu.Game.Configuration.IntroSequence; using IntroSequence = osu.Game.Configuration.IntroSequence;
@ -63,12 +64,32 @@ namespace osu.Game.Screens
protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler(); protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler();
[Resolved(canBeNull: true)]
private DatabaseContextFactory efContextFactory { get; set; }
private EFToRealmMigrator realmMigrator;
public override void OnEntering(IScreen last) public override void OnEntering(IScreen last)
{ {
base.OnEntering(last); base.OnEntering(last);
LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal); LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal);
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
// A non-null context factory means there's still content to migrate.
if (efContextFactory != null)
{
LoadComponentAsync(realmMigrator = new EFToRealmMigrator(), AddInternal);
realmMigrator.MigrationCompleted.ContinueWith(_ => Schedule(() =>
{
// Delay initial screen loading to ensure that the migration is in a complete and sane state
// before the intro screen may import the game intro beatmap.
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
}));
}
else
{
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
}
LoadComponentAsync(spinner = new LoadingSpinner(true, true) LoadComponentAsync(spinner = new LoadingSpinner(true, true)
{ {
@ -86,7 +107,7 @@ namespace osu.Game.Screens
private void checkIfLoaded() private void checkIfLoaded()
{ {
if (loadableScreen.LoadState != LoadState.Ready || !precompiler.FinishedCompiling) if (loadableScreen?.LoadState != LoadState.Ready || !precompiler.FinishedCompiling)
{ {
Schedule(checkIfLoaded); Schedule(checkIfLoaded);
return; return;

View File

@ -21,6 +21,7 @@ using osu.Game.Overlays;
using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Backgrounds;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using Realms;
namespace osu.Game.Screens.Menu namespace osu.Game.Screens.Menu
{ {
@ -93,28 +94,27 @@ namespace osu.Game.Screens.Menu
MenuMusic = config.GetBindable<bool>(OsuSetting.MenuMusic); MenuMusic = config.GetBindable<bool>(OsuSetting.MenuMusic);
seeya = audio.Samples.Get(SeeyaSampleName); seeya = audio.Samples.Get(SeeyaSampleName);
ILive<BeatmapSetInfo> setInfo = null;
// if the user has requested not to play theme music, we should attempt to find a random beatmap from their collection. // if the user has requested not to play theme music, we should attempt to find a random beatmap from their collection.
if (!MenuMusic.Value) if (!MenuMusic.Value)
{ {
var sets = beatmaps.GetAllUsableBeatmapSets(); realmContextFactory.Run(realm =>
if (sets.Count > 0)
{ {
setInfo = beatmaps.QueryBeatmapSet(s => s.ID == sets[RNG.Next(0, sets.Count - 1)].ID); var usableBeatmapSets = realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected).AsRealmCollection();
setInfo?.PerformRead(s =>
{
if (s.Beatmaps.Count == 0)
return;
initialBeatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps[0]); int setCount = usableBeatmapSets.Count;
});
} if (setCount > 0)
{
var found = usableBeatmapSets[RNG.Next(0, setCount - 1)].Beatmaps.FirstOrDefault();
if (found != null)
initialBeatmap = beatmaps.GetWorkingBeatmap(found);
}
});
} }
// we generally want a song to be playing on startup, so use the intro music even if a user has specified not to if no other track is available. // we generally want a song to be playing on startup, so use the intro music even if a user has specified not to if no other track is available.
if (setInfo == null) if (initialBeatmap == null)
{ {
if (!loadThemedIntro()) if (!loadThemedIntro())
{ {
@ -130,7 +130,7 @@ namespace osu.Game.Screens.Menu
bool loadThemedIntro() bool loadThemedIntro()
{ {
setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); var setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash);
if (setInfo == null) if (setInfo == null)
return false; return false;

View File

@ -94,9 +94,9 @@ namespace osu.Game.Screens.Menu
if (beatIndex < 0) if (beatIndex < 0)
return; return;
if (effectPoint.KiaiMode ? beatIndex % 2 == 0 : beatIndex % (int)timingPoint.TimeSignature == 0) if (effectPoint.KiaiMode ? beatIndex % 2 == 0 : beatIndex % timingPoint.TimeSignature.Numerator == 0)
flash(leftBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes); flash(leftBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes);
if (effectPoint.KiaiMode ? beatIndex % 2 == 1 : beatIndex % (int)timingPoint.TimeSignature == 0) if (effectPoint.KiaiMode ? beatIndex % 2 == 1 : beatIndex % timingPoint.TimeSignature.Numerator == 0)
flash(rightBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes); flash(rightBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes);
} }

View File

@ -282,7 +282,7 @@ namespace osu.Game.Screens.Menu
{ {
this.Delay(early_activation).Schedule(() => this.Delay(early_activation).Schedule(() =>
{ {
if (beatIndex % (int)timingPoint.TimeSignature == 0) if (beatIndex % timingPoint.TimeSignature.Numerator == 0)
sampleDownbeat.Play(); sampleDownbeat.Play();
else else
sampleBeat.Play(); sampleBeat.Play();

View File

@ -190,13 +190,13 @@ namespace osu.Game.Screens.Select
{ {
base.LoadComplete(); base.LoadComplete();
subscriptionSets = realmFactory.Register(realm => getBeatmapSets(realm).QueryAsyncWithNotifications(beatmapSetsChanged)); subscriptionSets = realmFactory.RegisterForNotifications(getBeatmapSets, beatmapSetsChanged);
subscriptionBeatmaps = realmFactory.Register(realm => realm.All<BeatmapInfo>().Where(b => !b.Hidden).QueryAsyncWithNotifications(beatmapsChanged)); subscriptionBeatmaps = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapInfo>().Where(b => !b.Hidden), beatmapsChanged);
// Can't use main subscriptions because we can't lookup deleted indices. // Can't use main subscriptions because we can't lookup deleted indices.
// https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-1605595. // https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-1605595.
subscriptionDeletedSets = realmFactory.Register(realm => realm.All<BeatmapSetInfo>().Where(s => s.DeletePending && !s.Protected).QueryAsyncWithNotifications(deletedBeatmapSetsChanged)); subscriptionDeletedSets = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapSetInfo>().Where(s => s.DeletePending && !s.Protected), deletedBeatmapSetsChanged);
subscriptionHiddenBeatmaps = realmFactory.Register(realm => realm.All<BeatmapInfo>().Where(b => b.Hidden).QueryAsyncWithNotifications(beatmapsChanged)); subscriptionHiddenBeatmaps = realmFactory.RegisterForNotifications(realm => realm.All<BeatmapInfo>().Where(b => b.Hidden), beatmapsChanged);
} }
private void deletedBeatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error) private void deletedBeatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
@ -274,7 +274,7 @@ namespace osu.Game.Screens.Select
} }
} }
private IRealmCollection<BeatmapSetInfo> getBeatmapSets(Realm realm) => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected).AsRealmCollection(); private IQueryable<BeatmapSetInfo> getBeatmapSets(Realm realm) => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected);
public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) =>
removeBeatmapSet(beatmapSet.ID); removeBeatmapSet(beatmapSet.ID);

View File

@ -48,19 +48,19 @@ namespace osu.Game.Screens.Select.Carousel
ruleset.BindValueChanged(_ => ruleset.BindValueChanged(_ =>
{ {
scoreSubscription?.Dispose(); scoreSubscription?.Dispose();
scoreSubscription = realmFactory.Register(realm => scoreSubscription = realmFactory.RegisterForNotifications(realm =>
realm.All<ScoreInfo>() realm.All<ScoreInfo>()
.Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0"
+ $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1" + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1"
+ $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2" + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2"
+ $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName) + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName)
.OrderByDescending(s => s.TotalScore) .OrderByDescending(s => s.TotalScore),
.QueryAsyncWithNotifications((items, changes, ___) => (items, changes, ___) =>
{ {
Rank = items.FirstOrDefault()?.Rank; Rank = items.FirstOrDefault()?.Rank;
// Required since presence is changed via IsPresent override // Required since presence is changed via IsPresent override
Invalidate(Invalidation.Presence); Invalidate(Invalidation.Presence);
})); });
}, true); }, true);
} }

View File

@ -113,14 +113,14 @@ namespace osu.Game.Screens.Select.Leaderboards
if (beatmapInfo == null) if (beatmapInfo == null)
return; return;
scoreSubscription = realmFactory.Register(realm => scoreSubscription = realmFactory.RegisterForNotifications(realm =>
realm.All<ScoreInfo>() realm.All<ScoreInfo>()
.Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} = $0", beatmapInfo.ID) .Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} = $0", beatmapInfo.ID),
.QueryAsyncWithNotifications((_, changes, ___) => (_, changes, ___) =>
{ {
if (!IsOnlineScope) if (!IsOnlineScope)
RefreshScores(); RefreshScores();
})); });
} }
protected override void Reset() protected override void Reset()

View File

@ -17,6 +17,7 @@ using osu.Game.Online.Spectator;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring; using osu.Game.Scoring;
using Realms;
namespace osu.Game.Screens.Spectate namespace osu.Game.Screens.Spectate
{ {
@ -79,23 +80,21 @@ namespace osu.Game.Screens.Spectate
playingUserStates.BindTo(spectatorClient.PlayingUserStates); playingUserStates.BindTo(spectatorClient.PlayingUserStates);
playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true); playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true);
realmSubscription = realmContextFactory.Register(realm => realmSubscription = realmContextFactory.RegisterForNotifications(
realm.All<BeatmapSetInfo>() realm => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
.Where(s => !s.DeletePending)
.QueryAsyncWithNotifications((items, changes, ___) =>
{
if (changes?.InsertedIndices == null)
return;
foreach (int c in changes.InsertedIndices)
beatmapUpdated(items[c]);
}));
foreach ((int id, var _) in userMap) foreach ((int id, var _) in userMap)
spectatorClient.WatchUser(id); spectatorClient.WatchUser(id);
})); }));
} }
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> items, ChangeSet changes, Exception ___)
{
if (changes?.InsertedIndices == null) return;
foreach (int c in changes.InsertedIndices) beatmapUpdated(items[c]);
}
private void beatmapUpdated(BeatmapSetInfo beatmapSet) private void beatmapUpdated(BeatmapSetInfo beatmapSet)
{ {
foreach ((int userId, _) in userMap) foreach ((int userId, _) in userMap)

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
@ -72,8 +73,11 @@ namespace osu.Game.Tests.Visual
[TearDownSteps] [TearDownSteps]
public void TearDownSteps() public void TearDownSteps()
{ {
AddStep("exit game", () => Game.Exit()); if (DebugUtils.IsNUnitRunning)
AddUntilStep("wait for game exit", () => Game.Parent == null); {
AddStep("exit game", () => Game.Exit());
AddUntilStep("wait for game exit", () => Game.Parent == null);
}
} }
protected void CreateGame() protected void CreateGame()

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Logging; using osu.Framework.Logging;
@ -48,7 +49,11 @@ namespace osu.Game.Tests.Visual
public virtual void SetUpSteps() => addExitAllScreensStep(); public virtual void SetUpSteps() => addExitAllScreensStep();
[TearDownSteps] [TearDownSteps]
public virtual void TearDownSteps() => addExitAllScreensStep(); public virtual void TearDownSteps()
{
if (DebugUtils.IsNUnitRunning)
addExitAllScreensStep();
}
private void addExitAllScreensStep() private void addExitAllScreensStep()
{ {

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -23,6 +24,12 @@ namespace osu.Game.Users.Drawables
/// </summary> /// </summary>
public bool ShowPlaceholderOnNull = true; public bool ShowPlaceholderOnNull = true;
/// <summary>
/// Perform an action in addition to showing the country ranking.
/// This should be used to perform auxiliary tasks and not as a primary action for clicking a flag (to maintain a consistent UX).
/// </summary>
public Action Action;
public UpdateableFlag(Country country = null) public UpdateableFlag(Country country = null)
{ {
Country = country; Country = country;
@ -52,6 +59,7 @@ namespace osu.Game.Users.Drawables
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)
{ {
Action?.Invoke();
rankingsOverlay?.ShowCountry(Country); rankingsOverlay?.ShowCountry(Country);
return true; return true;
} }

View File

@ -53,7 +53,8 @@ namespace osu.Game.Users
protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country)
{ {
Size = new Vector2(39, 26) Size = new Vector2(39, 26),
Action = Action,
}; };
protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon