From 6aa894e55e0f25d184125efa649c610ef6816648 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 24 Aug 2021 18:23:02 +0900
Subject: [PATCH 01/36] Split out separate component

---
 .../Drawables/DrawableManiaHitObject.cs       |  6 --
 osu.Game.Rulesets.Mania/UI/Column.cs          | 38 ++-------
 .../Objects/Drawables/DrawableHitObject.cs    |  5 ++
 .../UI/GameplaySampleTriggerSource.cs         | 84 +++++++++++++++++++
 4 files changed, 94 insertions(+), 39 deletions(-)
 create mode 100644 osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
index 5aff4e200b..9ac223a0d7 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
@@ -6,7 +6,6 @@ using JetBrains.Annotations;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
-using osu.Game.Audio;
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Rulesets.Mania.UI;
@@ -29,11 +28,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
         [Resolved(canBeNull: true)]
         private ManiaPlayfield playfield { get; set; }
 
-        /// <summary>
-        /// Gets the samples that are played by this object during gameplay.
-        /// </summary>
-        public ISampleInfo[] GetGameplaySamples() => Samples.Samples;
-
         protected override float SamplePlaybackPosition
         {
             get
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 9b5893b268..f5e30efd91 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -1,7 +1,6 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System.Linq;
 using osuTK.Graphics;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
@@ -19,6 +18,7 @@ using osuTK;
 using osu.Game.Rulesets.Mania.Beatmaps;
 using osu.Game.Rulesets.Mania.Objects;
 using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.UI;
 
 namespace osu.Game.Rulesets.Mania.UI
 {
@@ -28,12 +28,6 @@ namespace osu.Game.Rulesets.Mania.UI
         public const float COLUMN_WIDTH = 80;
         public const float SPECIAL_COLUMN_WIDTH = 70;
 
-        /// <summary>
-        /// For hitsounds played by this <see cref="Column"/> (i.e. not as a result of hitting a hitobject),
-        /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key.
-        /// </summary>
-        private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY;
-
         /// <summary>
         /// The index of this column as part of the whole playfield.
         /// </summary>
@@ -45,10 +39,10 @@ namespace osu.Game.Rulesets.Mania.UI
         internal readonly Container TopLevelContainer;
         private readonly DrawablePool<PoolableHitExplosion> hitExplosionPool;
         private readonly OrderedHitPolicy hitPolicy;
-        private readonly Container<SkinnableSound> hitSounds;
-
         public Container UnderlayElements => HitObjectArea.UnderlayElements;
 
+        private readonly GameplaySampleTriggerSource sampleTriggerSource;
+
         public Column(int index)
         {
             Index = index;
@@ -64,6 +58,7 @@ namespace osu.Game.Rulesets.Mania.UI
             InternalChildren = new[]
             {
                 hitExplosionPool = new DrawablePool<PoolableHitExplosion>(5),
+                sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer),
                 // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements
                 background.CreateProxy(),
                 HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both },
@@ -72,12 +67,6 @@ namespace osu.Game.Rulesets.Mania.UI
                     RelativeSizeAxes = Axes.Both
                 },
                 background,
-                hitSounds = new Container<SkinnableSound>
-                {
-                    Name = "Column samples pool",
-                    RelativeSizeAxes = Axes.Both,
-                    Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray()
-                },
                 TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }
             };
 
@@ -133,29 +122,12 @@ namespace osu.Game.Rulesets.Mania.UI
             HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result)));
         }
 
-        private int nextHitSoundIndex;
-
         public bool OnPressed(ManiaAction action)
         {
             if (action != Action.Value)
                 return false;
 
-            var nextObject =
-                HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ??
-                // fallback to non-alive objects to find next off-screen object
-                HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ??
-                HitObjectContainer.Objects.LastOrDefault();
-
-            if (nextObject is DrawableManiaHitObject maniaObject)
-            {
-                var hitSound = hitSounds[nextHitSoundIndex];
-
-                hitSound.Samples = maniaObject.GetGameplaySamples();
-                hitSound.Play();
-
-                nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds;
-            }
-
+            sampleTriggerSource.Play();
             return true;
         }
 
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index 29d8a475ef..b3e1b24d8d 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -54,6 +54,11 @@ namespace osu.Game.Rulesets.Objects.Drawables
         /// </summary>
         public readonly Bindable<Color4> AccentColour = new Bindable<Color4>(Color4.Gray);
 
+        /// <summary>
+        /// Gets the samples that are played by this object during gameplay.
+        /// </summary>
+        public ISampleInfo[] GetGameplaySamples() => Samples.Samples;
+
         protected PausableSkinnableSound Samples { get; private set; }
 
         public virtual IEnumerable<HitSampleInfo> GetSamples() => HitObject.Samples;
diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
new file mode 100644
index 0000000000..fedbcd541c
--- /dev/null
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -0,0 +1,84 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Audio;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Skinning;
+
+namespace osu.Game.Rulesets.UI
+{
+    /// <summary>
+    /// A component which can trigger the most appropriate hit sound for a given point in time, based on the state of a <see cref="HitObjectContainer"/>
+    /// </summary>
+    public class GameplaySampleTriggerSource : CompositeDrawable
+    {
+        private readonly HitObjectContainer hitObjectContainer;
+
+        private int nextHitSoundIndex;
+
+        /// <summary>
+        /// The number of concurrent samples allowed to be played concurrently so that it feels better when spam-pressing a key.
+        /// </summary>
+        private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY;
+
+        private readonly Container<SkinnableSound> hitSounds;
+
+        [Resolved]
+        private DrawableRuleset drawableRuleset { get; set; }
+
+        public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer)
+        {
+            this.hitObjectContainer = hitObjectContainer;
+            InternalChildren = new Drawable[]
+            {
+                hitSounds = new Container<SkinnableSound>
+                {
+                    Name = "concurrent sample pool",
+                    RelativeSizeAxes = Axes.Both,
+                    Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray()
+                },
+            };
+        }
+
+        private ISampleInfo[] playableSampleInfo;
+
+        /// <summary>
+        /// Play the most appropriate hit sound for the current point in time.
+        /// </summary>
+        public void Play()
+        {
+            var nextObject =
+                hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject ??
+                // fallback to non-alive objects to find next off-screen object
+                // TODO: make lookup more efficient?
+                drawableRuleset.Objects.FirstOrDefault(h => h.StartTime > Time.Current) ??
+                drawableRuleset.Objects.LastOrDefault();
+
+            if (nextObject != null)
+            {
+                var hitSound = getNextSample();
+                playableSampleInfo = GetPlayableSampleInfo(nextObject);
+                hitSound.Samples = playableSampleInfo;
+                hitSound.Play();
+            }
+        }
+
+        protected virtual ISampleInfo[] GetPlayableSampleInfo(HitObject nextObject) =>
+            // TODO: avoid cast somehow?
+            nextObject.Samples.Cast<ISampleInfo>().ToArray();
+
+        private SkinnableSound getNextSample()
+        {
+            var hitSound = hitSounds[nextHitSoundIndex];
+
+            // round robin over available samples to allow for concurrent playback.
+            nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds;
+
+            return hitSound;
+        }
+    }
+}

From 4a294d4de47c9cef709457ee8cf8357be11aa894 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 24 Aug 2021 19:05:59 +0900
Subject: [PATCH 02/36] Optimise fallback logic to reduce lookups to bare
 minimum

---
 .../UI/GameplaySampleTriggerSource.cs         | 36 ++++++++++++-------
 1 file changed, 24 insertions(+), 12 deletions(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index fedbcd541c..51f3052509 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -44,32 +44,44 @@ namespace osu.Game.Rulesets.UI
             };
         }
 
-        private ISampleInfo[] playableSampleInfo;
+        private HitObject fallbackObject;
 
         /// <summary>
         /// Play the most appropriate hit sound for the current point in time.
         /// </summary>
         public void Play()
         {
-            var nextObject =
-                hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject ??
-                // fallback to non-alive objects to find next off-screen object
-                // TODO: make lookup more efficient?
-                drawableRuleset.Objects.FirstOrDefault(h => h.StartTime > Time.Current) ??
-                drawableRuleset.Objects.LastOrDefault();
+            var nextObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
+
+            if (nextObject == null)
+            {
+                if (fallbackObject == null || fallbackObject.StartTime < Time.Current)
+                {
+                    // in the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
+                    // note that we don't want to cache the object if it is an alive object, as once it is hit we don't want to continue playing its sound.
+                    // check whether we can use the previous computed sample.
+
+                    // fallback to non-alive objects to find next off-screen object
+                    // TODO: make lookup more efficient?
+                    fallbackObject = hitObjectContainer.Entries
+                                                       .Where(e => e.Result?.HasResult != true && e.HitObject.StartTime > Time.Current)?
+                                                       .OrderBy(e => e.HitObject.StartTime)
+                                                       .FirstOrDefault()?.HitObject ?? hitObjectContainer.Entries.FirstOrDefault()?.HitObject;
+                }
+
+                nextObject = fallbackObject;
+            }
 
             if (nextObject != null)
             {
                 var hitSound = getNextSample();
-                playableSampleInfo = GetPlayableSampleInfo(nextObject);
-                hitSound.Samples = playableSampleInfo;
+                hitSound.Samples = GetPlayableSampleInfo(nextObject).Select(s => nextObject.SampleControlPoint.ApplyTo(s)).Cast<ISampleInfo>().ToArray();
                 hitSound.Play();
             }
         }
 
-        protected virtual ISampleInfo[] GetPlayableSampleInfo(HitObject nextObject) =>
-            // TODO: avoid cast somehow?
-            nextObject.Samples.Cast<ISampleInfo>().ToArray();
+        protected virtual HitSampleInfo[] GetPlayableSampleInfo(HitObject nextObject) =>
+            nextObject.Samples.ToArray();
 
         private SkinnableSound getNextSample()
         {

From 681215e5b58a799edc1c9e4098e9a781a5011105 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 14:57:41 +0900
Subject: [PATCH 03/36] Rewrite object lookup to use previous entry regardless

This changes the fallback logic to always prefer the previous resolved
lifetime entry rather than fallback to the first entry ever. I think
this is more correct in all cases.

Also rewrites the inline comments to hopefully be easier to parse.
---
 .../UI/GameplaySampleTriggerSource.cs         | 38 +++++++++++--------
 1 file changed, 22 insertions(+), 16 deletions(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index 51f3052509..1713104f01 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -16,15 +16,15 @@ namespace osu.Game.Rulesets.UI
     /// </summary>
     public class GameplaySampleTriggerSource : CompositeDrawable
     {
-        private readonly HitObjectContainer hitObjectContainer;
-
-        private int nextHitSoundIndex;
-
         /// <summary>
         /// The number of concurrent samples allowed to be played concurrently so that it feels better when spam-pressing a key.
         /// </summary>
         private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY;
 
+        private readonly HitObjectContainer hitObjectContainer;
+
+        private int nextHitSoundIndex;
+
         private readonly Container<SkinnableSound> hitSounds;
 
         [Resolved]
@@ -44,32 +44,38 @@ namespace osu.Game.Rulesets.UI
             };
         }
 
-        private HitObject fallbackObject;
+        private HitObjectLifetimeEntry fallbackObject;
 
         /// <summary>
         /// Play the most appropriate hit sound for the current point in time.
         /// </summary>
         public void Play()
         {
+            // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
             var nextObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
 
+            // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
             if (nextObject == null)
             {
-                if (fallbackObject == null || fallbackObject.StartTime < Time.Current)
+                // This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
+                if (fallbackObject == null || fallbackObject.HitObject.StartTime < Time.Current || fallbackObject.Result.IsHit)
                 {
-                    // in the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
-                    // note that we don't want to cache the object if it is an alive object, as once it is hit we don't want to continue playing its sound.
-                    // check whether we can use the previous computed sample.
+                    // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty).
+                    // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
+                    var lookup = hitObjectContainer.Entries
+                                                   .Where(e => e.Result?.HasResult != true && e.HitObject.StartTime > Time.Current)
+                                                   .OrderBy(e => e.HitObject.StartTime)
+                                                   .FirstOrDefault();
 
-                    // fallback to non-alive objects to find next off-screen object
-                    // TODO: make lookup more efficient?
-                    fallbackObject = hitObjectContainer.Entries
-                                                       .Where(e => e.Result?.HasResult != true && e.HitObject.StartTime > Time.Current)?
-                                                       .OrderBy(e => e.HitObject.StartTime)
-                                                       .FirstOrDefault()?.HitObject ?? hitObjectContainer.Entries.FirstOrDefault()?.HitObject;
+                    // If the lookup failed, use the previously resolved lookup (we still want to play a sound, and it is still likely the most valid result).
+                    if (lookup != null)
+                        fallbackObject = lookup;
+
+                    // If we still can't find anything, just play whatever we can to get a sound out.
+                    fallbackObject ??= hitObjectContainer.Entries.FirstOrDefault();
                 }
 
-                nextObject = fallbackObject;
+                nextObject = fallbackObject?.HitObject;
             }
 
             if (nextObject != null)

From a1936b141bb3acf91e79ca6725d14b5300e114fc Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 15:24:01 +0900
Subject: [PATCH 04/36] Refactor base class to allow correct usage in taiko
 drum

---
 .../UI/GameplaySampleTriggerSource.cs         | 36 +++++++++++++------
 1 file changed, 25 insertions(+), 11 deletions(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index 1713104f01..015c85beb9 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -49,7 +49,29 @@ namespace osu.Game.Rulesets.UI
         /// <summary>
         /// Play the most appropriate hit sound for the current point in time.
         /// </summary>
-        public void Play()
+        public virtual void Play()
+        {
+            var nextObject = GetMostValidObject();
+
+            if (nextObject == null)
+                return;
+
+            var samples = nextObject.Samples
+                                    .Select(s => nextObject.SampleControlPoint.ApplyTo(s))
+                                    .Cast<ISampleInfo>()
+                                    .ToArray();
+
+            PlaySamples(samples);
+        }
+
+        protected void PlaySamples(ISampleInfo[] samples)
+        {
+            var hitSound = getNextSample();
+            hitSound.Samples = samples;
+            hitSound.Play();
+        }
+
+        protected HitObject GetMostValidObject()
         {
             // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
             var nextObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
@@ -58,7 +80,7 @@ namespace osu.Game.Rulesets.UI
             if (nextObject == null)
             {
                 // This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
-                if (fallbackObject == null || fallbackObject.HitObject.StartTime < Time.Current || fallbackObject.Result.IsHit)
+                if (fallbackObject == null || fallbackObject.HitObject.StartTime < Time.Current || fallbackObject.Result?.IsHit == true)
                 {
                     // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty).
                     // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
@@ -78,17 +100,9 @@ namespace osu.Game.Rulesets.UI
                 nextObject = fallbackObject?.HitObject;
             }
 
-            if (nextObject != null)
-            {
-                var hitSound = getNextSample();
-                hitSound.Samples = GetPlayableSampleInfo(nextObject).Select(s => nextObject.SampleControlPoint.ApplyTo(s)).Cast<ISampleInfo>().ToArray();
-                hitSound.Play();
-            }
+            return nextObject;
         }
 
-        protected virtual HitSampleInfo[] GetPlayableSampleInfo(HitObject nextObject) =>
-            nextObject.Samples.ToArray();
-
         private SkinnableSound getNextSample()
         {
             var hitSound = hitSounds[nextHitSoundIndex];

From 8e0a04c4e5c2f4d8a40867856f90832f34b6e500 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 15:24:13 +0900
Subject: [PATCH 05/36] Update taiko `InputDrum` to use new trigger logic

---
 .../Skinning/TestSceneDrawableBarLine.cs      |   4 +-
 .../Skinning/TestSceneInputDrum.cs            |  10 +-
 .../Skinning/TestSceneTaikoPlayfield.cs       |   2 +-
 .../Audio/DrumSampleContainer.cs              | 104 ------------------
 .../Skinning/Legacy/LegacyInputDrum.cs        |  10 +-
 .../UI/DrawableTaikoRuleset.cs                |   2 +-
 osu.Game.Rulesets.Taiko/UI/InputDrum.cs       |  43 ++++++--
 osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs  |  10 +-
 8 files changed, 49 insertions(+), 136 deletions(-)
 delete mode 100644 osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs

diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs
index f9b8e9a985..269a855219 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs
@@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
                     Origin = Anchor.Centre,
                     Children = new Drawable[]
                     {
-                        new TaikoPlayfield(new ControlPointInfo()),
+                        new TaikoPlayfield(),
                         hoc = new ScrollingHitObjectContainer()
                     }
                 };
@@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
                     Origin = Anchor.Centre,
                     Children = new Drawable[]
                     {
-                        new TaikoPlayfield(new ControlPointInfo()),
+                        new TaikoPlayfield(),
                         hoc = new ScrollingHitObjectContainer()
                     }
                 };
diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs
index 055a292fe8..24db046748 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs
@@ -5,7 +5,6 @@ using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Rulesets.Taiko.UI;
 using osuTK;
 
@@ -17,6 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
         [BackgroundDependencyLoader]
         private void load()
         {
+            var playfield = new TaikoPlayfield();
+
+            var beatmap = CreateWorkingBeatmap(new TaikoRuleset().RulesetInfo).GetPlayableBeatmap(new TaikoRuleset().RulesetInfo);
+
+            foreach (var h in beatmap.HitObjects)
+                playfield.Add(h);
+
             SetContents(_ => new TaikoInputManager(new TaikoRuleset().RulesetInfo)
             {
                 RelativeSizeAxes = Axes.Both,
@@ -25,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
                     Size = new Vector2(200),
-                    Child = new InputDrum(new ControlPointInfo())
+                    Child = new InputDrum(playfield.HitObjectContainer)
                 }
             });
         }
diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs
index f96297a06d..6f2fcd08f1 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
                 Beatmap.Value.Track.Start();
             });
 
-            AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield(new ControlPointInfo())
+            AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield
             {
                 Anchor = Anchor.CentreLeft,
                 Origin = Anchor.CentreLeft,
diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs
deleted file mode 100644
index e4dc261363..0000000000
--- a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System.Collections.Generic;
-using System.Linq;
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Game.Audio;
-using osu.Game.Beatmaps.ControlPoints;
-using osu.Game.Skinning;
-
-namespace osu.Game.Rulesets.Taiko.Audio
-{
-    /// <summary>
-    /// Stores samples for the input drum.
-    /// The lifetime of the samples is adjusted so that they are only alive during the appropriate sample control point.
-    /// </summary>
-    public class DrumSampleContainer : LifetimeManagementContainer
-    {
-        private readonly ControlPointInfo controlPoints;
-        private readonly Dictionary<double, DrumSample> mappings = new Dictionary<double, DrumSample>();
-
-        private readonly IBindableList<SampleControlPoint> samplePoints = new BindableList<SampleControlPoint>();
-
-        public DrumSampleContainer(ControlPointInfo controlPoints)
-        {
-            this.controlPoints = controlPoints;
-        }
-
-        [BackgroundDependencyLoader]
-        private void load()
-        {
-            samplePoints.BindTo(controlPoints.SamplePoints);
-            samplePoints.BindCollectionChanged((_, __) => recreateMappings(), true);
-        }
-
-        private void recreateMappings()
-        {
-            mappings.Clear();
-            ClearInternal();
-
-            SampleControlPoint[] points = samplePoints.Count == 0
-                ? new[] { controlPoints.SamplePointAt(double.MinValue) }
-                : samplePoints.ToArray();
-
-            for (int i = 0; i < points.Length; i++)
-            {
-                var samplePoint = points[i];
-
-                var lifetimeStart = i > 0 ? samplePoint.Time : double.MinValue;
-                var lifetimeEnd = i + 1 < points.Length ? points[i + 1].Time : double.MaxValue;
-
-                AddInternal(mappings[samplePoint.Time] = new DrumSample(samplePoint)
-                {
-                    LifetimeStart = lifetimeStart,
-                    LifetimeEnd = lifetimeEnd
-                });
-            }
-        }
-
-        public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time];
-
-        public class DrumSample : CompositeDrawable
-        {
-            public override bool RemoveWhenNotAlive => false;
-
-            public PausableSkinnableSound Centre { get; private set; }
-            public PausableSkinnableSound Rim { get; private set; }
-
-            private readonly SampleControlPoint samplePoint;
-
-            private Bindable<string> sampleBank;
-            private BindableNumber<int> sampleVolume;
-
-            public DrumSample(SampleControlPoint samplePoint)
-            {
-                this.samplePoint = samplePoint;
-            }
-
-            [BackgroundDependencyLoader]
-            private void load()
-            {
-                sampleBank = samplePoint.SampleBankBindable.GetBoundCopy();
-                sampleBank.BindValueChanged(_ => recreate());
-
-                sampleVolume = samplePoint.SampleVolumeBindable.GetBoundCopy();
-                sampleVolume.BindValueChanged(_ => recreate());
-
-                recreate();
-            }
-
-            private void recreate()
-            {
-                InternalChildren = new Drawable[]
-                {
-                    Centre = new PausableSkinnableSound(samplePoint.GetSampleInfo()),
-                    Rim = new PausableSkinnableSound(samplePoint.GetSampleInfo(HitSampleInfo.HIT_CLAP))
-                };
-            }
-        }
-    }
-}
diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
index 795885d4b9..5a76694913 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
@@ -7,7 +7,8 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Input.Bindings;
-using osu.Game.Rulesets.Taiko.Audio;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.Taiko.UI;
 using osu.Game.Skinning;
 using osuTK;
 
@@ -111,7 +112,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
             public readonly Sprite Centre;
 
             [Resolved]
-            private DrumSampleContainer sampleContainer { get; set; }
+            private InputDrum.DrumSampleTriggerSource sampleTriggerSource { get; set; }
 
             public LegacyHalfDrum(bool flipped)
             {
@@ -143,17 +144,16 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
             public bool OnPressed(TaikoAction action)
             {
                 Drawable target = null;
-                var drumSample = sampleContainer.SampleAt(Time.Current);
 
                 if (action == CentreAction)
                 {
                     target = Centre;
-                    drumSample.Centre?.Play();
+                    sampleTriggerSource.Play(HitType.Centre);
                 }
                 else if (action == RimAction)
                 {
                     target = Rim;
-                    drumSample.Rim?.Play();
+                    sampleTriggerSource.Play(HitType.Rim);
                 }
 
                 if (target != null)
diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
index 650ce1f5a3..6ddbf3c16b 100644
--- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
@@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Taiko.UI
 
         protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo);
 
-        protected override Playfield CreatePlayfield() => new TaikoPlayfield(Beatmap.ControlPointInfo);
+        protected override Playfield CreatePlayfield() => new TaikoPlayfield();
 
         public override DrawableHitObject<TaikoHitObject> CreateDrawableRepresentation(TaikoHitObject h) => null;
 
diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
index 1ca1be1bdf..24e2dddb49 100644
--- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
+++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
@@ -2,18 +2,19 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
-using osuTK;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.Input.Bindings;
-using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Audio;
 using osu.Game.Graphics;
-using osu.Game.Rulesets.Taiko.Audio;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.UI;
 using osu.Game.Screens.Play;
 using osu.Game.Skinning;
+using osuTK;
 
 namespace osu.Game.Rulesets.Taiko.UI
 {
@@ -25,11 +26,11 @@ namespace osu.Game.Rulesets.Taiko.UI
         private const float middle_split = 0.025f;
 
         [Cached]
-        private DrumSampleContainer sampleContainer;
+        private DrumSampleTriggerSource sampleTriggerSource;
 
-        public InputDrum(ControlPointInfo controlPoints)
+        public InputDrum(HitObjectContainer hitObjectContainer)
         {
-            sampleContainer = new DrumSampleContainer(controlPoints);
+            sampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer);
 
             RelativeSizeAxes = Axes.Both;
         }
@@ -70,7 +71,7 @@ namespace osu.Game.Rulesets.Taiko.UI
                         }
                     }
                 }),
-                sampleContainer
+                sampleTriggerSource
             };
         }
 
@@ -95,7 +96,7 @@ namespace osu.Game.Rulesets.Taiko.UI
             private readonly Sprite centreHit;
 
             [Resolved]
-            private DrumSampleContainer sampleContainer { get; set; }
+            private DrumSampleTriggerSource sampleTriggerSource { get; set; }
 
             public TaikoHalfDrum(bool flipped)
             {
@@ -156,21 +157,19 @@ namespace osu.Game.Rulesets.Taiko.UI
                 Drawable target = null;
                 Drawable back = null;
 
-                var drumSample = sampleContainer.SampleAt(Time.Current);
-
                 if (action == CentreAction)
                 {
                     target = centreHit;
                     back = centre;
 
-                    drumSample.Centre?.Play();
+                    sampleTriggerSource.Play(HitType.Centre);
                 }
                 else if (action == RimAction)
                 {
                     target = rimHit;
                     back = rim;
 
-                    drumSample.Rim?.Play();
+                    sampleTriggerSource.Play(HitType.Rim);
                 }
 
                 if (target != null)
@@ -201,5 +200,25 @@ namespace osu.Game.Rulesets.Taiko.UI
             {
             }
         }
+
+        public class DrumSampleTriggerSource : GameplaySampleTriggerSource
+        {
+            public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer)
+                : base(hitObjectContainer)
+            {
+            }
+
+            public void Play(HitType hitType)
+            {
+                var hitObject = GetMostValidObject();
+
+                if (hitObject == null)
+                    return;
+
+                PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) });
+            }
+
+            public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead");
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
index 0d9e08b8b7..d650cab729 100644
--- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
@@ -8,7 +8,6 @@ using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Pooling;
-using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Graphics;
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.Judgements;
@@ -27,8 +26,6 @@ namespace osu.Game.Rulesets.Taiko.UI
 {
     public class TaikoPlayfield : ScrollingPlayfield
     {
-        private readonly ControlPointInfo controlPoints;
-
         /// <summary>
         /// Default height of a <see cref="TaikoPlayfield"/> when inside a <see cref="DrawableTaikoRuleset"/>.
         /// </summary>
@@ -56,11 +53,6 @@ namespace osu.Game.Rulesets.Taiko.UI
 
         private Container hitTargetOffsetContent;
 
-        public TaikoPlayfield(ControlPointInfo controlPoints)
-        {
-            this.controlPoints = controlPoints;
-        }
-
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
@@ -131,7 +123,7 @@ namespace osu.Game.Rulesets.Taiko.UI
                     Children = new Drawable[]
                     {
                         new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()),
-                        new InputDrum(controlPoints)
+                        new InputDrum(HitObjectContainer)
                         {
                             Anchor = Anchor.CentreLeft,
                             Origin = Anchor.CentreLeft,

From ef2b5e1c51d0fbcc4caece8588590375104a9b90 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 15:29:47 +0900
Subject: [PATCH 06/36] Tidy up variable names and unused resolved properties

---
 .../UI/GameplaySampleTriggerSource.cs         | 26 +++++++------------
 1 file changed, 9 insertions(+), 17 deletions(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index 015c85beb9..48905e7232 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -2,8 +2,6 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System.Linq;
-using osu.Framework.Allocation;
-using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Game.Audio;
 using osu.Game.Rulesets.Objects;
@@ -27,20 +25,14 @@ namespace osu.Game.Rulesets.UI
 
         private readonly Container<SkinnableSound> hitSounds;
 
-        [Resolved]
-        private DrawableRuleset drawableRuleset { get; set; }
-
         public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer)
         {
             this.hitObjectContainer = hitObjectContainer;
-            InternalChildren = new Drawable[]
+
+            InternalChild = hitSounds = new Container<SkinnableSound>
             {
-                hitSounds = new Container<SkinnableSound>
-                {
-                    Name = "concurrent sample pool",
-                    RelativeSizeAxes = Axes.Both,
-                    Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray()
-                },
+                Name = "concurrent sample pool",
+                ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound())
             };
         }
 
@@ -74,10 +66,10 @@ namespace osu.Game.Rulesets.UI
         protected HitObject GetMostValidObject()
         {
             // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
-            var nextObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
+            var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
 
             // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
-            if (nextObject == null)
+            if (hitObject == null)
             {
                 // This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
                 if (fallbackObject == null || fallbackObject.HitObject.StartTime < Time.Current || fallbackObject.Result?.IsHit == true)
@@ -97,15 +89,15 @@ namespace osu.Game.Rulesets.UI
                     fallbackObject ??= hitObjectContainer.Entries.FirstOrDefault();
                 }
 
-                nextObject = fallbackObject?.HitObject;
+                hitObject = fallbackObject?.HitObject;
             }
 
-            return nextObject;
+            return hitObject;
         }
 
         private SkinnableSound getNextSample()
         {
-            var hitSound = hitSounds[nextHitSoundIndex];
+            SkinnableSound hitSound = hitSounds[nextHitSoundIndex];
 
             // round robin over available samples to allow for concurrent playback.
             nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds;

From fc85ae0e349b7f01b4087cdef76250ac160873d8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 16:55:03 +0900
Subject: [PATCH 07/36] Add test coverage

---
 .../TestSceneGameplaySampleTriggerSource.cs   | 135 ++++++++++++++++++
 1 file changed, 135 insertions(+)
 create mode 100644 osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
new file mode 100644
index 0000000000..c446a7efd1
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
@@ -0,0 +1,135 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Audio;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.UI;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+    public class TestSceneGameplaySampleTriggerSource : PlayerTestScene
+    {
+        private TestGameplaySampleTriggerSource sampleTriggerSource;
+        protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
+
+        private Beatmap beatmap;
+
+        protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
+        {
+            beatmap = new Beatmap
+            {
+                BeatmapInfo = new BeatmapInfo
+                {
+                    BaseDifficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 },
+                    Ruleset = ruleset
+                }
+            };
+
+            const double start_offset = 8000;
+            const double spacing = 2000;
+
+            double t = start_offset;
+            beatmap.HitObjects.AddRange(new[]
+            {
+                new HitCircle
+                {
+                    // intentionally start objects a bit late so we can test the case of no alive objects.
+                    StartTime = t += spacing,
+                    Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+                },
+                new HitCircle
+                {
+                    StartTime = t += spacing,
+                    Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }
+                },
+                new HitCircle
+                {
+                    StartTime = t += spacing,
+                    Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) },
+                    SampleControlPoint = new SampleControlPoint { SampleBank = "soft" },
+                },
+                new HitCircle
+                {
+                    StartTime = t += spacing,
+                    Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) },
+                    SampleControlPoint = new SampleControlPoint { SampleBank = "soft" },
+                },
+            });
+
+            return beatmap;
+        }
+
+        public override void SetUpSteps()
+        {
+            base.SetUpSteps();
+
+            AddStep("Add trigger source", () => Player.HUDOverlay.Add(sampleTriggerSource = new TestGameplaySampleTriggerSource(Player.DrawableRuleset.Playfield.HitObjectContainer)));
+        }
+
+        [Test]
+        public void TestCorrectHitObject()
+        {
+            HitObjectLifetimeEntry nextObjectEntry = null;
+
+            AddUntilStep("no alive objects", () => getNextAliveObject() == null);
+
+            AddAssert("check initially correct object", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[0]);
+
+            AddUntilStep("get next object", () =>
+            {
+                var nextDrawableObject = getNextAliveObject();
+
+                if (nextDrawableObject != null)
+                {
+                    nextObjectEntry = nextDrawableObject.Entry;
+                    InputManager.MoveMouseTo(nextDrawableObject.ScreenSpaceDrawQuad.Centre);
+                    return true;
+                }
+
+                return false;
+            });
+
+            AddUntilStep("hit first hitobject", () =>
+            {
+                InputManager.Click(MouseButton.Left);
+                return nextObjectEntry.Result.HasResult;
+            });
+
+            AddAssert("check correct object after hit", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[1]);
+
+            AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[2]);
+            AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]);
+
+            AddUntilStep("no alive objects", () => getNextAliveObject() == null);
+            AddAssert("check correct object after none alive", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]);
+        }
+
+        private DrawableHitObject getNextAliveObject() =>
+            Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault();
+
+        [Test]
+        public void TestSampleTriggering()
+        {
+            AddRepeatStep("trigger sample", () => sampleTriggerSource.Play(), 10);
+        }
+
+        public class TestGameplaySampleTriggerSource : GameplaySampleTriggerSource
+        {
+            public TestGameplaySampleTriggerSource(HitObjectContainer hitObjectContainer)
+                : base(hitObjectContainer)
+            {
+            }
+
+            public new HitObject GetMostValidObject() => base.GetMostValidObject();
+        }
+    }
+}

From ccfff50c6f18c5a9b790e02e08cb9b16c3b6bac8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 16:55:34 +0900
Subject: [PATCH 08/36] Apply fixes in line with issues found during testing

I was trying to be too smart with caching, but if the `Play` method was
not called often enough it would have a recent reference. Unfortunately
this requires a separate query to `Entries`, but is also a special case
(no future hitobjects).

This also removes the time-based checks (result status alone should be
all we care about).
---
 .../UI/GameplaySampleTriggerSource.cs         | 20 ++++++++-----------
 1 file changed, 8 insertions(+), 12 deletions(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index 48905e7232..bceb5996b9 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -66,27 +66,23 @@ namespace osu.Game.Rulesets.UI
         protected HitObject GetMostValidObject()
         {
             // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
-            var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current)?.HitObject;
+            var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.IsHit != true)?.HitObject;
 
             // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
             if (hitObject == null)
             {
                 // This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
-                if (fallbackObject == null || fallbackObject.HitObject.StartTime < Time.Current || fallbackObject.Result?.IsHit == true)
+                if (fallbackObject == null || fallbackObject.Result?.HasResult == true)
                 {
                     // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty).
                     // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
-                    var lookup = hitObjectContainer.Entries
-                                                   .Where(e => e.Result?.HasResult != true && e.HitObject.StartTime > Time.Current)
-                                                   .OrderBy(e => e.HitObject.StartTime)
-                                                   .FirstOrDefault();
+                    fallbackObject = hitObjectContainer.Entries
+                                                       .Where(e => e.Result?.HasResult != true)
+                                                       .OrderBy(e => e.HitObject.StartTime)
+                                                       .FirstOrDefault();
 
-                    // If the lookup failed, use the previously resolved lookup (we still want to play a sound, and it is still likely the most valid result).
-                    if (lookup != null)
-                        fallbackObject = lookup;
-
-                    // If we still can't find anything, just play whatever we can to get a sound out.
-                    fallbackObject ??= hitObjectContainer.Entries.FirstOrDefault();
+                    // In the case there are no unjudged objects, the last hit object should be used instead.
+                    fallbackObject ??= hitObjectContainer.Entries.LastOrDefault();
                 }
 
                 hitObject = fallbackObject?.HitObject;

From fd78d0440bfca93e6313e546bd91d5f5aec3794d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 17:00:32 +0900
Subject: [PATCH 09/36] Update missed conditional

---
 osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index bceb5996b9..ac2067a913 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.UI
         protected HitObject GetMostValidObject()
         {
             // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
-            var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.IsHit != true)?.HitObject;
+            var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject;
 
             // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
             if (hitObject == null)

From 599145b46aaaf543146307f575ec2e0632b7f3ac Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Wed, 25 Aug 2021 11:29:48 +0300
Subject: [PATCH 10/36] Stop clocks when removing them from sync manager

---
 .../OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs   | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs
index cf0dfbb585..b8f47c16ff 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs
@@ -61,7 +61,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
             playerClocks.Add(clock);
         }
 
-        public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock);
+        public void RemovePlayerClock(ISpectatorPlayerClock clock)
+        {
+            playerClocks.Remove(clock);
+            clock.Stop();
+        }
 
         protected override void Update()
         {

From 196c74fce87048c33e6c3ed1ee099b870438adb3 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Wed, 25 Aug 2021 11:30:13 +0300
Subject: [PATCH 11/36] Gray out and remove player clock when users stop
 playing

---
 .../Multiplayer/Spectate/MultiSpectatorScreen.cs         | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
index d10917259d..bf7c738882 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics;
 using osu.Game.Online.Multiplayer;
 using osu.Game.Online.Spectator;
 using osu.Game.Screens.Play;
@@ -32,6 +33,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
         /// </summary>
         public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true);
 
+        [Resolved]
+        private OsuColour colours { get; set; }
+
         [Resolved]
         private SpectatorClient spectatorClient { get; set; }
 
@@ -215,6 +219,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
         protected override void EndGameplay(int userId)
         {
             RemoveUser(userId);
+
+            var instance = instances.Single(i => i.UserId == userId);
+
+            instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
+            syncManager.RemovePlayerClock(instance.GameplayClock);
             leaderboard.RemoveClock(userId);
         }
 

From 13acdb5f19137d5868ac946885ed29a0e3a9e6f9 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Wed, 25 Aug 2021 11:30:37 +0300
Subject: [PATCH 12/36] Add test coverage

---
 .../TestSceneMultiSpectatorScreen.cs          | 48 ++++++++++++++++++-
 1 file changed, 46 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index 18e4a6c575..1c198c11aa 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -5,6 +5,7 @@ using System.Collections.Generic;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.ObjectExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Testing;
@@ -19,6 +20,7 @@ using osu.Game.Screens.Play.HUD;
 using osu.Game.Screens.Play.PlayerSettings;
 using osu.Game.Tests.Beatmaps.IO;
 using osu.Game.Users;
+using osuTK.Graphics;
 
 namespace osu.Game.Tests.Visual.Multiplayer
 {
@@ -314,6 +316,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
             AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType<DrawableRuleset>().Single().FrameStableClock.CurrentTime > 30000);
         }
 
+        [Test]
+        public void TestPlayersLeaveWhileSpectating()
+        {
+            start(Enumerable.Range(PLAYER_1_ID, 8).ToArray());
+            sendFrames(Enumerable.Range(PLAYER_1_ID, 8).ToArray(), 300);
+
+            loadSpectateScreen();
+
+            for (int i = 7; i >= 0; i--)
+            {
+                var id = PLAYER_1_ID + i;
+
+                end(new[] { id });
+                AddUntilStep("player area grayed", () => getInstance(id).Colour != Color4.White);
+                AddUntilStep("score quit set", () => getLeaderboardScore(id).HasQuit.Value);
+                sendFrames(Enumerable.Range(PLAYER_1_ID, i).ToArray(), 300);
+            }
+        }
+
         private void loadSpectateScreen(bool waitForPlayerLoad = true)
         {
             AddStep("load screen", () =>
@@ -333,10 +354,31 @@ namespace osu.Game.Tests.Visual.Multiplayer
             {
                 foreach (int id in userIds)
                 {
-                    OnlinePlayDependencies.Client.AddUser(new User { Id = id }, true);
+                    var user = new MultiplayerRoomUser(id)
+                    {
+                        User = new User { Id = id },
+                    };
 
+                    OnlinePlayDependencies.Client.AddUser(user.User, true);
                     SpectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId);
-                    playingUsers.Add(new MultiplayerRoomUser(id));
+
+                    playingUsers.Add(user);
+                }
+            });
+        }
+
+        private void end(int[] userIds)
+        {
+            AddStep("end play", () =>
+            {
+                foreach (int id in userIds)
+                {
+                    var user = playingUsers.Single(u => u.UserID == id);
+
+                    OnlinePlayDependencies.Client.RemoveUser(user.User.AsNonNull());
+                    SpectatorClient.EndPlay(id);
+
+                    playingUsers.Remove(user);
                 }
             });
         }
@@ -374,5 +416,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
         private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType<Player>().Single();
 
         private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId);
+
+        private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId);
     }
 }

From 7e6e2a7e292b5030739c07b342c46d0f68118323 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 25 Aug 2021 17:39:06 +0900
Subject: [PATCH 13/36] Remove unused assignment

---
 .../Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
index c446a7efd1..3e0a937ffa 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay
                 },
                 new HitCircle
                 {
-                    StartTime = t += spacing,
+                    StartTime = t + spacing,
                     Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) },
                     SampleControlPoint = new SampleControlPoint { SampleBank = "soft" },
                 },

From 998abcbf31350ae033c6159904365337196cb0a9 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Wed, 25 Aug 2021 18:25:31 +0300
Subject: [PATCH 14/36] Replace occurences of `Enumerable.Range(PLAYER_1_ID,
 ...)` with a method

---
 .../Multiplayer/TestSceneMultiSpectatorScreen.cs   | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index 1c198c11aa..97c992e8ec 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
         [Test]
         public void TestGeneral()
         {
-            int[] userIds = Enumerable.Range(0, 4).Select(i => PLAYER_1_ID + i).ToArray();
+            int[] userIds = getPlayerIds(4);
 
             start(userIds);
             loadSpectateScreen();
@@ -319,19 +319,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
         [Test]
         public void TestPlayersLeaveWhileSpectating()
         {
-            start(Enumerable.Range(PLAYER_1_ID, 8).ToArray());
-            sendFrames(Enumerable.Range(PLAYER_1_ID, 8).ToArray(), 300);
+            start(getPlayerIds(8));
+            sendFrames(getPlayerIds(8), 300);
 
             loadSpectateScreen();
 
-            for (int i = 7; i >= 0; i--)
+            for (int count = 7; count >= 0; count--)
             {
-                var id = PLAYER_1_ID + i;
+                var id = PLAYER_1_ID + count;
 
                 end(new[] { id });
                 AddUntilStep("player area grayed", () => getInstance(id).Colour != Color4.White);
                 AddUntilStep("score quit set", () => getLeaderboardScore(id).HasQuit.Value);
-                sendFrames(Enumerable.Range(PLAYER_1_ID, i).ToArray(), 300);
+                sendFrames(getPlayerIds(count), 300);
             }
         }
 
@@ -418,5 +418,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
         private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId);
 
         private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId);
+
+        private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray();
     }
 }

From 5acaafa7089f28104c53685bf9db9c505d8c3890 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Wed, 25 Aug 2021 18:28:23 +0300
Subject: [PATCH 15/36] Make `end` accept one user ID rather than unnecessarily
 an array

---
 .../TestSceneMultiSpectatorScreen.cs            | 17 +++++++----------
 1 file changed, 7 insertions(+), 10 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index 97c992e8ec..c3be5c56ef 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -328,7 +328,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
             {
                 var id = PLAYER_1_ID + count;
 
-                end(new[] { id });
+                end(id);
                 AddUntilStep("player area grayed", () => getInstance(id).Colour != Color4.White);
                 AddUntilStep("score quit set", () => getLeaderboardScore(id).HasQuit.Value);
                 sendFrames(getPlayerIds(count), 300);
@@ -367,19 +367,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
             });
         }
 
-        private void end(int[] userIds)
+        private void end(int userId)
         {
-            AddStep("end play", () =>
+            AddStep($"end play for {userId}", () =>
             {
-                foreach (int id in userIds)
-                {
-                    var user = playingUsers.Single(u => u.UserID == id);
+                var user = playingUsers.Single(u => u.UserID == userId);
 
-                    OnlinePlayDependencies.Client.RemoveUser(user.User.AsNonNull());
-                    SpectatorClient.EndPlay(id);
+                OnlinePlayDependencies.Client.RemoveUser(user.User.AsNonNull());
+                SpectatorClient.EndPlay(userId);
 
-                    playingUsers.Remove(user);
-                }
+                playingUsers.Remove(user);
             });
         }
 

From ec85d7f3567569dbc9da26306b4d11ab843809ba Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 26 Aug 2021 17:15:23 +0900
Subject: [PATCH 16/36] Remove unused helper method

---
 osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index b3e1b24d8d..29d8a475ef 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -54,11 +54,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
         /// </summary>
         public readonly Bindable<Color4> AccentColour = new Bindable<Color4>(Color4.Gray);
 
-        /// <summary>
-        /// Gets the samples that are played by this object during gameplay.
-        /// </summary>
-        public ISampleInfo[] GetGameplaySamples() => Samples.Samples;
-
         protected PausableSkinnableSound Samples { get; private set; }
 
         public virtual IEnumerable<HitSampleInfo> GetSamples() => HitObject.Samples;

From 15aa0458bc801e3d3ecf6e3a8cc6d93fa0e1ea1d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 26 Aug 2021 17:15:36 +0900
Subject: [PATCH 17/36] Use `PausableSkinnableSound` instead

---
 osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index ac2067a913..c18698f77e 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.UI
             InternalChild = hitSounds = new Container<SkinnableSound>
             {
                 Name = "concurrent sample pool",
-                ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound())
+                ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound())
             };
         }
 

From f078a9d2bf942745f6f400a5c996357b27a70e21 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 26 Aug 2021 17:17:39 +0900
Subject: [PATCH 18/36] Fix incorrect step type

---
 .../Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
index 3e0a937ffa..fccc1a377c 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
@@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay
         {
             HitObjectLifetimeEntry nextObjectEntry = null;
 
-            AddUntilStep("no alive objects", () => getNextAliveObject() == null);
+            AddAssert("no alive objects", () => getNextAliveObject() == null);
 
             AddAssert("check initially correct object", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[0]);
 

From 90e81a595d0c2fafae408bc8667043682c8a2587 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 26 Aug 2021 17:19:46 +0900
Subject: [PATCH 19/36] Move `DrumSampleTriggerSource` into its own class to
 avoid nested references

---
 .../Skinning/Legacy/LegacyInputDrum.cs        |  2 +-
 .../UI/DrumSampleTriggerSource.cs             | 30 +++++++++++++++++++
 osu.Game.Rulesets.Taiko/UI/InputDrum.cs       | 20 -------------
 3 files changed, 31 insertions(+), 21 deletions(-)
 create mode 100644 osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs

diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
index 5a76694913..9d35093591 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs
@@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
             public readonly Sprite Centre;
 
             [Resolved]
-            private InputDrum.DrumSampleTriggerSource sampleTriggerSource { get; set; }
+            private DrumSampleTriggerSource sampleTriggerSource { get; set; }
 
             public LegacyHalfDrum(bool flipped)
             {
diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs
new file mode 100644
index 0000000000..3279d128d3
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Game.Audio;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Taiko.UI
+{
+    public class DrumSampleTriggerSource : GameplaySampleTriggerSource
+    {
+        public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer)
+            : base(hitObjectContainer)
+        {
+        }
+
+        public void Play(HitType hitType)
+        {
+            var hitObject = GetMostValidObject();
+
+            if (hitObject == null)
+                return;
+
+            PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) });
+        }
+
+        public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead");
+    }
+}
diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
index 24e2dddb49..3eafd201b7 100644
--- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
+++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
@@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.Input.Bindings;
-using osu.Game.Audio;
 using osu.Game.Graphics;
 using osu.Game.Rulesets.Taiko.Objects;
 using osu.Game.Rulesets.UI;
@@ -201,24 +200,5 @@ namespace osu.Game.Rulesets.Taiko.UI
             }
         }
 
-        public class DrumSampleTriggerSource : GameplaySampleTriggerSource
-        {
-            public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer)
-                : base(hitObjectContainer)
-            {
-            }
-
-            public void Play(HitType hitType)
-            {
-                var hitObject = GetMostValidObject();
-
-                if (hitObject == null)
-                    return;
-
-                PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) });
-            }
-
-            public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead");
-        }
     }
 }

From cea632463e781f59ad307e2f373632998656b41e Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Thu, 26 Aug 2021 22:30:20 +0300
Subject: [PATCH 20/36] Remove empty newline

---
 osu.Game.Rulesets.Taiko/UI/InputDrum.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
index 3eafd201b7..ddfaf64549 100644
--- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
+++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs
@@ -199,6 +199,5 @@ namespace osu.Game.Rulesets.Taiko.UI
             {
             }
         }
-
     }
 }

From b7a031619460698faa284ab71b5b4668775ab00d Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Fri, 27 Aug 2021 13:14:56 +0300
Subject: [PATCH 21/36] Shorten test player count to 4 for less steps

---
 .../Multiplayer/TestSceneMultiSpectatorScreen.cs       | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index c3be5c56ef..6f6769bdb8 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -319,18 +319,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
         [Test]
         public void TestPlayersLeaveWhileSpectating()
         {
-            start(getPlayerIds(8));
-            sendFrames(getPlayerIds(8), 300);
+            start(getPlayerIds(4));
+            sendFrames(getPlayerIds(4), 300);
 
             loadSpectateScreen();
 
-            for (int count = 7; count >= 0; count--)
+            for (int count = 3; count >= 0; count--)
             {
                 var id = PLAYER_1_ID + count;
 
                 end(id);
-                AddUntilStep("player area grayed", () => getInstance(id).Colour != Color4.White);
-                AddUntilStep("score quit set", () => getLeaderboardScore(id).HasQuit.Value);
+                AddUntilStep($"{id} area grayed", () => getInstance(id).Colour != Color4.White);
+                AddUntilStep($"{id} score quit set", () => getLeaderboardScore(id).HasQuit.Value);
                 sendFrames(getPlayerIds(count), 300);
             }
         }

From 1650fbb8be04d4a0c7deeb805651803d956fcfe1 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Fri, 27 Aug 2021 13:15:12 +0300
Subject: [PATCH 22/36] Add failing test steps

---
 .../Multiplayer/TestSceneMultiSpectatorScreen.cs      | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index 6f6769bdb8..bfcb55ce33 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -333,6 +333,17 @@ namespace osu.Game.Tests.Visual.Multiplayer
                 AddUntilStep($"{id} score quit set", () => getLeaderboardScore(id).HasQuit.Value);
                 sendFrames(getPlayerIds(count), 300);
             }
+
+            Player player = null;
+
+            AddStep($"get {PLAYER_1_ID} player instance", () => player = getInstance(PLAYER_1_ID).ChildrenOfType<Player>().Single());
+
+            start(new[] { PLAYER_1_ID });
+            sendFrames(PLAYER_1_ID, 300);
+
+            AddAssert($"{PLAYER_1_ID} player instance still same", () => getInstance(PLAYER_1_ID).ChildrenOfType<Player>().Single() == player);
+            AddAssert($"{PLAYER_1_ID} area still grayed", () => getInstance(PLAYER_1_ID).Colour != Color4.White);
+            AddAssert($"{PLAYER_1_ID} score quit still set", () => getLeaderboardScore(PLAYER_1_ID).HasQuit.Value);
         }
 
         private void loadSpectateScreen(bool waitForPlayerLoad = true)

From 378734a7f8cdd6678fcc42886f99bacfcfefcba6 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Fri, 27 Aug 2021 13:16:08 +0300
Subject: [PATCH 23/36] Separate solo spectator player and "exit on restart"
 logic to own class

---
 osu.Game/Screens/Play/SoloSpectator.cs        |  2 +-
 osu.Game/Screens/Play/SoloSpectatorPlayer.cs  | 52 +++++++++++++++++++
 osu.Game/Screens/Play/SpectatorPlayer.cs      | 30 +++--------
 .../Screens/Play/SpectatorPlayerLoader.cs     |  7 +--
 4 files changed, 61 insertions(+), 30 deletions(-)
 create mode 100644 osu.Game/Screens/Play/SoloSpectatorPlayer.cs

diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs
index 820d776e63..4520e2e825 100644
--- a/osu.Game/Screens/Play/SoloSpectator.cs
+++ b/osu.Game/Screens/Play/SoloSpectator.cs
@@ -211,7 +211,7 @@ namespace osu.Game.Screens.Play
                 Beatmap.Value = gameplayState.Beatmap;
                 Ruleset.Value = gameplayState.Ruleset.RulesetInfo;
 
-                this.Push(new SpectatorPlayerLoader(gameplayState.Score));
+                this.Push(new SpectatorPlayerLoader(gameplayState.Score, () => new SoloSpectatorPlayer(gameplayState.Score)));
             }
         }
 
diff --git a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs
new file mode 100644
index 0000000000..969a5bf2b4
--- /dev/null
+++ b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs
@@ -0,0 +1,52 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Screens;
+using osu.Game.Online.Spectator;
+using osu.Game.Scoring;
+
+namespace osu.Game.Screens.Play
+{
+    public class SoloSpectatorPlayer : SpectatorPlayer
+    {
+        private readonly Score score;
+
+        public SoloSpectatorPlayer(Score score, PlayerConfiguration configuration = null)
+            : base(score, configuration)
+        {
+            this.score = score;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            SpectatorClient.OnUserBeganPlaying += userBeganPlaying;
+        }
+
+        public override bool OnExiting(IScreen next)
+        {
+            SpectatorClient.OnUserBeganPlaying -= userBeganPlaying;
+
+            return base.OnExiting(next);
+        }
+
+        private void userBeganPlaying(int userId, SpectatorState state)
+        {
+            if (userId != score.ScoreInfo.UserID) return;
+
+            Schedule(() =>
+            {
+                if (this.IsCurrentScreen()) this.Exit();
+            });
+        }
+
+        protected override void Dispose(bool isDisposing)
+        {
+            base.Dispose(isDisposing);
+
+            if (SpectatorClient != null)
+                SpectatorClient.OnUserBeganPlaying -= userBeganPlaying;
+        }
+    }
+}
diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs
index 1dae28092a..d7e42a9cd1 100644
--- a/osu.Game/Screens/Play/SpectatorPlayer.cs
+++ b/osu.Game/Screens/Play/SpectatorPlayer.cs
@@ -14,16 +14,16 @@ using osu.Game.Screens.Ranking;
 
 namespace osu.Game.Screens.Play
 {
-    public class SpectatorPlayer : Player
+    public abstract class SpectatorPlayer : Player
     {
         [Resolved]
-        private SpectatorClient spectatorClient { get; set; }
+        protected SpectatorClient SpectatorClient { get; private set; }
 
         private readonly Score score;
 
         protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap
 
-        public SpectatorPlayer(Score score, PlayerConfiguration configuration = null)
+        protected SpectatorPlayer(Score score, PlayerConfiguration configuration = null)
             : base(configuration)
         {
             this.score = score;
@@ -32,8 +32,6 @@ namespace osu.Game.Screens.Play
         [BackgroundDependencyLoader]
         private void load()
         {
-            spectatorClient.OnUserBeganPlaying += userBeganPlaying;
-
             AddInternal(new OsuSpriteText
             {
                 Text = $"Watching {score.ScoreInfo.User.Username} playing live!",
@@ -50,7 +48,7 @@ namespace osu.Game.Screens.Play
 
             // Start gameplay along with the very first arrival frame (the latest one).
             score.Replay.Frames.Clear();
-            spectatorClient.OnNewFrames += userSentFrames;
+            SpectatorClient.OnNewFrames += userSentFrames;
         }
 
         private void userSentFrames(int userId, FrameDataBundle bundle)
@@ -93,31 +91,17 @@ namespace osu.Game.Screens.Play
 
         public override bool OnExiting(IScreen next)
         {
-            spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
-            spectatorClient.OnNewFrames -= userSentFrames;
+            SpectatorClient.OnNewFrames -= userSentFrames;
 
             return base.OnExiting(next);
         }
 
-        private void userBeganPlaying(int userId, SpectatorState state)
-        {
-            if (userId != score.ScoreInfo.UserID) return;
-
-            Schedule(() =>
-            {
-                if (this.IsCurrentScreen()) this.Exit();
-            });
-        }
-
         protected override void Dispose(bool isDisposing)
         {
             base.Dispose(isDisposing);
 
-            if (spectatorClient != null)
-            {
-                spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
-                spectatorClient.OnNewFrames -= userSentFrames;
-            }
+            if (SpectatorClient != null)
+                SpectatorClient.OnNewFrames -= userSentFrames;
         }
     }
 }
diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
index bdd23962dc..10cc36c9a9 100644
--- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
+++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
@@ -11,12 +11,7 @@ namespace osu.Game.Screens.Play
     {
         public readonly ScoreInfo Score;
 
-        public SpectatorPlayerLoader(Score score)
-            : this(score, () => new SpectatorPlayer(score))
-        {
-        }
-
-        public SpectatorPlayerLoader(Score score, Func<Player> createPlayer)
+        public SpectatorPlayerLoader(Score score, Func<SpectatorPlayer> createPlayer)
             : base(createPlayer)
         {
             if (score.Replay == null)

From e374ef163de240c91bff458fd59e10b62e2d91e7 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 29 Aug 2021 15:00:28 +0300
Subject: [PATCH 24/36] Update localisable formattable extensions usages inline
 with framework change

---
 osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs                | 2 +-
 osu.Game/Overlays/BeatmapSet/BasicStats.cs                      | 1 +
 osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs                   | 2 +-
 osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs      | 1 +
 osu.Game/Overlays/BeatmapSet/SuccessRate.cs                     | 2 +-
 osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs       | 1 +
 osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs | 1 +
 .../Profile/Header/Components/ProfileHeaderStatisticsButton.cs  | 2 +-
 osu.Game/Overlays/Profile/Header/Components/RankGraph.cs        | 1 +
 osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs       | 1 +
 osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs          | 1 +
 osu.Game/Overlays/Profile/Sections/CounterPill.cs               | 2 +-
 .../Overlays/Profile/Sections/Historical/ProfileLineChart.cs    | 1 +
 .../Overlays/Profile/Sections/Historical/UserHistoryGraph.cs    | 1 +
 osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs         | 1 +
 .../Profile/Sections/Ranks/DrawableProfileWeightedScore.cs      | 2 +-
 osu.Game/Overlays/Rankings/SpotlightSelector.cs                 | 1 +
 osu.Game/Overlays/Rankings/Tables/CountriesTable.cs             | 2 +-
 osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs           | 2 +-
 osu.Game/Overlays/Rankings/Tables/RankingsTable.cs              | 1 +
 osu.Game/Overlays/Rankings/Tables/ScoresTable.cs                | 2 +-
 osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs             | 1 +
 osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs                  | 1 +
 osu.Game/Screens/Select/Details/UserRatings.cs                  | 2 +-
 osu.Game/Utils/FormatUtils.cs                                   | 1 +
 25 files changed, 25 insertions(+), 10 deletions(-)

diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs
index c239fda455..dde7680989 100644
--- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs
+++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs
@@ -4,12 +4,12 @@
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.UserInterface;
-using osu.Framework.Localisation;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Overlays;
diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs
index 2dcb2f1777..5a6cde8229 100644
--- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs
+++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs
@@ -4,6 +4,7 @@
 using System;
 using osu.Framework.Allocation;
 using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Cursor;
diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs
index b16fb76ec3..60e341d2ac 100644
--- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs
+++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs
@@ -6,12 +6,12 @@ using System.Linq;
 using osu.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Input.Events;
-using osu.Framework.Localisation;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Drawables;
 using osu.Game.Graphics;
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
index c934020059..7704fa24df 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
@@ -3,6 +3,7 @@
 
 using System;
 using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Effects;
diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
index b1e9abe3aa..cde4589c98 100644
--- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
+++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
@@ -2,9 +2,9 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Framework.Localisation;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
index f15fa2705a..180a288729 100644
--- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
+++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
@@ -3,6 +3,7 @@
 
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs
index 877637be22..2c8c421eba 100644
--- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs
@@ -3,6 +3,7 @@
 
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Cursor;
diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
index 1235836aac..b098f9f840 100644
--- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
@@ -1,10 +1,10 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
-using osu.Framework.Localisation;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osuTK;
diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
index 74a25591b4..e8bce404e1 100644
--- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.Linq;
 using Humanizer;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Localisation;
 using osu.Game.Graphics;
diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs
index 9e52751904..8ca6961950 100644
--- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs
+++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs
@@ -4,6 +4,7 @@
 using System.Collections.Generic;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs
index 438f52a2ce..cf930e985c 100644
--- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs
+++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs
@@ -4,6 +4,7 @@
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs
index 34211b40b7..bd6cb4d09b 100644
--- a/osu.Game/Overlays/Profile/Sections/CounterPill.cs
+++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs
@@ -8,7 +8,7 @@ using osu.Game.Graphics;
 using osu.Framework.Bindables;
 using osu.Game.Graphics.Sprites;
 using osu.Framework.Allocation;
-using osu.Framework.Localisation;
+using osu.Framework.Extensions.LocalisationExtensions;
 
 namespace osu.Game.Overlays.Profile.Sections
 {
diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs
index 449b1da35d..a75235359a 100644
--- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs
+++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs
@@ -9,6 +9,7 @@ using System.Linq;
 using osu.Game.Graphics.Sprites;
 using osu.Framework.Utils;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Game.Graphics;
 using osu.Framework.Graphics.Shapes;
 using osuTK;
diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
index ac94f0fc87..d25c53b5ec 100644
--- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
+++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
@@ -5,6 +5,7 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using JetBrains.Annotations;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Localisation;
 using static osu.Game.Users.User;
 
diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs
index eb55a0a78d..762716efab 100644
--- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs
+++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs
@@ -12,6 +12,7 @@ using osu.Game.Graphics.Containers;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Users;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Game.Resources.Localisation.Web;
 using osu.Framework.Localisation;
 
diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs
index 4e4a665a60..f77464ecb9 100644
--- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs
+++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs
@@ -1,9 +1,9 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Framework.Localisation;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Resources.Localisation.Web;
diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs
index 5309778a47..0f071883ca 100644
--- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs
+++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs
@@ -16,6 +16,7 @@ using System.Collections.Generic;
 using osu.Framework.Graphics.UserInterface;
 using osu.Game.Online.API.Requests;
 using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Localisation;
 using osu.Game.Resources.Localisation.Web;
 
diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs
index 85a317728f..a908380e95 100644
--- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs
@@ -9,8 +9,8 @@ using osu.Game.Graphics;
 using osu.Game.Graphics.Containers;
 using System.Collections.Generic;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Game.Resources.Localisation.Web;
-using osu.Framework.Localisation;
 
 namespace osu.Game.Overlays.Rankings.Tables
 {
diff --git a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
index 6facf1e7a2..215cc95198 100644
--- a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
@@ -2,9 +2,9 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System.Collections.Generic;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Framework.Localisation;
 using osu.Game.Resources.Localisation.Web;
 using osu.Game.Users;
 
diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs
index bc8eac16a9..6e6230f958 100644
--- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs
@@ -10,6 +10,7 @@ using osu.Framework.Extensions;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Game.Users;
 using osu.Game.Users.Drawables;
 using osuTK;
diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs
index b6bb66e2c8..934da4501e 100644
--- a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs
@@ -2,9 +2,9 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System.Collections.Generic;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Framework.Localisation;
 using osu.Game.Resources.Localisation.Web;
 using osu.Game.Users;
 
diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs
index b96ab556df..cc2ef55a2b 100644
--- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs
@@ -3,6 +3,7 @@
 
 using System.Collections.Generic;
 using System.Linq;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Game.Graphics;
diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
index 68e3f0df7d..d04e60a2ab 100644
--- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
+++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
@@ -4,6 +4,7 @@
 using System;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs
index a7f28b932a..eabc476db9 100644
--- a/osu.Game/Screens/Select/Details/UserRatings.cs
+++ b/osu.Game/Screens/Select/Details/UserRatings.cs
@@ -8,8 +8,8 @@ using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Graphics.UserInterface;
 using System.Linq;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Game.Beatmaps;
-using osu.Framework.Localisation;
 using osu.Game.Resources.Localisation.Web;
 
 namespace osu.Game.Screens.Select.Details
diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs
index e763558647..d14dbb49f3 100644
--- a/osu.Game/Utils/FormatUtils.cs
+++ b/osu.Game/Utils/FormatUtils.cs
@@ -3,6 +3,7 @@
 
 using System;
 using Humanizer;
+using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Localisation;
 
 namespace osu.Game.Utils

From 8f3416d8534405210acb21b939ef22c011e674c9 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 29 Aug 2021 16:03:37 +0300
Subject: [PATCH 25/36] Assert PP not null when `showPerformancePoints` is true

---
 osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
index a154016824..8fe1d35b62 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
@@ -4,6 +4,7 @@
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Extensions;
@@ -192,6 +193,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
 
             if (showPerformancePoints)
             {
+                Debug.Assert(score.PP != null);
+
                 content.Add(new OsuSpriteText
                 {
                     Text = score.PP.ToLocalisableString(@"N0"),

From 6aaef7b0be3985b30f142f1e8562a744bd4373cc Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 29 Aug 2021 17:19:13 +0300
Subject: [PATCH 26/36] Handle null PP during score set in
 `TopScoreStatisticsSection`

Supersedes #14562
Closes #14541
---
 .../Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
index 23069eccdf..883e83ce6e 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
@@ -116,7 +116,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
                 maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x");
 
                 ppColumn.Alpha = value.Beatmap?.Status.GrantsPerformancePoints() == true ? 1 : 0;
-                ppColumn.Text = value.PP.ToLocalisableString(@"N0");
+                ppColumn.Text = value.PP?.ToLocalisableString(@"N0");
 
                 statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn);
                 modsColumn.Mods = value.Mods;

From 6dc11543ad527683bb8691e609ec4e486191f138 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 29 Aug 2021 17:20:33 +0300
Subject: [PATCH 27/36] Handle (null?) PP in `PerformanceTable`

---
 osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
index 215cc95198..17c17b1f1a 100644
--- a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
+++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Rankings.Tables
 
         protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[]
         {
-            new RowText { Text = item.PP.ToLocalisableString(@"N0"), }
+            new RowText { Text = item.PP?.ToLocalisableString(@"N0"), }
         };
     }
 }

From ee49305cad0f0edc6c7a7c0b5c65d8d63b95a3b8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 14:40:25 +0900
Subject: [PATCH 28/36] Move taiko legacy speed multiplier to `osu.Game`
 project

Allows it to be used in local case in `LegacyBeatmapEncoder`.
---
 .../Beatmaps/TaikoBeatmapConverter.cs                  | 10 ++--------
 osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs            |  4 ++--
 osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs      |  8 +++++++-
 3 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
index 90c99316b1..77f058fad9 100644
--- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
@@ -18,12 +18,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
 {
     internal class TaikoBeatmapConverter : BeatmapConverter<TaikoHitObject>
     {
-        /// <summary>
-        /// osu! is generally slower than taiko, so a factor is added to increase
-        /// speed. This must be used everywhere slider length or beat length is used.
-        /// </summary>
-        public const float LEGACY_VELOCITY_MULTIPLIER = 1.4f;
-
         /// <summary>
         /// Because swells are easier in taiko than spinners are in osu!,
         /// legacy taiko multiplies a factor when converting the number of required hits.
@@ -55,7 +49,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
             // Rewrite the beatmap info to add the slider velocity multiplier
             original.BeatmapInfo = original.BeatmapInfo.Clone();
             original.BeatmapInfo.BaseDifficulty = original.BeatmapInfo.BaseDifficulty.Clone();
-            original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LEGACY_VELOCITY_MULTIPLIER;
+            original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER;
 
             Beatmap<TaikoHitObject> converted = base.ConvertBeatmap(original, cancellationToken);
 
@@ -155,7 +149,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
 
             // The true distance, accounting for any repeats. This ends up being the drum roll distance later
             int spans = (obj as IHasRepeats)?.SpanCount() ?? 1;
-            double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER;
+            double distance = distanceData.Distance * spans * LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER;
 
             TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime);
             DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime);
diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
index c0377c67a5..b0634295d0 100644
--- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
@@ -6,10 +6,10 @@ using System;
 using System.Threading;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Beatmaps.Formats;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Scoring;
-using osu.Game.Rulesets.Taiko.Beatmaps;
 using osu.Game.Rulesets.Taiko.Judgements;
 using osuTK;
 
@@ -120,7 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
         double IHasDistance.Distance => Duration * Velocity;
 
         SliderPath IHasPath.Path
-            => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER);
+            => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER);
 
         #endregion
     }
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index 246dc991d5..1595ba5b8e 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -24,6 +24,12 @@ namespace osu.Game.Beatmaps.Formats
     {
         public const int LATEST_VERSION = 128;
 
+        /// <summary>
+        /// osu! is generally slower than taiko, so a factor is added to increase
+        /// speed. This must be used everywhere slider length or beat length is used.
+        /// </summary>
+        public const float LEGACY_TAIKO_VELOCITY_MULTIPLIER = 1.4f;
+
         private readonly IBeatmap beatmap;
 
         [CanBeNull]
@@ -142,7 +148,7 @@ namespace osu.Game.Beatmaps.Formats
 
             // Taiko adjusts the slider multiplier (see: TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER)
             writer.WriteLine(beatmap.BeatmapInfo.RulesetID == 1
-                ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / 1.4f}")
+                ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / LEGACY_TAIKO_VELOCITY_MULTIPLIER}")
                 : FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}"));
 
             writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate}"));

From 4adfe9a6dc3a4b05d67c4c626f2e7b2009d9b785 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 15:30:04 +0900
Subject: [PATCH 29/36] Add test coverage of double-convert stability

---
 .../Beatmaps/Formats/LegacyBeatmapEncoderTest.cs | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
index 855a75117d..96986f06d5 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
@@ -49,6 +49,22 @@ namespace osu.Game.Tests.Beatmaps.Formats
             Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration));
         }
 
+        [TestCaseSource(nameof(allBeatmaps))]
+        public void TestEncodeDecodeStabilityDoubleConvert(string name)
+        {
+            var decoded = decodeFromLegacy(beatmaps_resource_store.GetStream(name), name);
+            var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded), name);
+
+            // run an extra convert. this is expected to be stable.
+            decodedAfterEncode.beatmap = convert(decodedAfterEncode.beatmap);
+
+            sort(decoded.beatmap);
+            sort(decodedAfterEncode.beatmap);
+
+            Assert.That(decodedAfterEncode.beatmap.Serialize(), Is.EqualTo(decoded.beatmap.Serialize()));
+            Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration));
+        }
+
         [Test]
         public void TestEncodeMultiSegmentSliderWithFloatingPointError()
         {

From 6a6dac609caaad6e60d94a49224efb532e1111eb Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 15:30:17 +0900
Subject: [PATCH 30/36] Fix instability of taiko double conversion

Until now, the taiko speed multiplier was potentially applied more than
once if conversion was run multiple times.
---
 .../Beatmaps/TaikoBeatmapConverter.cs         | 19 +++++++++++++++----
 osu.Game/Beatmaps/BeatmapDifficulty.cs        | 18 +++++++++++++++++-
 2 files changed, 32 insertions(+), 5 deletions(-)

diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
index 77f058fad9..9b73e644c5 100644
--- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
@@ -46,10 +46,12 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
 
         protected override Beatmap<TaikoHitObject> ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken)
         {
-            // Rewrite the beatmap info to add the slider velocity multiplier
-            original.BeatmapInfo = original.BeatmapInfo.Clone();
-            original.BeatmapInfo.BaseDifficulty = original.BeatmapInfo.BaseDifficulty.Clone();
-            original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER;
+            if (!(original.BeatmapInfo.BaseDifficulty is TaikoMutliplierAppliedDifficulty))
+            {
+                // Rewrite the beatmap info to add the slider velocity multiplier
+                original.BeatmapInfo = original.BeatmapInfo.Clone();
+                original.BeatmapInfo.BaseDifficulty = new TaikoMutliplierAppliedDifficulty(original.BeatmapInfo.BaseDifficulty);
+            }
 
             Beatmap<TaikoHitObject> converted = base.ConvertBeatmap(original, cancellationToken);
 
@@ -188,5 +190,14 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
         }
 
         protected override Beatmap<TaikoHitObject> CreateBeatmap() => new TaikoBeatmap();
+
+        private class TaikoMutliplierAppliedDifficulty : BeatmapDifficulty
+        {
+            public TaikoMutliplierAppliedDifficulty(BeatmapDifficulty difficulty)
+            {
+                difficulty.CopyTo(this);
+                SliderMultiplier *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER;
+            }
+        }
     }
 }
diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs
index c56fec67aa..1844b193f2 100644
--- a/osu.Game/Beatmaps/BeatmapDifficulty.cs
+++ b/osu.Game/Beatmaps/BeatmapDifficulty.cs
@@ -32,7 +32,23 @@ namespace osu.Game.Beatmaps
         /// <summary>
         /// Returns a shallow-clone of this <see cref="BeatmapDifficulty"/>.
         /// </summary>
-        public BeatmapDifficulty Clone() => (BeatmapDifficulty)MemberwiseClone();
+        public BeatmapDifficulty Clone()
+        {
+            var diff = new BeatmapDifficulty();
+            CopyTo(diff);
+            return diff;
+        }
+
+        public void CopyTo(BeatmapDifficulty difficulty)
+        {
+            difficulty.ApproachRate = ApproachRate;
+            difficulty.DrainRate = DrainRate;
+            difficulty.CircleSize = CircleSize;
+            difficulty.OverallDifficulty = OverallDifficulty;
+
+            difficulty.SliderMultiplier = SliderMultiplier;
+            difficulty.SliderTickRate = SliderTickRate;
+        }
 
         /// <summary>
         /// Maps a difficulty value [0, 10] to a two-piece linear range of values.

From 58a052ea1f7bf15a980870d48b7176b9e5c026d8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 16:00:07 +0900
Subject: [PATCH 31/36] Update framework

---
 osu.Android.props        | 2 +-
 osu.Game/osu.Game.csproj | 2 +-
 osu.iOS.props            | 4 ++--
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/osu.Android.props b/osu.Android.props
index f18400bf2f..8a9bf1b9cd 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
   </ItemGroup>
   <ItemGroup>
     <PackageReference Include="ppy.osu.Game.Resources" Version="2021.827.0" />
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2021.828.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2021.830.0" />
   </ItemGroup>
   <ItemGroup Label="Transitive Dependencies">
     <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a89d57cd1f..b4d4aa3070 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
     <PackageReference Include="Realm" Version="10.3.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2021.828.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2021.830.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2021.827.0" />
     <PackageReference Include="Sentry" Version="3.8.3" />
     <PackageReference Include="SharpCompress" Version="0.28.3" />
diff --git a/osu.iOS.props b/osu.iOS.props
index bb4700a081..29e9b9fe20 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
     <Reference Include="System.Net.Http" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2021.828.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2021.830.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2021.827.0" />
   </ItemGroup>
   <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@@ -93,7 +93,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
-    <PackageReference Include="ppy.osu.Framework" Version="2021.828.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2021.830.0" />
     <PackageReference Include="SharpCompress" Version="0.28.3" />
     <PackageReference Include="NUnit" Version="3.13.2" />
     <PackageReference Include="SharpRaven" Version="2.4.0" />

From fa2bf421886fe70c14706ae2d41831afcf7e49fb Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 16:04:54 +0900
Subject: [PATCH 32/36] Update tooltip implementations

---
 osu.Game/Beatmaps/Drawables/DifficultyIcon.cs |  6 ++----
 .../Graphics/Cursor/OsuTooltipContainer.cs    |  9 ++-------
 osu.Game/Graphics/DateTooltip.cs              |  8 ++------
 osu.Game/Overlays/Mods/ModButtonTooltip.cs    | 19 ++++---------------
 .../Profile/Header/Components/RankGraph.cs    |  5 ++---
 .../Sections/Historical/UserHistoryGraph.cs   |  5 ++---
 osu.Game/Overlays/Profile/UserGraph.cs        |  2 +-
 7 files changed, 15 insertions(+), 39 deletions(-)

diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs
index 3210ef0112..199f719893 100644
--- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs
+++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs
@@ -259,10 +259,10 @@ namespace osu.Game.Beatmaps.Drawables
 
             private readonly IBindable<StarDifficulty> starDifficulty = new Bindable<StarDifficulty>();
 
-            public bool SetContent(object content)
+            public void SetContent(object content)
             {
                 if (!(content is DifficultyIconTooltipContent iconContent))
-                    return false;
+                    return;
 
                 difficultyName.Text = iconContent.Beatmap.Version;
 
@@ -273,8 +273,6 @@ namespace osu.Game.Beatmaps.Drawables
                     starRating.Text = $"{difficulty.NewValue.Stars:0.##}";
                     difficultyFlow.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars);
                 }, true);
-
-                return true;
             }
 
             public void Move(Vector2 pos) => Position = pos;
diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs
index 81dca99ddd..35d7b4e795 100644
--- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs
+++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs
@@ -31,12 +31,9 @@ namespace osu.Game.Graphics.Cursor
             private readonly OsuSpriteText text;
             private bool instantMovement = true;
 
-            public override bool SetContent(object content)
+            public override void SetContent(LocalisableString contentString)
             {
-                if (!(content is LocalisableString contentString))
-                    return false;
-
-                if (contentString == text.Text) return true;
+                if (contentString == text.Text) return;
 
                 text.Text = contentString;
 
@@ -47,8 +44,6 @@ namespace osu.Game.Graphics.Cursor
                 }
                 else
                     AutoSizeDuration = 0;
-
-                return true;
             }
 
             public OsuTooltip()
diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs
index 67fcab43f7..3094f9cc2b 100644
--- a/osu.Game/Graphics/DateTooltip.cs
+++ b/osu.Game/Graphics/DateTooltip.cs
@@ -12,7 +12,7 @@ using osuTK;
 
 namespace osu.Game.Graphics
 {
-    public class DateTooltip : VisibilityContainer, ITooltip
+    public class DateTooltip : VisibilityContainer, ITooltip<DateTimeOffset>
     {
         private readonly OsuSpriteText dateText, timeText;
         private readonly Box background;
@@ -63,14 +63,10 @@ namespace osu.Game.Graphics
         protected override void PopIn() => this.FadeIn(200, Easing.OutQuint);
         protected override void PopOut() => this.FadeOut(200, Easing.OutQuint);
 
-        public bool SetContent(object content)
+        public void SetContent(DateTimeOffset date)
         {
-            if (!(content is DateTimeOffset date))
-                return false;
-
             dateText.Text = $"{date:d MMMM yyyy} ";
             timeText.Text = $"{date:HH:mm:ss \"UTC\"z}";
-            return true;
         }
 
         public void Move(Vector2 pos) => Position = pos;
diff --git a/osu.Game/Overlays/Mods/ModButtonTooltip.cs b/osu.Game/Overlays/Mods/ModButtonTooltip.cs
index 666ed07e28..89fcd61d76 100644
--- a/osu.Game/Overlays/Mods/ModButtonTooltip.cs
+++ b/osu.Game/Overlays/Mods/ModButtonTooltip.cs
@@ -18,7 +18,7 @@ using osuTK;
 
 namespace osu.Game.Overlays.Mods
 {
-    public class ModButtonTooltip : VisibilityContainer, ITooltip
+    public class ModButtonTooltip : VisibilityContainer, ITooltip<Mod>
     {
         private readonly OsuSpriteText descriptionText;
         private readonly Box background;
@@ -82,12 +82,9 @@ namespace osu.Game.Overlays.Mods
 
         private Mod lastMod;
 
-        public bool SetContent(object content)
+        public void SetContent(Mod mod)
         {
-            if (!(content is Mod mod))
-                return false;
-
-            if (mod.Equals(lastMod)) return true;
+            if (mod.Equals(lastMod)) return;
 
             lastMod = mod;
 
@@ -99,15 +96,7 @@ namespace osu.Game.Overlays.Mods
 
             incompatibleMods.Value = allMods.Where(m => m.GetType() != mod.GetType() && incompatibleTypes.Any(t => t.IsInstanceOfType(m))).ToList();
 
-            if (!incompatibleMods.Value.Any())
-            {
-                incompatibleText.Text = "Compatible with all mods";
-                return true;
-            }
-
-            incompatibleText.Text = "Incompatible with:";
-
-            return true;
+            incompatibleText.Text = !incompatibleMods.Value.Any() ? "Compatible with all mods" : "Incompatible with:";
         }
 
         public void Move(Vector2 pos) => Position = pos;
diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
index e8bce404e1..7ba8ae7c80 100644
--- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs
@@ -81,14 +81,13 @@ namespace osu.Game.Overlays.Profile.Header.Components
             {
             }
 
-            public override bool SetContent(object content)
+            public override void SetContent(object content)
             {
                 if (!(content is TooltipDisplayContent info))
-                    return false;
+                    return;
 
                 Counter.Text = info.Rank;
                 BottomText.Text = info.Time;
-                return true;
             }
         }
 
diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
index d25c53b5ec..85287d2325 100644
--- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
+++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs
@@ -50,14 +50,13 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
                 this.tooltipCounterName = tooltipCounterName;
             }
 
-            public override bool SetContent(object content)
+            public override void SetContent(object content)
             {
                 if (!(content is TooltipDisplayContent info) || info.Name != tooltipCounterName)
-                    return false;
+                    return;
 
                 Counter.Text = info.Count;
                 BottomText.Text = info.Date;
-                return true;
             }
         }
 
diff --git a/osu.Game/Overlays/Profile/UserGraph.cs b/osu.Game/Overlays/Profile/UserGraph.cs
index b7a08b6c5e..b88cc32ff7 100644
--- a/osu.Game/Overlays/Profile/UserGraph.cs
+++ b/osu.Game/Overlays/Profile/UserGraph.cs
@@ -268,7 +268,7 @@ namespace osu.Game.Overlays.Profile
                 background.Colour = colours.Gray1;
             }
 
-            public abstract bool SetContent(object content);
+            public abstract void SetContent(object content);
 
             private bool instantMove = true;
 

From 678386f5c4672bda1c762ccbe26673259808e928 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 16:05:56 +0900
Subject: [PATCH 33/36] Fix missed null coalesce

---
 osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
index 7704fa24df..5c3906cb39 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs
@@ -128,7 +128,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
 
         public int? ScorePosition
         {
-            set => rankText.Text = value == null ? (LocalisableString)"-" : value.ToLocalisableString(@"\##");
+            set => rankText.Text = value?.ToLocalisableString(@"\##") ?? (LocalisableString)"-";
         }
 
         /// <summary>

From da7a871afa1edc784fe111e4f500baaa9d1f740e Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 30 Aug 2021 16:27:24 +0900
Subject: [PATCH 34/36] Update inline comment to point to new variable location

Co-authored-by: PercyDan <50285552+PercyDan54@users.noreply.github.com>
---
 osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index 1595ba5b8e..fb6806a13d 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -146,7 +146,7 @@ namespace osu.Game.Beatmaps.Formats
             writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty}"));
             writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.BeatmapInfo.BaseDifficulty.ApproachRate}"));
 
-            // Taiko adjusts the slider multiplier (see: TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER)
+            // Taiko adjusts the slider multiplier (see: LEGACY_TAIKO_VELOCITY_MULTIPLIER)
             writer.WriteLine(beatmap.BeatmapInfo.RulesetID == 1
                 ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / LEGACY_TAIKO_VELOCITY_MULTIPLIER}")
                 : FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}"));

From 04bf667d0db6de78dfb8e3e17a0f3d371d42023b Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Mon, 30 Aug 2021 17:49:18 +0800
Subject: [PATCH 35/36] Parse partially typed enum names in filter query

---
 osu.Game/Screens/Select/FilterQueryParser.cs | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
index 72d10019b2..591a632a7c 100644
--- a/osu.Game/Screens/Select/FilterQueryParser.cs
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -3,8 +3,8 @@
 
 using System;
 using System.Globalization;
+using System.Linq;
 using System.Text.RegularExpressions;
-using osu.Game.Beatmaps;
 using osu.Game.Screens.Select.Filter;
 
 namespace osu.Game.Screens.Select
@@ -64,8 +64,7 @@ namespace osu.Game.Screens.Select
                     return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt);
 
                 case "status":
-                    return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value,
-                        (string s, out BeatmapSetOnlineStatus val) => Enum.TryParse(value, true, out val));
+                    return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, tryParseEnum);
 
                 case "creator":
                     return TryUpdateCriteriaText(ref criteria.Creator, op, value);
@@ -120,6 +119,14 @@ namespace osu.Game.Screens.Select
         private static bool tryParseInt(string value, out int result) =>
             int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
 
+        private static bool tryParseEnum<TEnum>(string value, out TEnum result) where TEnum : struct
+        {
+            if (Enum.TryParse(value, true, out result)) return true;
+
+            string status = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture));
+            return Enum.TryParse(status, true, out result);
+        }
+
         /// <summary>
         /// Attempts to parse a keyword filter with the specified <paramref name="op"/> and textual <paramref name="value"/>.
         /// If the value indicates a valid textual filter, the function returns <c>true</c> and the resulting data is stored into

From 8137eee527e93bc5963533b0ea3404cde110c4b1 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Mon, 30 Aug 2021 18:05:47 +0800
Subject: [PATCH 36/36] Reuse `value` to save enum name

Co-authored-by: Salman Ahmed <frenzibyte@gmail.com>
---
 osu.Game/Screens/Select/FilterQueryParser.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
index 591a632a7c..a882148392 100644
--- a/osu.Game/Screens/Select/FilterQueryParser.cs
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -123,8 +123,8 @@ namespace osu.Game.Screens.Select
         {
             if (Enum.TryParse(value, true, out result)) return true;
 
-            string status = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture));
-            return Enum.TryParse(status, true, out result);
+            value = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture));
+            return Enum.TryParse(value, true, out result);
         }
 
         /// <summary>