diff --git a/osu.Android.props b/osu.Android.props
index 5a0e7479fa..956093b2ac 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs
new file mode 100644
index 0000000000..861de5303d
--- /dev/null
+++ b/osu.Game.Tests/Database/FileStoreTests.cs
@@ -0,0 +1,114 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Logging;
+using osu.Game.Models;
+using osu.Game.Stores;
+
+#nullable enable
+
+namespace osu.Game.Tests.Database
+{
+ public class FileStoreTests : RealmTest
+ {
+ [Test]
+ public void TestImportFile()
+ {
+ RunTestWithRealm((realmFactory, storage) =>
+ {
+ var realm = realmFactory.Context;
+ var files = new RealmFileStore(realmFactory, storage);
+
+ var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 });
+
+ realm.Write(() => files.Add(testData, realm));
+
+ Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8"));
+ Assert.True(files.Storage.Exists(realm.All().First().StoragePath));
+ });
+ }
+
+ [Test]
+ public void TestImportSameFileTwice()
+ {
+ RunTestWithRealm((realmFactory, storage) =>
+ {
+ var realm = realmFactory.Context;
+ var files = new RealmFileStore(realmFactory, storage);
+
+ var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 });
+
+ realm.Write(() => files.Add(testData, realm));
+ realm.Write(() => files.Add(testData, realm));
+
+ Assert.AreEqual(1, realm.All().Count());
+ });
+ }
+
+ [Test]
+ public void TestDontPurgeReferenced()
+ {
+ RunTestWithRealm((realmFactory, storage) =>
+ {
+ var realm = realmFactory.Context;
+ var files = new RealmFileStore(realmFactory, storage);
+
+ var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm));
+
+ var timer = new Stopwatch();
+ timer.Start();
+
+ realm.Write(() =>
+ {
+ // attach the file to an arbitrary beatmap
+ var beatmapSet = CreateBeatmapSet(CreateRuleset());
+
+ beatmapSet.Files.Add(new RealmNamedFileUsage(file, "arbitrary.resource"));
+
+ realm.Add(beatmapSet);
+ });
+
+ Logger.Log($"Import complete at {timer.ElapsedMilliseconds}");
+
+ string path = file.StoragePath;
+
+ Assert.True(realm.All().Any());
+ Assert.True(files.Storage.Exists(path));
+
+ files.Cleanup();
+ Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}");
+
+ Assert.True(realm.All().Any());
+ Assert.True(file.IsValid);
+ Assert.True(files.Storage.Exists(path));
+ });
+ }
+
+ [Test]
+ public void TestPurgeUnreferenced()
+ {
+ RunTestWithRealm((realmFactory, storage) =>
+ {
+ var realm = realmFactory.Context;
+ var files = new RealmFileStore(realmFactory, storage);
+
+ var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm));
+
+ string path = file.StoragePath;
+
+ Assert.True(realm.All().Any());
+ Assert.True(files.Storage.Exists(path));
+
+ files.Cleanup();
+
+ Assert.False(realm.All().Any());
+ Assert.False(file.IsValid);
+ Assert.False(files.Storage.Exists(path));
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs
index 245981cd9b..3e8b6091fd 100644
--- a/osu.Game.Tests/Database/GeneralUsageTests.cs
+++ b/osu.Game.Tests/Database/GeneralUsageTests.cs
@@ -1,3 +1,6 @@
+// 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 System.Threading.Tasks;
diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs
new file mode 100644
index 0000000000..33aa1afb89
--- /dev/null
+++ b/osu.Game.Tests/Database/RealmLiveTests.cs
@@ -0,0 +1,213 @@
+// 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.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Models;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Tests.Database
+{
+ public class RealmLiveTests : RealmTest
+ {
+ [Test]
+ public void TestLiveCastability()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ RealmLive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive();
+
+ ILive iBeatmap = beatmap;
+
+ Assert.AreEqual(0, iBeatmap.Value.Length);
+ });
+ }
+
+ [Test]
+ public void TestValueAccessWithOpenContext()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ RealmLive? liveBeatmap = null;
+ Task.Factory.StartNew(() =>
+ {
+ using (var threadContext = realmFactory.CreateContext())
+ {
+ var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ liveBeatmap = beatmap.ToLive();
+ }
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+
+ Debug.Assert(liveBeatmap != null);
+
+ Task.Factory.StartNew(() =>
+ {
+ Assert.DoesNotThrow(() =>
+ {
+ using (realmFactory.CreateContext())
+ {
+ var resolved = liveBeatmap.Value;
+
+ Assert.IsTrue(resolved.Realm.IsClosed);
+ Assert.IsTrue(resolved.IsValid);
+
+ // can access properties without a crash.
+ Assert.IsFalse(resolved.Hidden);
+ }
+ });
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+ });
+ }
+
+ [Test]
+ public void TestScopedReadWithoutContext()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ RealmLive? liveBeatmap = null;
+ Task.Factory.StartNew(() =>
+ {
+ using (var threadContext = realmFactory.CreateContext())
+ {
+ var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ liveBeatmap = beatmap.ToLive();
+ }
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+
+ Debug.Assert(liveBeatmap != null);
+
+ Task.Factory.StartNew(() =>
+ {
+ liveBeatmap.PerformRead(beatmap =>
+ {
+ Assert.IsTrue(beatmap.IsValid);
+ Assert.IsFalse(beatmap.Hidden);
+ });
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+ });
+ }
+
+ [Test]
+ public void TestScopedWriteWithoutContext()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ RealmLive? liveBeatmap = null;
+ Task.Factory.StartNew(() =>
+ {
+ using (var threadContext = realmFactory.CreateContext())
+ {
+ var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ liveBeatmap = beatmap.ToLive();
+ }
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+
+ Debug.Assert(liveBeatmap != null);
+
+ Task.Factory.StartNew(() =>
+ {
+ liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; });
+ liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); });
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+ });
+ }
+
+ [Test]
+ public void TestValueAccessWithoutOpenContextFails()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ RealmLive? liveBeatmap = null;
+ Task.Factory.StartNew(() =>
+ {
+ using (var threadContext = realmFactory.CreateContext())
+ {
+ var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ liveBeatmap = beatmap.ToLive();
+ }
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+
+ Debug.Assert(liveBeatmap != null);
+
+ Task.Factory.StartNew(() =>
+ {
+ Assert.Throws(() =>
+ {
+ var unused = liveBeatmap.Value;
+ });
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+ });
+ }
+
+ [Test]
+ public void TestLiveAssumptions()
+ {
+ RunTestWithRealm((realmFactory, _) =>
+ {
+ int changesTriggered = 0;
+
+ using (var updateThreadContext = realmFactory.CreateContext())
+ {
+ updateThreadContext.All().SubscribeForNotifications(gotChange);
+ RealmLive? liveBeatmap = null;
+
+ Task.Factory.StartNew(() =>
+ {
+ using (var threadContext = realmFactory.CreateContext())
+ {
+ var ruleset = CreateRuleset();
+ var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ // add a second beatmap to ensure that a full refresh occurs below.
+ // not just a refresh from the resolved Live.
+ threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata())));
+
+ liveBeatmap = beatmap.ToLive();
+ }
+ }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait();
+
+ Debug.Assert(liveBeatmap != null);
+
+ // not yet seen by main context
+ Assert.AreEqual(0, updateThreadContext.All().Count());
+ Assert.AreEqual(0, changesTriggered);
+
+ var resolved = liveBeatmap.Value;
+
+ // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point.
+ Assert.AreEqual(2, updateThreadContext.All().Count());
+ Assert.AreEqual(1, changesTriggered);
+
+ // even though the realm that this instance was resolved for was closed, it's still valid.
+ Assert.IsTrue(resolved.Realm.IsClosed);
+ Assert.IsTrue(resolved.IsValid);
+
+ // can access properties without a crash.
+ Assert.IsFalse(resolved.Hidden);
+
+ updateThreadContext.Write(r =>
+ {
+ // can use with the main context.
+ r.Remove(resolved);
+ });
+ }
+
+ void gotChange(IRealmCollection sender, ChangeSet changes, Exception error)
+ {
+ changesTriggered++;
+ }
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs
index 576f901c1a..04c9f2577a 100644
--- a/osu.Game.Tests/Database/RealmTest.cs
+++ b/osu.Game.Tests/Database/RealmTest.cs
@@ -4,12 +4,13 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
-using Nito.AsyncEx;
using NUnit.Framework;
+using osu.Framework.Extensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Database;
+using osu.Game.Models;
#nullable enable
@@ -28,42 +29,109 @@ namespace osu.Game.Tests.Database
protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "")
{
- AsyncContext.Run(() =>
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller))
{
- var testStorage = storage.GetStorageForDirectory(caller);
-
- using (var realmFactory = new RealmContextFactory(testStorage, caller))
+ host.Run(new RealmTestGame(() =>
{
- Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
- testAction(realmFactory, testStorage);
+ var testStorage = storage.GetStorageForDirectory(caller);
- realmFactory.Dispose();
+ using (var realmFactory = new RealmContextFactory(testStorage, caller))
+ {
+ Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
+ testAction(realmFactory, testStorage);
- Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
- realmFactory.Compact();
- Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}");
- }
- });
+ realmFactory.Dispose();
+
+ Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
+ realmFactory.Compact();
+ Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}");
+ }
+ }));
+ }
}
protected void RunTestWithRealmAsync(Func testAction, [CallerMemberName] string caller = "")
{
- AsyncContext.Run(async () =>
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller))
{
- var testStorage = storage.GetStorageForDirectory(caller);
-
- using (var realmFactory = new RealmContextFactory(testStorage, caller))
+ host.Run(new RealmTestGame(async () =>
{
- Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
- await testAction(realmFactory, testStorage);
+ var testStorage = storage.GetStorageForDirectory(caller);
- realmFactory.Dispose();
+ using (var realmFactory = new RealmContextFactory(testStorage, caller))
+ {
+ Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}");
+ await testAction(realmFactory, testStorage);
- Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
- realmFactory.Compact();
- Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}");
+ realmFactory.Dispose();
+
+ Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}");
+ realmFactory.Compact();
+ }
+ }));
+ }
+ }
+
+ protected static RealmBeatmapSet CreateBeatmapSet(RealmRuleset ruleset)
+ {
+ RealmFile createRealmFile() => new RealmFile { Hash = Guid.NewGuid().ToString().ComputeSHA2Hash() };
+
+ var metadata = new RealmBeatmapMetadata
+ {
+ Title = "My Love",
+ Artist = "Kuba Oms"
+ };
+
+ var beatmapSet = new RealmBeatmapSet
+ {
+ Beatmaps =
+ {
+ new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Easy", },
+ new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Normal", },
+ new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Hard", },
+ new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Insane", }
+ },
+ Files =
+ {
+ new RealmNamedFileUsage(createRealmFile(), "test [easy].osu"),
+ new RealmNamedFileUsage(createRealmFile(), "test [normal].osu"),
+ new RealmNamedFileUsage(createRealmFile(), "test [hard].osu"),
+ new RealmNamedFileUsage(createRealmFile(), "test [insane].osu"),
}
- });
+ };
+
+ for (int i = 0; i < 8; i++)
+ beatmapSet.Files.Add(new RealmNamedFileUsage(createRealmFile(), $"hitsound{i}.mp3"));
+
+ foreach (var b in beatmapSet.Beatmaps)
+ b.BeatmapSet = beatmapSet;
+
+ return beatmapSet;
+ }
+
+ protected static RealmRuleset CreateRuleset() =>
+ new RealmRuleset(0, "osu!", "osu", true);
+
+ private class RealmTestGame : Framework.Game
+ {
+ public RealmTestGame(Func work)
+ {
+ // ReSharper disable once AsyncVoidLambda
+ Scheduler.Add(async () =>
+ {
+ await work().ConfigureAwait(true);
+ Exit();
+ });
+ }
+
+ public RealmTestGame(Action work)
+ {
+ Scheduler.Add(() =>
+ {
+ work();
+ Exit();
+ });
+ }
}
private static long getFileSize(Storage testStorage, RealmContextFactory realmFactory)
diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs
index ab47067411..ffb3d41d18 100644
--- a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs
+++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs
@@ -6,7 +6,6 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
-using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
@@ -65,10 +64,9 @@ namespace osu.Game.Tests.Skins
public new void TriggerSourceChanged() => base.TriggerSourceChanged();
- protected override void OnSourceChanged()
+ protected override void RefreshSources()
{
- ResetSources();
- sources.ForEach(AddSource);
+ SetSources(sources);
}
}
diff --git a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs
index 211543a881..0107632f6e 100644
--- a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs
+++ b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs
@@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
@@ -18,16 +19,19 @@ namespace osu.Game.Tests.Visual.Audio
{
public class TestSceneAudioFilter : OsuTestScene
{
- private OsuSpriteText lowpassText;
- private AudioFilter lowpassFilter;
+ private OsuSpriteText lowPassText;
+ private AudioFilter lowPassFilter;
- private OsuSpriteText highpassText;
- private AudioFilter highpassFilter;
+ private OsuSpriteText highPassText;
+ private AudioFilter highPassFilter;
private Track track;
private WaveformTestBeatmap beatmap;
+ private OsuSliderBar lowPassSlider;
+ private OsuSliderBar highPassSlider;
+
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
@@ -38,53 +42,89 @@ namespace osu.Game.Tests.Visual.Audio
{
Children = new Drawable[]
{
- lowpassFilter = new AudioFilter(audio.TrackMixer),
- highpassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass),
- lowpassText = new OsuSpriteText
+ lowPassFilter = new AudioFilter(audio.TrackMixer),
+ highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass),
+ lowPassText = new OsuSpriteText
{
Padding = new MarginPadding(20),
- Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz",
+ Text = $"Low Pass: {lowPassFilter.Cutoff}hz",
Font = new FontUsage(size: 40)
},
- new OsuSliderBar
+ lowPassSlider = new OsuSliderBar
{
Width = 500,
Height = 50,
Padding = new MarginPadding(20),
- Current = { BindTarget = lowpassFilter.Cutoff }
+ Current = new BindableInt
+ {
+ MinValue = 0,
+ MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF,
+ }
},
- highpassText = new OsuSpriteText
+ highPassText = new OsuSpriteText
{
Padding = new MarginPadding(20),
- Text = $"High Pass: {highpassFilter.Cutoff.Value}hz",
+ Text = $"High Pass: {highPassFilter.Cutoff}hz",
Font = new FontUsage(size: 40)
},
- new OsuSliderBar
+ highPassSlider = new OsuSliderBar
{
Width = 500,
Height = 50,
Padding = new MarginPadding(20),
- Current = { BindTarget = highpassFilter.Cutoff }
+ Current = new BindableInt
+ {
+ MinValue = 0,
+ MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF,
+ }
}
}
});
- lowpassFilter.Cutoff.ValueChanged += e => lowpassText.Text = $"Low Pass: {e.NewValue}hz";
- highpassFilter.Cutoff.ValueChanged += e => highpassText.Text = $"High Pass: {e.NewValue}hz";
+
+ lowPassSlider.Current.ValueChanged += e =>
+ {
+ lowPassText.Text = $"Low Pass: {e.NewValue}hz";
+ lowPassFilter.Cutoff = e.NewValue;
+ };
+
+ highPassSlider.Current.ValueChanged += e =>
+ {
+ highPassText.Text = $"High Pass: {e.NewValue}hz";
+ highPassFilter.Cutoff = e.NewValue;
+ };
}
+ #region Overrides of Drawable
+
+ protected override void Update()
+ {
+ base.Update();
+ highPassSlider.Current.Value = highPassFilter.Cutoff;
+ lowPassSlider.Current.Value = lowPassFilter.Cutoff;
+ }
+
+ #endregion
+
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Play Track", () => track.Start());
+
+ AddStep("Reset filters", () =>
+ {
+ lowPassFilter.Cutoff = AudioFilter.MAX_LOWPASS_CUTOFF;
+ highPassFilter.Cutoff = 0;
+ });
+
waitTrackPlay();
}
[Test]
- public void TestLowPass()
+ public void TestLowPassSweep()
{
AddStep("Filter Sweep", () =>
{
- lowpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
+ lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic);
});
@@ -92,7 +132,7 @@ namespace osu.Game.Tests.Visual.Audio
AddStep("Filter Sweep (reverse)", () =>
{
- lowpassFilter.CutoffTo(0).Then()
+ lowPassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
});
@@ -101,11 +141,11 @@ namespace osu.Game.Tests.Visual.Audio
}
[Test]
- public void TestHighPass()
+ public void TestHighPassSweep()
{
AddStep("Filter Sweep", () =>
{
- highpassFilter.CutoffTo(0).Then()
+ highPassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
});
@@ -113,7 +153,7 @@ namespace osu.Game.Tests.Visual.Audio
AddStep("Filter Sweep (reverse)", () =>
{
- highpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
+ highPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic);
});
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
index 2258a209e2..f0aa3e2350 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
@@ -32,6 +32,8 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("wait for editor load", () => editor != null);
+ AddStep("Set overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty = 7);
+
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("Enter compose mode", () => InputManager.Key(Key.F1));
@@ -41,11 +43,11 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre));
AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left));
- AddStep("Save and exit", () =>
- {
- InputManager.Keys(PlatformAction.Save);
- InputManager.Key(Key.Escape);
- });
+ AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1);
+
+ AddStep("Save", () => InputManager.Keys(PlatformAction.Save));
+
+ AddStep("Exit", () => InputManager.Key(Key.Escape));
AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu);
@@ -57,6 +59,7 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("Wait for editor load", () => editor != null);
AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1);
+ AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7);
}
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
index 5eb71e92c2..ae0decaee1 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
@@ -103,6 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay
checkFrameCount(0);
}
+ [Test]
+ public void TestRatePreservedWhenTimeNotProgressing()
+ {
+ AddStep("set manual clock rate", () => manualClock.Rate = 1);
+ seekManualTo(5000);
+ createStabilityContainer();
+ checkRate(1);
+
+ seekManualTo(10000);
+ checkRate(1);
+
+ AddWaitStep("wait some", 3);
+ checkRate(1);
+
+ seekManualTo(5000);
+ checkRate(-1);
+
+ AddWaitStep("wait some", 3);
+ checkRate(-1);
+
+ seekManualTo(10000);
+ checkRate(1);
+ }
+
private const int max_frames_catchup = 50;
private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () =>
@@ -116,6 +140,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkFrameCount(int frames) =>
AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames);
+ private void checkRate(double rate) =>
+ AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate);
+
public class ClockConsumingChild : CompositeDrawable
{
private readonly OsuSpriteText text;
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
index 3ed274690e..48a97d54f7 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
@@ -90,8 +90,12 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest(() =>
{
AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true);
- AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300);
+
+ // Fail occurs at 164ms with the provided beatmap.
+ // Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience.
+ AddStep("set storyboard duration to 0.6s", () => currentStoryboardDuration = 600);
});
+
AddUntilStep("wait for fail", () => Player.HasFailed);
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible);
diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs
new file mode 100644
index 0000000000..cb7c334656
--- /dev/null
+++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs
@@ -0,0 +1,31 @@
+// 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 NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Database;
+using osu.Game.Tests.Resources;
+
+namespace osu.Game.Tests.Visual.Navigation
+{
+ public class TestSceneStartupImport : OsuGameTestScene
+ {
+ private string importFilename;
+
+ protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename });
+
+ public override void SetUpSteps()
+ {
+ AddStep("Prepare import beatmap", () => importFilename = TestResources.GetTestBeatmapForImport());
+
+ base.SetUpSteps();
+ }
+
+ [Test]
+ public void TestImportCreatedNotification()
+ {
+ AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs
index 067f1cabb4..4811fc979e 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs
@@ -142,6 +142,8 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("store selected beatmap", () => selected = Beatmap.Value);
+ AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any());
+
AddStep("select next and enter", () =>
{
InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType()
@@ -599,10 +601,10 @@ namespace osu.Game.Tests.Visual.SongSelect
});
FilterableDifficultyIcon difficultyIcon = null;
- AddStep("Find an icon", () =>
+ AddUntilStep("Find an icon", () =>
{
- difficultyIcon = set.ChildrenOfType()
- .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex());
+ return (difficultyIcon = set.ChildrenOfType()
+ .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null;
});
AddStep("Click on a difficulty", () =>
@@ -765,10 +767,10 @@ namespace osu.Game.Tests.Visual.SongSelect
});
FilterableGroupedDifficultyIcon groupIcon = null;
- AddStep("Find group icon for different ruleset", () =>
+ AddUntilStep("Find group icon for different ruleset", () =>
{
- groupIcon = set.ChildrenOfType()
- .First(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3);
+ return (groupIcon = set.ChildrenOfType()
+ .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3)) != null;
});
AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0);
diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs
index ee48bdd7d9..d2a39e9db7 100644
--- a/osu.Game/Audio/Effects/AudioFilter.cs
+++ b/osu.Game/Audio/Effects/AudioFilter.cs
@@ -4,7 +4,6 @@
using System.Diagnostics;
using ManagedBass.Fx;
using osu.Framework.Audio.Mixing;
-using osu.Framework.Bindables;
using osu.Framework.Graphics;
namespace osu.Game.Audio.Effects
@@ -21,10 +20,25 @@ namespace osu.Game.Audio.Effects
private readonly BQFParameters filter;
private readonly BQFType type;
+ private bool isAttached;
+
+ private int cutoff;
+
///
- /// The current cutoff of this filter.
+ /// The cutoff frequency of this filter.
///
- public BindableNumber Cutoff { get; }
+ public int Cutoff
+ {
+ get => cutoff;
+ set
+ {
+ if (value == cutoff)
+ return;
+
+ cutoff = value;
+ updateFilter(cutoff);
+ }
+ }
///
/// A Component that implements a BASS FX BiQuad Filter Effect.
@@ -36,102 +50,96 @@ namespace osu.Game.Audio.Effects
this.mixer = mixer;
this.type = type;
- int initialCutoff;
-
- switch (type)
- {
- case BQFType.HighPass:
- initialCutoff = 1;
- break;
-
- case BQFType.LowPass:
- initialCutoff = MAX_LOWPASS_CUTOFF;
- break;
-
- default:
- initialCutoff = 500; // A default that should ensure audio remains audible for other filters.
- break;
- }
-
- Cutoff = new BindableNumber(initialCutoff)
- {
- MinValue = 1,
- MaxValue = MAX_LOWPASS_CUTOFF
- };
-
filter = new BQFParameters
{
lFilter = type,
- fCenter = initialCutoff,
fBandwidth = 0,
- fQ = 0.7f // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0)
+ // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0)
+ fQ = 0.7f
};
- // Don't start attached if this is low-pass or high-pass filter (as they have special auto-attach/detach logic)
- if (type != BQFType.LowPass && type != BQFType.HighPass)
- attachFilter();
-
- Cutoff.ValueChanged += updateFilter;
+ Cutoff = getInitialCutoff(type);
}
- private void attachFilter()
+ private int getInitialCutoff(BQFType type)
{
- Debug.Assert(!mixer.Effects.Contains(filter));
- mixer.Effects.Add(filter);
- }
-
- private void detachFilter()
- {
- Debug.Assert(mixer.Effects.Contains(filter));
- mixer.Effects.Remove(filter);
- }
-
- private void updateFilter(ValueChangedEvent cutoff)
- {
- // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz.
- if (type == BQFType.LowPass)
+ switch (type)
{
- if (cutoff.NewValue >= MAX_LOWPASS_CUTOFF)
- {
- detachFilter();
- return;
- }
+ case BQFType.HighPass:
+ return 1;
- if (cutoff.OldValue >= MAX_LOWPASS_CUTOFF && cutoff.NewValue < MAX_LOWPASS_CUTOFF)
- attachFilter();
+ case BQFType.LowPass:
+ return MAX_LOWPASS_CUTOFF;
+
+ default:
+ return 500; // A default that should ensure audio remains audible for other filters.
+ }
+ }
+
+ private void updateFilter(int newValue)
+ {
+ switch (type)
+ {
+ case BQFType.LowPass:
+ // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz.
+ if (newValue >= MAX_LOWPASS_CUTOFF)
+ {
+ ensureDetached();
+ return;
+ }
+
+ break;
+
+ // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz.
+ case BQFType.HighPass:
+ if (newValue <= 1)
+ {
+ ensureDetached();
+ return;
+ }
+
+ break;
}
- // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz.
- if (type == BQFType.HighPass)
- {
- if (cutoff.NewValue <= 1)
- {
- detachFilter();
- return;
- }
-
- if (cutoff.OldValue <= 1 && cutoff.NewValue > 1)
- attachFilter();
- }
+ ensureAttached();
var filterIndex = mixer.Effects.IndexOf(filter);
+
if (filterIndex < 0) return;
if (mixer.Effects[filterIndex] is BQFParameters existingFilter)
{
- existingFilter.fCenter = cutoff.NewValue;
+ existingFilter.fCenter = newValue;
// required to update effect with new parameters.
mixer.Effects[filterIndex] = existingFilter;
}
}
+ private void ensureAttached()
+ {
+ if (isAttached)
+ return;
+
+ Debug.Assert(!mixer.Effects.Contains(filter));
+ mixer.Effects.Add(filter);
+ isAttached = true;
+ }
+
+ private void ensureDetached()
+ {
+ if (!isAttached)
+ return;
+
+ Debug.Assert(mixer.Effects.Contains(filter));
+ mixer.Effects.Remove(filter);
+ isAttached = false;
+ }
+
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
-
- if (mixer.Effects.Contains(filter))
- detachFilter();
+ ensureDetached();
}
}
}
diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs
index e4de4cf8ff..fb6a924f68 100644
--- a/osu.Game/Audio/Effects/ITransformableFilter.cs
+++ b/osu.Game/Audio/Effects/ITransformableFilter.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Transforms;
@@ -12,7 +11,7 @@ namespace osu.Game.Audio.Effects
///
/// The filter cutoff.
///
- BindableNumber Cutoff { get; }
+ int Cutoff { get; set; }
}
public static class FilterableAudioComponentExtensions
@@ -40,7 +39,7 @@ namespace osu.Game.Audio.Effects
public static TransformSequence CutoffTo(this T component, int newCutoff, double duration, TEasing easing)
where T : class, ITransformableFilter, IDrawable
where TEasing : IEasingFunction
- => component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing);
+ => component.TransformTo(nameof(component.Cutoff), newCutoff, duration, easing);
///
/// Smoothly adjusts filter cutoff over time.
@@ -49,6 +48,6 @@ namespace osu.Game.Audio.Effects
public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration, TEasing easing)
where T : class, ITransformableFilter, IDrawable
where TEasing : IEasingFunction
- => sequence.Append(o => o.TransformBindableTo(o.Cutoff, newCutoff, duration, easing));
+ => sequence.Append(o => o.TransformTo(nameof(o.Cutoff), newCutoff, duration, easing));
}
}
diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs
index f3434c5153..627e54c803 100644
--- a/osu.Game/Beatmaps/BeatmapConverter.cs
+++ b/osu.Game/Beatmaps/BeatmapConverter.cs
@@ -40,7 +40,13 @@ namespace osu.Game.Beatmaps
public IBeatmap Convert(CancellationToken cancellationToken = default)
{
// We always operate on a clone of the original beatmap, to not modify it game-wide
- return ConvertBeatmap(Beatmap.Clone(), cancellationToken);
+ var original = Beatmap.Clone();
+
+ // Shallow clone isn't enough to ensure we don't mutate beatmap info unexpectedly.
+ // Can potentially be removed after `Beatmap.Difficulty` doesn't save back to `Beatmap.BeatmapInfo`.
+ original.BeatmapInfo = original.BeatmapInfo.Clone();
+
+ return ConvertBeatmap(original, cancellationToken);
}
///
diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs
index 8d0c87374f..b22f2ae485 100644
--- a/osu.Game/Configuration/RandomSelectAlgorithm.cs
+++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs
@@ -10,7 +10,7 @@ namespace osu.Game.Configuration
[Description("Never repeat")]
RandomPermutation,
- [Description("Random")]
+ [Description("True Random")]
Random
}
}
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index ee1a7e2900..c235fc7728 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -116,7 +116,7 @@ namespace osu.Game.Database
/// One or more archive locations on disk.
public Task Import(params string[] paths)
{
- var notification = new ProgressNotification { State = ProgressNotificationState.Active };
+ var notification = new ImportProgressNotification();
PostNotification?.Invoke(notification);
@@ -125,7 +125,7 @@ namespace osu.Game.Database
public Task Import(params ImportTask[] tasks)
{
- var notification = new ProgressNotification { State = ProgressNotificationState.Active };
+ var notification = new ImportProgressNotification();
PostNotification?.Invoke(notification);
diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs
new file mode 100644
index 0000000000..024d9f2a89
--- /dev/null
+++ b/osu.Game/Database/IHasRealmFiles.cs
@@ -0,0 +1,20 @@
+// 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 osu.Game.Models;
+
+#nullable enable
+
+namespace osu.Game.Database
+{
+ ///
+ /// A model that contains a list of files it is responsible for.
+ ///
+ public interface IHasRealmFiles
+ {
+ IList Files { get; }
+
+ string Hash { get; set; }
+ }
+}
diff --git a/osu.Game/Database/INamedFile.cs b/osu.Game/Database/INamedFile.cs
new file mode 100644
index 0000000000..2bd45d4e42
--- /dev/null
+++ b/osu.Game/Database/INamedFile.cs
@@ -0,0 +1,19 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Models;
+
+#nullable enable
+
+namespace osu.Game.Database
+{
+ ///
+ /// Represents a join model which gives a filename and scope to a .
+ ///
+ public interface INamedFile
+ {
+ string Filename { get; set; }
+
+ RealmFile File { get; set; }
+ }
+}
diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs
new file mode 100644
index 0000000000..aaee3e117f
--- /dev/null
+++ b/osu.Game/Database/ImportProgressNotification.cs
@@ -0,0 +1,15 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Overlays.Notifications;
+
+namespace osu.Game.Database
+{
+ public class ImportProgressNotification : ProgressNotification
+ {
+ public ImportProgressNotification()
+ {
+ State = ProgressNotificationState.Active;
+ }
+ }
+}
diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs
index 0ff902a8bc..c3810eb441 100644
--- a/osu.Game/Database/RealmContextFactory.cs
+++ b/osu.Game/Database/RealmContextFactory.cs
@@ -135,9 +135,8 @@ namespace osu.Game.Database
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
- // TODO: this can be added for safety once we figure how to bypass in test
- // if (!ThreadSafety.IsUpdateThread)
- // throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread.");
+ if (!ThreadSafety.IsUpdateThread)
+ throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread.");
Logger.Log(@"Blocking realm operations.", LoggingTarget.Database);
diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs
new file mode 100644
index 0000000000..abb69644d6
--- /dev/null
+++ b/osu.Game/Database/RealmLive.cs
@@ -0,0 +1,111 @@
+// 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 Realms;
+
+#nullable enable
+
+namespace osu.Game.Database
+{
+ ///
+ /// Provides a method of working with realm objects over longer application lifetimes.
+ ///
+ /// The underlying object type.
+ public class RealmLive : ILive where T : RealmObject, IHasGuidPrimaryKey
+ {
+ public Guid ID { get; }
+
+ private readonly SynchronizationContext? fetchedContext;
+ private readonly int fetchedThreadId;
+
+ ///
+ /// The original live data used to create this instance.
+ ///
+ private readonly T data;
+
+ ///
+ /// Construct a new instance of live realm data.
+ ///
+ /// The realm data.
+ public RealmLive(T data)
+ {
+ this.data = data;
+
+ fetchedContext = SynchronizationContext.Current;
+ fetchedThreadId = Thread.CurrentThread.ManagedThreadId;
+
+ ID = data.ID;
+ }
+
+ ///
+ /// Perform a read operation on this live object.
+ ///
+ /// The action to perform.
+ public void PerformRead(Action perform)
+ {
+ if (originalDataValid)
+ {
+ perform(data);
+ return;
+ }
+
+ using (var realm = Realm.GetInstance(data.Realm.Config))
+ perform(realm.Find(ID));
+ }
+
+ ///
+ /// Perform a read operation on this live object.
+ ///
+ /// The action to perform.
+ public TReturn PerformRead(Func perform)
+ {
+ if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn)))
+ throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}.");
+
+ if (originalDataValid)
+ return perform(data);
+
+ using (var realm = Realm.GetInstance(data.Realm.Config))
+ return perform(realm.Find(ID));
+ }
+
+ ///
+ /// Perform a write operation on this live object.
+ ///
+ /// The action to perform.
+ public void PerformWrite(Action perform) =>
+ PerformRead(t =>
+ {
+ var transaction = t.Realm.BeginWrite();
+ perform(t);
+ transaction.Commit();
+ });
+
+ public T Value
+ {
+ get
+ {
+ if (originalDataValid)
+ return data;
+
+ T retrieved;
+
+ using (var realm = Realm.GetInstance(data.Realm.Config))
+ retrieved = realm.Find(ID);
+
+ if (!retrieved.IsValid)
+ throw new InvalidOperationException("Attempted to access value without an open context");
+
+ return retrieved;
+ }
+ }
+
+ private bool originalDataValid => isCorrectThread && data.IsValid;
+
+ // this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72)
+ private bool isCorrectThread
+ => (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId;
+ }
+}
diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs
index c5aa1399a3..18a926fa8c 100644
--- a/osu.Game/Database/RealmObjectExtensions.cs
+++ b/osu.Game/Database/RealmObjectExtensions.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
+using System.Linq;
using AutoMapper;
using osu.Game.Input.Bindings;
using Realms;
@@ -47,5 +48,17 @@ namespace osu.Game.Database
return mapper.Map(item);
}
+
+ public static List> ToLive(this IEnumerable realmList)
+ where T : RealmObject, IHasGuidPrimaryKey
+ {
+ return realmList.Select(l => new RealmLive(l)).ToList();
+ }
+
+ public static RealmLive ToLive(this T realmObject)
+ where T : RealmObject, IHasGuidPrimaryKey
+ {
+ return new RealmLive(realmObject);
+ }
}
}
diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs
index aa6eabd7d1..008781c2e5 100644
--- a/osu.Game/Localisation/AudioSettingsStrings.cs
+++ b/osu.Game/Localisation/AudioSettingsStrings.cs
@@ -24,6 +24,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString VolumeHeader => new TranslatableString(getKey(@"volume_header"), @"Volume");
+ ///
+ /// "Output device"
+ ///
+ public static LocalisableString OutputDevice => new TranslatableString(getKey(@"output_device"), @"Output device");
+
///
/// "Master"
///
diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs
index 6d6381b429..fa92187650 100644
--- a/osu.Game/Localisation/GameplaySettingsStrings.cs
+++ b/osu.Game/Localisation/GameplaySettingsStrings.cs
@@ -14,11 +14,36 @@ namespace osu.Game.Localisation
///
public static LocalisableString GameplaySectionHeader => new TranslatableString(getKey(@"gameplay_section_header"), @"Gameplay");
+ ///
+ /// "Beatmap"
+ ///
+ public static LocalisableString BeatmapHeader => new TranslatableString(getKey(@"beatmap_header"), @"Beatmap");
+
///
/// "General"
///
public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General");
+ ///
+ /// "Audio"
+ ///
+ public static LocalisableString AudioHeader => new TranslatableString(getKey(@"audio"), @"Audio");
+
+ ///
+ /// "HUD"
+ ///
+ public static LocalisableString HUDHeader => new TranslatableString(getKey(@"h_u_d"), @"HUD");
+
+ ///
+ /// "Input"
+ ///
+ public static LocalisableString InputHeader => new TranslatableString(getKey(@"input"), @"Input");
+
+ ///
+ /// "Background"
+ ///
+ public static LocalisableString BackgroundHeader => new TranslatableString(getKey(@"background"), @"Background");
+
///
/// "Background dim"
///
diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs
index 0e384f983f..f85cc0f2ae 100644
--- a/osu.Game/Localisation/GraphicsSettingsStrings.cs
+++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs
@@ -104,6 +104,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString HitLighting => new TranslatableString(getKey(@"hit_lighting"), @"Hit lighting");
+ ///
+ /// "Screenshots"
+ ///
+ public static LocalisableString Screenshots => new TranslatableString(getKey(@"screenshots"), @"Screenshots");
+
///
/// "Screenshot format"
///
diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs
new file mode 100644
index 0000000000..a356c9e20b
--- /dev/null
+++ b/osu.Game/Localisation/RulesetSettingsStrings.cs
@@ -0,0 +1,19 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Localisation;
+
+namespace osu.Game.Localisation
+{
+ public static class RulesetSettingsStrings
+ {
+ private const string prefix = @"osu.Game.Resources.Localisation.RulesetSettings";
+
+ ///
+ /// "Rulesets"
+ ///
+ public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets");
+
+ private static string getKey(string key) => $@"{prefix}:{key}";
+ }
+}
diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs
index f22b4d6bf5..8b74b94d59 100644
--- a/osu.Game/Localisation/SkinSettingsStrings.cs
+++ b/osu.Game/Localisation/SkinSettingsStrings.cs
@@ -14,6 +14,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString SkinSectionHeader => new TranslatableString(getKey(@"skin_section_header"), @"Skin");
+ ///
+ /// "Current skin"
+ ///
+ public static LocalisableString CurrentSkin => new TranslatableString(getKey(@"current_skin"), @"Current skin");
+
///
/// "Skin layout editor"
///
diff --git a/osu.Game/Models/RealmBeatmap.cs b/osu.Game/Models/RealmBeatmap.cs
new file mode 100644
index 0000000000..5049c1384d
--- /dev/null
+++ b/osu.Game/Models/RealmBeatmap.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;
+using JetBrains.Annotations;
+using Newtonsoft.Json;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Rulesets;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ ///
+ /// A single beatmap difficulty.
+ ///
+ [ExcludeFromDynamicCompile]
+ [Serializable]
+ [MapTo("Beatmap")]
+ public class RealmBeatmap : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo
+ {
+ [PrimaryKey]
+ public Guid ID { get; set; } = Guid.NewGuid();
+
+ public string DifficultyName { get; set; } = string.Empty;
+
+ public RealmRuleset Ruleset { get; set; } = null!;
+
+ public RealmBeatmapDifficulty Difficulty { get; set; } = null!;
+
+ public RealmBeatmapMetadata Metadata { get; set; } = null!;
+
+ public RealmBeatmapSet? BeatmapSet { get; set; }
+
+ public BeatmapSetOnlineStatus Status
+ {
+ get => (BeatmapSetOnlineStatus)StatusInt;
+ set => StatusInt = (int)value;
+ }
+
+ [MapTo(nameof(Status))]
+ public int StatusInt { get; set; }
+
+ public int? OnlineID { get; set; }
+
+ public double Length { get; set; }
+
+ public double BPM { get; set; }
+
+ public string Hash { get; set; } = string.Empty;
+
+ public double StarRating { get; set; }
+
+ public string MD5Hash { get; set; } = string.Empty;
+
+ [JsonIgnore]
+ public bool Hidden { get; set; }
+
+ public RealmBeatmap(RealmRuleset ruleset, RealmBeatmapDifficulty difficulty, RealmBeatmapMetadata metadata)
+ {
+ Ruleset = ruleset;
+ Difficulty = difficulty;
+ Metadata = metadata;
+ }
+
+ [UsedImplicitly]
+ private RealmBeatmap()
+ {
+ }
+
+ #region Properties we may not want persisted (but also maybe no harm?)
+
+ public double AudioLeadIn { get; set; }
+
+ public float StackLeniency { get; set; } = 0.7f;
+
+ public bool SpecialStyle { get; set; }
+
+ public bool LetterboxInBreaks { get; set; }
+
+ public bool WidescreenStoryboard { get; set; }
+
+ public bool EpilepsyWarning { get; set; }
+
+ public bool SamplesMatchPlaybackRate { get; set; }
+
+ public double DistanceSpacing { get; set; }
+
+ public int BeatDivisor { get; set; }
+
+ public int GridSize { get; set; }
+
+ public double TimelineZoom { get; set; }
+
+ #endregion
+
+ public bool AudioEquals(RealmBeatmap? other) => other != null
+ && BeatmapSet != null
+ && other.BeatmapSet != null
+ && BeatmapSet.Hash == other.BeatmapSet.Hash
+ && Metadata.AudioFile == other.Metadata.AudioFile;
+
+ public bool BackgroundEquals(RealmBeatmap? other) => other != null
+ && BeatmapSet != null
+ && other.BeatmapSet != null
+ && BeatmapSet.Hash == other.BeatmapSet.Hash
+ && Metadata.BackgroundFile == other.Metadata.BackgroundFile;
+
+ IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata;
+ IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet;
+ IRulesetInfo IBeatmapInfo.Ruleset => Ruleset;
+ IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => Difficulty;
+ }
+}
diff --git a/osu.Game/Models/RealmBeatmapDifficulty.cs b/osu.Game/Models/RealmBeatmapDifficulty.cs
new file mode 100644
index 0000000000..3c1dad69e4
--- /dev/null
+++ b/osu.Game/Models/RealmBeatmapDifficulty.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ [MapTo("BeatmapDifficulty")]
+ public class RealmBeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo
+ {
+ public float DrainRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
+ public float CircleSize { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
+ public float OverallDifficulty { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
+ public float ApproachRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY;
+
+ public double SliderMultiplier { get; set; } = 1;
+ public double SliderTickRate { get; set; } = 1;
+
+ ///
+ /// Returns a shallow-clone of this .
+ ///
+ public RealmBeatmapDifficulty Clone()
+ {
+ var diff = new RealmBeatmapDifficulty();
+ CopyTo(diff);
+ return diff;
+ }
+
+ public void CopyTo(RealmBeatmapDifficulty difficulty)
+ {
+ difficulty.ApproachRate = ApproachRate;
+ difficulty.DrainRate = DrainRate;
+ difficulty.CircleSize = CircleSize;
+ difficulty.OverallDifficulty = OverallDifficulty;
+
+ difficulty.SliderMultiplier = SliderMultiplier;
+ difficulty.SliderTickRate = SliderTickRate;
+ }
+ }
+}
diff --git a/osu.Game/Models/RealmBeatmapMetadata.cs b/osu.Game/Models/RealmBeatmapMetadata.cs
new file mode 100644
index 0000000000..6ea7170d0f
--- /dev/null
+++ b/osu.Game/Models/RealmBeatmapMetadata.cs
@@ -0,0 +1,45 @@
+// 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 osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ [Serializable]
+ [MapTo("BeatmapMetadata")]
+ public class RealmBeatmapMetadata : RealmObject, IBeatmapMetadataInfo
+ {
+ public string Title { get; set; } = string.Empty;
+
+ [JsonProperty("title_unicode")]
+ public string TitleUnicode { get; set; } = string.Empty;
+
+ public string Artist { get; set; } = string.Empty;
+
+ [JsonProperty("artist_unicode")]
+ public string ArtistUnicode { get; set; } = string.Empty;
+
+ public string Author { get; set; } = string.Empty; // eventually should be linked to a persisted User.
+
+ public string Source { get; set; } = string.Empty;
+
+ [JsonProperty(@"tags")]
+ public string Tags { get; set; } = string.Empty;
+
+ ///
+ /// The time in milliseconds to begin playing the track for preview purposes.
+ /// If -1, the track should begin playing at 40% of its length.
+ ///
+ public int PreviewTime { get; set; }
+
+ public string AudioFile { get; set; } = string.Empty;
+ public string BackgroundFile { get; set; } = string.Empty;
+ }
+}
diff --git a/osu.Game/Models/RealmBeatmapSet.cs b/osu.Game/Models/RealmBeatmapSet.cs
new file mode 100644
index 0000000000..314ca4494b
--- /dev/null
+++ b/osu.Game/Models/RealmBeatmapSet.cs
@@ -0,0 +1,78 @@
+// 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.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ [MapTo("BeatmapSet")]
+ public class RealmBeatmapSet : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo
+ {
+ [PrimaryKey]
+ public Guid ID { get; set; } = Guid.NewGuid();
+
+ public int? OnlineID { get; set; }
+
+ public DateTimeOffset DateAdded { get; set; }
+
+ public IBeatmapMetadataInfo? Metadata => Beatmaps.FirstOrDefault()?.Metadata;
+
+ public IList Beatmaps { get; } = null!;
+
+ public IList Files { get; } = null!;
+
+ public bool DeletePending { get; set; }
+
+ public string Hash { get; set; } = string.Empty;
+
+ ///
+ /// Whether deleting this beatmap set should be prohibited (due to it being a system requirement to be present).
+ ///
+ public bool Protected { get; set; }
+
+ public double MaxStarDifficulty => Beatmaps.Max(b => b.StarRating);
+
+ public double MaxLength => Beatmaps.Max(b => b.Length);
+
+ public double MaxBPM => Beatmaps.Max(b => b.BPM);
+
+ ///
+ /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null.
+ /// The path returned is relative to the user file storage.
+ ///
+ /// The name of the file to get the storage path of.
+ public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.StoragePath;
+
+ public override string ToString() => Metadata?.ToString() ?? base.ToString();
+
+ public bool Equals(RealmBeatmapSet? other)
+ {
+ if (other == null)
+ return false;
+
+ if (IsManaged && other.IsManaged)
+ return ID == other.ID;
+
+ if (OnlineID.HasValue && other.OnlineID.HasValue)
+ return OnlineID == other.OnlineID;
+
+ if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash))
+ return Hash == other.Hash;
+
+ return ReferenceEquals(this, other);
+ }
+
+ IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps;
+
+ IEnumerable IBeatmapSetInfo.Files => Files;
+ }
+}
diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs
new file mode 100644
index 0000000000..2715f4be45
--- /dev/null
+++ b/osu.Game/Models/RealmFile.cs
@@ -0,0 +1,22 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.IO;
+using osu.Framework.Testing;
+using osu.Game.IO;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ [MapTo("File")]
+ public class RealmFile : RealmObject, IFileInfo
+ {
+ [PrimaryKey]
+ public string Hash { get; set; } = string.Empty;
+
+ public string StoragePath => Path.Combine(Hash.Remove(1), Hash.Remove(2), Hash);
+ }
+}
diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs
new file mode 100644
index 0000000000..ba12d51d0b
--- /dev/null
+++ b/osu.Game/Models/RealmNamedFileUsage.cs
@@ -0,0 +1,34 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using JetBrains.Annotations;
+using osu.Framework.Testing;
+using osu.Game.Database;
+using osu.Game.IO;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage
+ {
+ public RealmFile File { get; set; } = null!;
+
+ public string Filename { get; set; } = null!;
+
+ public RealmNamedFileUsage(RealmFile file, string filename)
+ {
+ File = file;
+ Filename = filename;
+ }
+
+ [UsedImplicitly]
+ private RealmNamedFileUsage()
+ {
+ }
+
+ IFileInfo INamedFileUsage.File => File;
+ }
+}
diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs
new file mode 100644
index 0000000000..0dcd701ed2
--- /dev/null
+++ b/osu.Game/Models/RealmRuleset.cs
@@ -0,0 +1,63 @@
+// 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 JetBrains.Annotations;
+using osu.Framework.Testing;
+using osu.Game.Rulesets;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Models
+{
+ [ExcludeFromDynamicCompile]
+ [MapTo("Ruleset")]
+ public class RealmRuleset : RealmObject, IEquatable, IRulesetInfo
+ {
+ [PrimaryKey]
+ public string ShortName { get; set; } = string.Empty;
+
+ public int? OnlineID { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public string InstantiationInfo { get; set; } = string.Empty;
+
+ public RealmRuleset(string shortName, string name, string instantiationInfo, int? onlineID = null)
+ {
+ ShortName = shortName;
+ Name = name;
+ InstantiationInfo = instantiationInfo;
+ OnlineID = onlineID;
+ }
+
+ [UsedImplicitly]
+ private RealmRuleset()
+ {
+ }
+
+ public RealmRuleset(int? onlineID, string name, string shortName, bool available)
+ {
+ OnlineID = onlineID;
+ Name = name;
+ ShortName = shortName;
+ Available = available;
+ }
+
+ public bool Available { get; set; }
+
+ public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo;
+
+ public override string ToString() => Name;
+
+ public RealmRuleset Clone() => new RealmRuleset
+ {
+ OnlineID = OnlineID,
+ Name = Name,
+ ShortName = ShortName,
+ InstantiationInfo = InstantiationInfo,
+ Available = Available
+ };
+ }
+}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 7895715045..020cdebab6 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -211,13 +211,6 @@ namespace osu.Game
[BackgroundDependencyLoader]
private void load()
{
- if (args?.Length > 0)
- {
- var paths = args.Where(a => !a.StartsWith('-')).ToArray();
- if (paths.Length > 0)
- Task.Run(() => Import(paths));
- }
-
dependencies.CacheAs(this);
dependencies.Cache(SentryLogger);
@@ -867,6 +860,19 @@ namespace osu.Game
{
if (mode.NewValue != OverlayActivation.All) CloseAllOverlays();
};
+
+ // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup.
+ handleStartupImport();
+ }
+
+ private void handleStartupImport()
+ {
+ if (args?.Length > 0)
+ {
+ var paths = args.Where(a => !a.StartsWith('-')).ToArray();
+ if (paths.Length > 0)
+ Task.Run(() => Import(paths));
+ }
}
private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays)
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 7f4fe8a943..09eb482d16 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
@@ -410,11 +411,28 @@ namespace osu.Game
{
Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""...");
- using (realmFactory.BlockAllOperations())
+ IDisposable realmBlocker = null;
+
+ try
{
- contextFactory.FlushConnections();
+ ManualResetEventSlim readyToRun = new ManualResetEventSlim();
+
+ Scheduler.Add(() =>
+ {
+ realmBlocker = realmFactory.BlockAllOperations();
+ contextFactory.FlushConnections();
+
+ readyToRun.Set();
+ }, false);
+
+ readyToRun.Wait();
+
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
}
+ finally
+ {
+ realmBlocker?.Dispose();
+ }
Logger.Log(@"Migration complete!");
}
diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs
index 51214fe460..198aa1438a 100644
--- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs
+++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs
@@ -29,7 +29,7 @@ namespace osu.Game.Overlays.OSD
private Sample sampleChange;
public TrackedSettingToast(SettingDescription description)
- : base(description.Name, description.Value, description.Shortcut)
+ : base(description.Name.ToString(), description.Value.ToString(), description.Shortcut.ToString())
{
FillFlowContainer optionLights;
diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs
index d697b45424..0c54ae2763 100644
--- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs
@@ -28,6 +28,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
{
dropdown = new AudioDeviceSettingsDropdown
{
+ LabelText = AudioSettingsStrings.OutputDevice,
Keywords = new[] { "speaker", "headphone", "output" }
}
};
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs
new file mode 100644
index 0000000000..dba64d695a
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs
@@ -0,0 +1,34 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Configuration;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections.Gameplay
+{
+ public class AudioSettings : SettingsSubsection
+ {
+ protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ Children = new Drawable[]
+ {
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.PositionalHitsounds,
+ Current = config.GetBindable(OsuSetting.PositionalHitSounds)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak,
+ Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak)
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs
new file mode 100644
index 0000000000..94e0c5e494
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs
@@ -0,0 +1,48 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Configuration;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections.Gameplay
+{
+ public class BackgroundSettings : SettingsSubsection
+ {
+ protected override LocalisableString Header => GameplaySettingsStrings.BackgroundHeader;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ Children = new Drawable[]
+ {
+ new SettingsSlider
+ {
+ LabelText = GameplaySettingsStrings.BackgroundDim,
+ Current = config.GetBindable(OsuSetting.DimLevel),
+ KeyboardStep = 0.01f,
+ DisplayAsPercentage = true
+ },
+ new SettingsSlider
+ {
+ LabelText = GameplaySettingsStrings.BackgroundBlur,
+ Current = config.GetBindable(OsuSetting.BlurLevel),
+ KeyboardStep = 0.01f,
+ DisplayAsPercentage = true
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.LightenDuringBreaks,
+ Current = config.GetBindable(OsuSetting.LightenDuringBreaks)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow,
+ Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow),
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs
new file mode 100644
index 0000000000..aaa60ce81b
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs
@@ -0,0 +1,44 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Configuration;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections.Gameplay
+{
+ public class BeatmapSettings : SettingsSubsection
+ {
+ protected override LocalisableString Header => GameplaySettingsStrings.BeatmapHeader;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ Children = new Drawable[]
+ {
+ new SettingsCheckbox
+ {
+ LabelText = SkinSettingsStrings.BeatmapSkins,
+ Current = config.GetBindable(OsuSetting.BeatmapSkins)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = SkinSettingsStrings.BeatmapColours,
+ Current = config.GetBindable(OsuSetting.BeatmapColours)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = SkinSettingsStrings.BeatmapHitsounds,
+ Current = config.GetBindable(OsuSetting.BeatmapHitsounds)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GraphicsSettingsStrings.StoryboardVideo,
+ Current = config.GetBindable(OsuSetting.ShowStoryboard)
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
index 3a0265e453..d4e4fd571d 100644
--- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
@@ -1,7 +1,6 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
@@ -20,77 +19,18 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
{
Children = new Drawable[]
{
- new SettingsSlider
- {
- LabelText = GameplaySettingsStrings.BackgroundDim,
- Current = config.GetBindable(OsuSetting.DimLevel),
- KeyboardStep = 0.01f,
- DisplayAsPercentage = true
- },
- new SettingsSlider
- {
- LabelText = GameplaySettingsStrings.BackgroundBlur,
- Current = config.GetBindable(OsuSetting.BlurLevel),
- KeyboardStep = 0.01f,
- DisplayAsPercentage = true
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.LightenDuringBreaks,
- Current = config.GetBindable(OsuSetting.LightenDuringBreaks)
- },
- new SettingsEnumDropdown
- {
- LabelText = GameplaySettingsStrings.HUDVisibilityMode,
- Current = config.GetBindable(OsuSetting.HUDVisibilityMode)
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.ShowDifficultyGraph,
- Current = config.GetBindable(OsuSetting.ShowProgressGraph)
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail,
- Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail),
- Keywords = new[] { "hp", "bar" }
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow,
- Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow),
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay,
- Current = config.GetBindable(OsuSetting.KeyOverlay)
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.PositionalHitsounds,
- Current = config.GetBindable(OsuSetting.PositionalHitSounds)
- },
- new SettingsCheckbox
- {
- LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak,
- Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak)
- },
new SettingsEnumDropdown
{
LabelText = GameplaySettingsStrings.ScoreDisplayMode,
Current = config.GetBindable(OsuSetting.ScoreDisplayMode),
Keywords = new[] { "scoring" }
},
- };
-
- if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
- {
- Add(new SettingsCheckbox
+ new SettingsCheckbox
{
- LabelText = GameplaySettingsStrings.DisableWinKey,
- Current = config.GetBindable(OsuSetting.GameplayDisableWinKey)
- });
- }
+ LabelText = GraphicsSettingsStrings.HitLighting,
+ Current = config.GetBindable(OsuSetting.HitLighting)
+ },
+ };
}
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs
new file mode 100644
index 0000000000..e1b452e322
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Configuration;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections.Gameplay
+{
+ public class HUDSettings : SettingsSubsection
+ {
+ protected override LocalisableString Header => GameplaySettingsStrings.HUDHeader;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ Children = new Drawable[]
+ {
+ new SettingsEnumDropdown
+ {
+ LabelText = GameplaySettingsStrings.HUDVisibilityMode,
+ Current = config.GetBindable(OsuSetting.HUDVisibilityMode)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.ShowDifficultyGraph,
+ Current = config.GetBindable(OsuSetting.ShowProgressGraph)
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail,
+ Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail),
+ Keywords = new[] { "hp", "bar" }
+ },
+ new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay,
+ Current = config.GetBindable(OsuSetting.KeyOverlay)
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs
new file mode 100644
index 0000000000..962572ca6e
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Configuration;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections.Gameplay
+{
+ public class InputSettings : SettingsSubsection
+ {
+ protected override LocalisableString Header => GameplaySettingsStrings.InputHeader;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ Children = new Drawable[]
+ {
+ new SettingsSlider
+ {
+ LabelText = SkinSettingsStrings.GameplayCursorSize,
+ Current = config.GetBindable(OsuSetting.GameplayCursorSize),
+ KeyboardStep = 0.01f
+ },
+ new SettingsCheckbox
+ {
+ LabelText = SkinSettingsStrings.AutoCursorSize,
+ Current = config.GetBindable(OsuSetting.AutoCursorSize)
+ },
+ };
+
+ if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
+ {
+ Add(new SettingsCheckbox
+ {
+ LabelText = GameplaySettingsStrings.DisableWinKey,
+ Current = config.GetBindable(OsuSetting.GameplayDisableWinKey)
+ });
+ }
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs
index 42d9d48d73..120e2d908c 100644
--- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs
+++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs
@@ -1,16 +1,11 @@
// 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.Allocation;
using osu.Framework.Graphics;
-using osu.Game.Overlays.Settings.Sections.Gameplay;
-using osu.Game.Rulesets;
-using System.Linq;
using osu.Framework.Graphics.Sprites;
-using osu.Framework.Logging;
using osu.Framework.Localisation;
using osu.Game.Localisation;
+using osu.Game.Overlays.Settings.Sections.Gameplay;
namespace osu.Game.Overlays.Settings.Sections
{
@@ -20,7 +15,7 @@ namespace osu.Game.Overlays.Settings.Sections
public override Drawable CreateIcon() => new SpriteIcon
{
- Icon = FontAwesome.Regular.Circle
+ Icon = FontAwesome.Regular.DotCircle
};
public GameplaySection()
@@ -28,27 +23,13 @@ namespace osu.Game.Overlays.Settings.Sections
Children = new Drawable[]
{
new GeneralSettings(),
+ new AudioSettings(),
+ new BeatmapSettings(),
+ new BackgroundSettings(),
+ new HUDSettings(),
+ new InputSettings(),
new ModsSettings(),
};
}
-
- [BackgroundDependencyLoader]
- private void load(RulesetStore rulesets)
- {
- foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance()))
- {
- try
- {
- SettingsSubsection section = ruleset.CreateSettings();
-
- if (section != null)
- Add(section);
- }
- catch (Exception e)
- {
- Logger.Error(e, "Failed to load ruleset settings");
- }
- }
- }
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs
similarity index 67%
rename from osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs
rename to osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs
index 20b1d8d801..dbb9ddc1c1 100644
--- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs
@@ -9,25 +9,15 @@ using osu.Game.Localisation;
namespace osu.Game.Overlays.Settings.Sections.Graphics
{
- public class DetailSettings : SettingsSubsection
+ public class ScreenshotSettings : SettingsSubsection
{
- protected override LocalisableString Header => GraphicsSettingsStrings.DetailSettingsHeader;
+ protected override LocalisableString Header => GraphicsSettingsStrings.Screenshots;
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
Children = new Drawable[]
{
- new SettingsCheckbox
- {
- LabelText = GraphicsSettingsStrings.StoryboardVideo,
- Current = config.GetBindable(OsuSetting.ShowStoryboard)
- },
- new SettingsCheckbox
- {
- LabelText = GraphicsSettingsStrings.HitLighting,
- Current = config.GetBindable(OsuSetting.HitLighting)
- },
new SettingsEnumDropdown
{
LabelText = GraphicsSettingsStrings.ScreenshotFormat,
diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs
index fd0718f9f2..591848506a 100644
--- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs
+++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs
@@ -22,9 +22,9 @@ namespace osu.Game.Overlays.Settings.Sections
{
Children = new Drawable[]
{
- new RendererSettings(),
new LayoutSettings(),
- new DetailSettings(),
+ new RendererSettings(),
+ new ScreenshotSettings(),
};
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs
new file mode 100644
index 0000000000..b9339d5299
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs
@@ -0,0 +1,44 @@
+// 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.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Localisation;
+using osu.Framework.Logging;
+using osu.Game.Rulesets;
+using osu.Game.Localisation;
+
+namespace osu.Game.Overlays.Settings.Sections
+{
+ public class RulesetSection : SettingsSection
+ {
+ public override LocalisableString Header => RulesetSettingsStrings.Rulesets;
+
+ public override Drawable CreateIcon() => new SpriteIcon
+ {
+ Icon = FontAwesome.Solid.Chess
+ };
+
+ [BackgroundDependencyLoader]
+ private void load(RulesetStore rulesets)
+ {
+ foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance()))
+ {
+ try
+ {
+ SettingsSubsection section = ruleset.CreateSettings();
+
+ if (section != null)
+ Add(section);
+ }
+ catch (Exception e)
+ {
+ Logger.Error(e, "Failed to load ruleset settings");
+ }
+ }
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
index d18099eb0a..00198235c5 100644
--- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs
+++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
@@ -64,39 +64,16 @@ namespace osu.Game.Overlays.Settings.Sections
{
Children = new Drawable[]
{
- skinDropdown = new SkinSettingsDropdown(),
+ skinDropdown = new SkinSettingsDropdown
+ {
+ LabelText = SkinSettingsStrings.CurrentSkin
+ },
new SettingsButton
{
Text = SkinSettingsStrings.SkinLayoutEditor,
Action = () => skinEditor?.Toggle(),
},
new ExportSkinButton(),
- new SettingsSlider
- {
- LabelText = SkinSettingsStrings.GameplayCursorSize,
- Current = config.GetBindable(OsuSetting.GameplayCursorSize),
- KeyboardStep = 0.01f
- },
- new SettingsCheckbox
- {
- LabelText = SkinSettingsStrings.AutoCursorSize,
- Current = config.GetBindable(OsuSetting.AutoCursorSize)
- },
- new SettingsCheckbox
- {
- LabelText = SkinSettingsStrings.BeatmapSkins,
- Current = config.GetBindable(OsuSetting.BeatmapSkins)
- },
- new SettingsCheckbox
- {
- LabelText = SkinSettingsStrings.BeatmapColours,
- Current = config.GetBindable(OsuSetting.BeatmapColours)
- },
- new SettingsCheckbox
- {
- LabelText = SkinSettingsStrings.BeatmapHitsounds,
- Current = config.GetBindable(OsuSetting.BeatmapHitsounds)
- },
};
managerUpdated = skins.ItemUpdated.GetBoundCopy();
diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs
index 55e8aee266..c84cba8189 100644
--- a/osu.Game/Overlays/SettingsOverlay.cs
+++ b/osu.Game/Overlays/SettingsOverlay.cs
@@ -24,12 +24,13 @@ namespace osu.Game.Overlays
protected override IEnumerable CreateSections() => new SettingsSection[]
{
new GeneralSection(),
- new GraphicsSection(),
- new AudioSection(),
+ new SkinSection(),
new InputSection(createSubPanel(new KeyBindingPanel())),
new UserInterfaceSection(),
new GameplaySection(),
- new SkinSection(),
+ new RulesetSection(),
+ new AudioSection(),
+ new GraphicsSection(),
new OnlineSection(),
new MaintenanceSection(),
new DebugSection(),
diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
index e9865f6c8b..c0b339a231 100644
--- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
+++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
@@ -55,7 +55,10 @@ namespace osu.Game.Rulesets.UI
///
/// The current direction of playback to be exposed to frame stable children.
///
- private int direction;
+ ///
+ /// Initially it is presumed that playback will proceed in the forward direction.
+ ///
+ private int direction = 1;
[BackgroundDependencyLoader(true)]
private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler)
@@ -139,7 +142,9 @@ namespace osu.Game.Rulesets.UI
state = PlaybackState.NotValid;
}
- if (state == PlaybackState.Valid)
+ // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously.
+ // this avoids spurious flips in direction from -1 to 1 during rewinds.
+ if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime)
direction = proposedTime >= manualClock.CurrentTime ? 1 : -1;
double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime);
diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs
index e250791b72..ea158c5789 100644
--- a/osu.Game/Screens/Play/FailAnimation.cs
+++ b/osu.Game/Screens/Play/FailAnimation.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Screens.Play
{
///
/// Manage the animation to be applied when a player fails.
- /// Single file; automatically disposed after use.
+ /// Single use and automatically disposed after use.
///
public class FailAnimation : CompositeDrawable
{
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 090210e611..444bea049b 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -947,7 +947,7 @@ namespace osu.Game.Screens.Play
public override void OnSuspending(IScreen next)
{
- screenSuspension?.Expire();
+ screenSuspension?.RemoveAndDisposeImmediately();
fadeOut();
base.OnSuspending(next);
@@ -955,7 +955,8 @@ namespace osu.Game.Screens.Play
public override bool OnExiting(IScreen next)
{
- screenSuspension?.Expire();
+ screenSuspension?.RemoveAndDisposeImmediately();
+ failAnimation?.RemoveAndDisposeImmediately();
// if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap.
if (prepareScoreForDisplayTask == null)
diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs
index 94a61a4ef3..cf5bff57cf 100644
--- a/osu.Game/Screens/Play/PlayerLoader.cs
+++ b/osu.Game/Screens/Play/PlayerLoader.cs
@@ -35,6 +35,8 @@ namespace osu.Game.Screens.Play
{
protected const float BACKGROUND_BLUR = 15;
+ private const double content_out_duration = 300;
+
public override bool HideOverlaysOnEnter => hideOverlays;
public override bool DisallowExternalBeatmapRulesetChanges => true;
@@ -135,36 +137,39 @@ namespace osu.Game.Screens.Play
muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce);
batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce);
- InternalChild = (content = new LogoTrackingContainer
+ InternalChildren = new Drawable[]
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- RelativeSizeAxes = Axes.Both,
- }).WithChildren(new Drawable[]
- {
- MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade)
+ (content = new LogoTrackingContainer
{
- Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- },
- PlayerSettings = new FillFlowContainer
+ RelativeSizeAxes = Axes.Both,
+ }).WithChildren(new Drawable[]
{
- Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
- AutoSizeAxes = Axes.Both,
- Direction = FillDirection.Vertical,
- Spacing = new Vector2(0, 20),
- Margin = new MarginPadding(25),
- Children = new PlayerSettingsGroup[]
+ MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade)
{
- VisualSettings = new VisualSettings(),
- new InputSettings()
- }
- },
- idleTracker = new IdleTracker(750),
+ Alpha = 0,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ },
+ PlayerSettings = new FillFlowContainer
+ {
+ Anchor = Anchor.TopRight,
+ Origin = Anchor.TopRight,
+ AutoSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 20),
+ Margin = new MarginPadding(25),
+ Children = new PlayerSettingsGroup[]
+ {
+ VisualSettings = new VisualSettings(),
+ new InputSettings()
+ }
+ },
+ idleTracker = new IdleTracker(750),
+ }),
lowPassFilter = new AudioFilter(audio.TrackMixer)
- });
+ };
if (Beatmap.Value.BeatmapInfo.EpilepsyWarning)
{
@@ -195,7 +200,6 @@ namespace osu.Game.Screens.Play
epilepsyWarning.DimmableBackground = b;
});
- lowPassFilter.CutoffTo(500, 100, Easing.OutCubic);
Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
content.ScaleTo(0.7f);
@@ -240,15 +244,15 @@ namespace osu.Game.Screens.Play
public override bool OnExiting(IScreen next)
{
cancelLoad();
+ contentOut();
- content.ScaleTo(0.7f, 150, Easing.InQuint);
- this.FadeOut(150);
+ // Ensure the screen doesn't expire until all the outwards fade operations have completed.
+ this.Delay(content_out_duration).FadeOut();
ApplyToBackground(b => b.IgnoreUserSettings.Value = true);
BackgroundBrightnessReduction = false;
Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment);
- lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic);
return base.OnExiting(next);
}
@@ -344,6 +348,7 @@ namespace osu.Game.Screens.Play
content.FadeInFromZero(400);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
+ lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint);
ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint));
}
@@ -353,8 +358,9 @@ namespace osu.Game.Screens.Play
// Ensure the logo is no longer tracking before we scale the content
content.StopTracking();
- content.ScaleTo(0.7f, 300, Easing.InQuint);
- content.FadeOut(250);
+ content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint);
+ content.FadeOut(content_out_duration, Easing.OutQuint);
+ lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration);
}
private void pushWhenLoaded()
@@ -381,7 +387,7 @@ namespace osu.Game.Screens.Play
contentOut();
- TransformSequence pushSequence = this.Delay(250);
+ TransformSequence pushSequence = this.Delay(content_out_duration);
// only show if the warning was created (i.e. the beatmap needs it)
// and this is not a restart of the map (the warning expires after first load).
@@ -400,6 +406,11 @@ namespace osu.Game.Screens.Play
})
.Delay(EpilepsyWarning.FADE_DURATION);
}
+ else
+ {
+ // This goes hand-in-hand with the restoration of low pass filter in contentOut().
+ this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic);
+ }
pushSequence.Schedule(() =>
{
diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs
index ba3e2bf6ad..a5ed0fc990 100644
--- a/osu.Game/Skinning/ISkinSource.cs
+++ b/osu.Game/Skinning/ISkinSource.cs
@@ -12,6 +12,9 @@ namespace osu.Game.Skinning
///
public interface ISkinSource : ISkin
{
+ ///
+ /// Fired whenever a source change occurs, signalling that consumers should re-query as required.
+ ///
event Action SourceChanged;
///
diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs
index f5a7788359..b884794739 100644
--- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs
+++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs
@@ -58,10 +58,8 @@ namespace osu.Game.Skinning
return base.CreateChildDependencies(parent);
}
- protected override void OnSourceChanged()
+ protected override void RefreshSources()
{
- ResetSources();
-
// Populate a local list first so we can adjust the returned order as we go.
var sources = new List();
@@ -91,8 +89,7 @@ namespace osu.Game.Skinning
else
sources.Add(rulesetResourcesSkin);
- foreach (var skin in sources)
- AddSource(skin);
+ SetSources(sources);
}
protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin)
diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs
index ada6e4b788..c8e4c2c7b6 100644
--- a/osu.Game/Skinning/SkinProvidingContainer.cs
+++ b/osu.Game/Skinning/SkinProvidingContainer.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio.Sample;
@@ -40,10 +41,12 @@ namespace osu.Game.Skinning
protected virtual bool AllowColourLookup => true;
+ private readonly object sourceSetLock = new object();
+
///
/// A dictionary mapping each source to a wrapper which handles lookup allowances.
///
- private readonly List<(ISkin skin, DisableableSkinSource wrapped)> skinSources = new List<(ISkin, DisableableSkinSource)>();
+ private (ISkin skin, DisableableSkinSource wrapped)[] skinSources = Array.Empty<(ISkin skin, DisableableSkinSource wrapped)>();
///
/// Constructs a new initialised with a single skin source.
@@ -52,7 +55,7 @@ namespace osu.Game.Skinning
: this()
{
if (skin != null)
- AddSource(skin);
+ SetSources(new[] { skin });
}
///
@@ -168,49 +171,42 @@ namespace osu.Game.Skinning
}
///
- /// Add a new skin to this provider. Will be added to the end of the lookup order precedence.
+ /// Replace the sources used for lookups in this container.
///
- /// The skin to add.
- protected void AddSource(ISkin skin)
+ ///
+ /// This does not implicitly fire a event. Consider calling if required.
+ ///
+ /// The new sources.
+ protected void SetSources(IEnumerable sources)
{
- skinSources.Add((skin, new DisableableSkinSource(skin, this)));
+ lock (sourceSetLock)
+ {
+ foreach (var skin in skinSources)
+ {
+ if (skin.skin is ISkinSource source)
+ source.SourceChanged -= TriggerSourceChanged;
+ }
- if (skin is ISkinSource source)
- source.SourceChanged += TriggerSourceChanged;
+ skinSources = sources.Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray();
+
+ foreach (var skin in skinSources)
+ {
+ if (skin.skin is ISkinSource source)
+ source.SourceChanged += TriggerSourceChanged;
+ }
+ }
}
///
- /// Remove a skin from this provider.
- ///
- /// The skin to remove.
- protected void RemoveSource(ISkin skin)
- {
- if (skinSources.RemoveAll(s => s.skin == skin) == 0)
- return;
-
- if (skin is ISkinSource source)
- source.SourceChanged -= TriggerSourceChanged;
- }
-
- ///
- /// Clears all skin sources.
- ///
- protected void ResetSources()
- {
- foreach (var i in skinSources.ToArray())
- RemoveSource(i.skin);
- }
-
- ///
- /// Invoked when any source has changed (either or a source registered via ).
+ /// Invoked after any consumed source change, before the external event is fired.
/// This is also invoked once initially during to ensure sources are ready for children consumption.
///
- protected virtual void OnSourceChanged() { }
+ protected virtual void RefreshSources() { }
protected void TriggerSourceChanged()
{
// Expose to implementations, giving them a chance to react before notifying external consumers.
- OnSourceChanged();
+ RefreshSources();
SourceChanged?.Invoke();
}
diff --git a/osu.Game/Stores/RealmFileStore.cs b/osu.Game/Stores/RealmFileStore.cs
new file mode 100644
index 0000000000..f7b7471634
--- /dev/null
+++ b/osu.Game/Stores/RealmFileStore.cs
@@ -0,0 +1,116 @@
+// 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.IO;
+using System.Linq;
+using osu.Framework.Extensions;
+using osu.Framework.IO.Stores;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Database;
+using osu.Game.Models;
+using Realms;
+
+#nullable enable
+
+namespace osu.Game.Stores
+{
+ ///
+ /// Handles the storing of files to the file system (and database) backing.
+ ///
+ [ExcludeFromDynamicCompile]
+ public class RealmFileStore
+ {
+ private readonly RealmContextFactory realmFactory;
+
+ public readonly IResourceStore Store;
+
+ public readonly Storage Storage;
+
+ public RealmFileStore(RealmContextFactory realmFactory, Storage storage)
+ {
+ this.realmFactory = realmFactory;
+
+ Storage = storage.GetStorageForDirectory(@"files");
+ Store = new StorageBackedResourceStore(Storage);
+ }
+
+ ///
+ /// Add a new file to the game-wide database, copying it to permanent storage if not already present.
+ ///
+ /// The file data stream.
+ /// The realm instance to add to. Should already be in a transaction.
+ ///
+ public RealmFile Add(Stream data, Realm realm)
+ {
+ string hash = data.ComputeSHA2Hash();
+
+ var existing = realm.Find(hash);
+
+ var file = existing ?? new RealmFile { Hash = hash };
+
+ if (!checkFileExistsAndMatchesHash(file))
+ copyToStore(file, data);
+
+ if (!file.IsManaged)
+ realm.Add(file);
+
+ return file;
+ }
+
+ private void copyToStore(RealmFile file, Stream data)
+ {
+ data.Seek(0, SeekOrigin.Begin);
+
+ using (var output = Storage.GetStream(file.StoragePath, FileAccess.Write))
+ data.CopyTo(output);
+
+ data.Seek(0, SeekOrigin.Begin);
+ }
+
+ private bool checkFileExistsAndMatchesHash(RealmFile file)
+ {
+ string path = file.StoragePath;
+
+ // we may be re-adding a file to fix missing store entries.
+ if (!Storage.Exists(path))
+ return false;
+
+ // even if the file already exists, check the existing checksum for safety.
+ using (var stream = Storage.GetStream(path))
+ return stream.ComputeSHA2Hash() == file.Hash;
+ }
+
+ public void Cleanup()
+ {
+ var realm = realmFactory.Context;
+
+ // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal.
+ using (var transaction = realm.BeginWrite())
+ {
+ // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707)
+ var files = realm.All().ToList();
+
+ foreach (var file in files)
+ {
+ if (file.BacklinksCount > 0)
+ continue;
+
+ try
+ {
+ Storage.Delete(file.StoragePath);
+ realm.Remove(file);
+ }
+ catch (Exception e)
+ {
+ Logger.Error(e, $@"Could not delete databased file {file.Hash}");
+ }
+ }
+
+ transaction.Commit();
+ }
+ }
+ }
+}
diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
index 64f1ee4a7a..6d63525011 100644
--- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
+++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics.Textures;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
+using osu.Game.IO.Serialization;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
@@ -109,6 +110,8 @@ namespace osu.Game.Tests.Beatmaps
{
var beatmap = GetBeatmap(name);
+ string beforeConversion = beatmap.Serialize();
+
var converterResult = new Dictionary>();
var working = new ConversionWorkingBeatmap(beatmap)
@@ -122,6 +125,10 @@ namespace osu.Game.Tests.Beatmaps
working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods);
+ string afterConversion = beatmap.Serialize();
+
+ Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap");
+
return new ConvertResult
{
Mappings = converterResult.Select(r =>
diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs
index 77db697cb6..6a11bd3fea 100644
--- a/osu.Game/Tests/Visual/OsuGameTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs
@@ -78,9 +78,11 @@ namespace osu.Game.Tests.Visual
protected void CreateGame()
{
- AddGame(Game = new TestOsuGame(LocalStorage, API));
+ AddGame(Game = CreateTestGame());
}
+ protected virtual TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API);
+
protected void PushAndConfirm(Func newScreen)
{
Screen screen = null;
@@ -135,7 +137,8 @@ namespace osu.Game.Tests.Visual
public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens);
- public TestOsuGame(Storage storage, IAPIProvider api)
+ public TestOsuGame(Storage storage, IAPIProvider api, string[] args = null)
+ : base(args)
{
Storage = storage;
API = api;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 4877ddf725..184c9d3f63 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index edce9d27fe..38b920420b 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -93,7 +93,7 @@
-
+